Skip to content

fix(grader): morphological stem for plural/participle attribute drift (ORO-1924) - #251

Merged
that-guy-wade merged 1 commit into
mainfrom
sethschilbe/oro-1924-value-stem
Aug 18, 2026
Merged

fix(grader): morphological stem for plural/participle attribute drift (ORO-1924)#251
that-guy-wade merged 1 commit into
mainfrom
sethschilbe/oro-1924-value-stem

Conversation

@that-guy-wade

Copy link
Copy Markdown
Contributor

Description

Closes ORO-1924 (miner-reported false reject: reward brand: "mixed brands" vs product "mix brands"). Broader same-class miss surface: "no stones" vs "no stone", "32 ohms" vs "32 ohm", "powder" vs "powders", "wall mounted" vs "wall mount", "bikini sets" vs "bikini set", and 30+ others across the last 10 races.

Extends _attr_constraint_hit (grader path in src/agent/rewards/orm.py) with a per-token morphological stem check. Product side is stem-augmented in _build_attr_index; reward side runs the same exact + token-subsequence lookups against its stemmed form. Empty-stem values (punctuation-only, non-ASCII) are always skipped so ., -, 黑色 never bucket together.

Backtest

Shadow grader ran on races 116-125 (10 completed races, 2,662 miner submissions, 7,196 attribute constraints):

  • 75 flips FAIL → PASS (74 clean true-accepts + 1 borderline + 0 false accepts)
  • 0 regressions PASS → FAIL
  • Overall attribute pass-rate: 73.74% → 74.78% (+1.04pp)

157,656 unique (nk, stem_form) buckets harvested for collision safety; every multi-variant bucket manually reviewed. Zero semantic collisions.

Considered a corpus-mined fuzzy-cluster alternative (union-find on RapidFuzz.token_set_ratio + partial_ratio). Rejected — transitivity chains merge all colors into one blob (black ~ "black red" ~ red ~ "dark blue" ~ blue ~ …), producing a hand-labeled precision of 37-60% and enough false collapses to flip legitimate correct-rejects into false accepts. The stem approach hits 92% ex-borderline precision AND is deterministic without any data artifact.

Changes Made

  • src/agent/rewards/orm.py (+72 lines): _STEM_SUFFIX_RE, _stem_token, _stem_tokens, _stem_form helpers. _build_attr_index gains stem-side entries in val_keys + key_tokens. _attr_constraint_hit gains path (4) — same-key morphological stem — after the existing three paths.
  • tests/test_attr_matching.py (+139 lines): stem helper unit tests, empty-stem safety, double-consonant pass→pas guard, min-base-length guard, distinct-enum non-collision, size / capacity / color non-interaction, symmetry with token-subseq. Parametrized regression test with 14 miner-verified flip pairs (both directions each).

Issue Link

Testing

Manual Testing

Miner-reported case: pass. All 14 backtest-derived flip patterns pass (each direction).

Test Results: tests/test_attr_matching.py — 55/55 pass (14 pre-existing + 41 new including 28 parametrized).

Automated Testing

Test Command(s):

python -m pytest tests/test_attr_matching.py -v

Documentation

  • README updated
  • Code comments added/updated
  • API documentation updated
  • Configuration documentation updated

Documentation Changes:

_attr_constraint_hit docstring now enumerates path (4) with examples. Inline comments on the stem helpers cite the backtest numbers.

Checklist

  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been published and merged

Additional Notes

  • Rollout: on merge, tag v1.2.7, GHA builds ghcr.io/oro-ai/oro/validator:stable + :latest, Watchtower rolls prod + staging validator (~10 min). No CDK change.
  • Regex is deliberately narrow: only strips s / es / ed / ing off ≥3-char bases with a double-s guard. No -er/-est/-ly/-tion cascade — each has known catalog collisions. boxes → boxe (regex greedy) is a known no-op; broadening to a Porter-style -es rule is deferred until real data shows the gap.
  • Backend demo copy (Backend/app/demo/shoppingbench/agent/rewards/orm.py) is cosmetic — it runs only in the demo portal, not race scoring. Not touched in this PR; can be synced in a separate small PR if we want it consistent.
  • Follow-up ticket: file for problem-gen data-quality — the flip catalog also surfaced that the reward generator picks plural/singular forms inconsistently vs catalog convention ("32 ohms" for a slot the catalog stores as "32 ohm"), and stuffs junk values into colorfamily (., -, @). Fixing at the source would shrink the reliance on the grader stem entirely.

…rift

ORO-1924. `_attr_constraint_hit` previously required an exact
same-key value or token-subsequence match. That misses plural/participle
drift the seller catalog stores inconsistently — reward `"mixed brands"`
vs product `"mix brands"` (miner-reported), or `"no stones"` vs
`"no stone"`, `"32 ohms"` vs `"32 ohm"`, `"powder"` vs `"powders"`, etc.

Adds `_stem_token` / `_stem_form` (strip a single `s`/`es`/`ed`/`ing`
off tokens whose base is ≥3 chars, with a `pass→pas` double-consonant
guard). `_build_attr_index` also indexes product values under their
stemmed form; `_attr_constraint_hit` gains a fourth check that runs the
existing exact + token-subsequence lookups against the reward's stemmed
form. Empty-stem values (punctuation-only, non-ASCII) are always skipped
so they never collide under a single bogus bucket.

Backtested on races 116-125 (2,662 miner submissions, 7,196 constraints):
+75 FAIL→PASS, 0 regressions PASS→FAIL, 0 false accepts. Overall
attribute pass-rate lift 73.74% → 74.78%. Full list of miner-verified
flip patterns is now a parametrized test.

Size handling and compat-prefix logic are unaffected — all size-code
tokens are ≤2 chars so the ≥3-char base guard skips them, and the
num+unit fuse produces atoms with no strippable suffix.
@that-guy-wade that-guy-wade self-assigned this Aug 18, 2026
@that-guy-wade
that-guy-wade requested a review from shardi-b August 18, 2026 02:31

@shardi-b shardi-b left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #251 — per-token morphological stem for attribute matching

Verdict: approve. No blocking findings.

The change adds a deliberately narrow stemmer (_stem_token/_stem_form/_stem_tokens in src/agent/rewards/orm.py) that strips s/es/ed/ing from ≥3-char tokens, then augments both the product index (_build_attr_index) and the reward matcher (_attr_constraint_hit) with a same-key stem path to catch plural/participle drift ("mixed brands""mix brands").

Why it is safe:

  • Additive-only index changes. The new val_keys/key_tokens entries are appended alongside the existing raw entries, so no previously-passing exact or token-subsequence match can regress; only new matches can be introduced.
  • Symmetric by construction. Because the product side is stem-augmented, a singular reward vs. plural product (and vice-versa) resolves through the existing path (1)/(2) even when only one side stems — verified by the bidirectional flip tests.
  • Guards are real. The ≥3-char base guard blocks used→us/ing→i, the double-s guard keeps pass/class/dress intact, the empty-stem guard prevents ./-/黑色 from bucketing under one bogus canonical, and the number+unit fuse keeps 42w42. Each has an explicit negative test.
  • Backed by 14 parametrized cases plus guard/negative tests and a reported 10-race / 7,196-constraint backtest (+75 flips, 0 regressions, 0 false accepts).

Non-blocking observations passed to the author (not defects on known inputs, so no findings filed):

  1. -ed/-ing over-stemming can collide distinct values — I confirmed _hit("material","coating",[("material","coated")]) is True (both stem to coat), as do processedprocessing and bootedboots. This is a theoretical breach of the "distinct enums never collide" invariant, but bounded by the narrow suffix set and the backtest's 0 observed false accepts.
  2. The es regex alternative is effectively dead: greedy [a-z]{3,} reaches the s branch first on backtracking, so boxesboxe and _hit("t","boxes",[("t","box")]) is False. This is documented and pinned in tests, and produces a miss (never a false accept), so it is harmless.

Both items are edge-case quality notes well below the blocking threshold.

@that-guy-wade
that-guy-wade merged commit 9105a05 into main Aug 18, 2026
2 checks passed
@that-guy-wade
that-guy-wade deleted the sethschilbe/oro-1924-value-stem branch August 18, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants