fix(grader): morphological stem for plural/participle attribute drift (ORO-1924) - #251
Merged
Merged
Conversation
…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.
shardi-b
approved these changes
Aug 18, 2026
shardi-b
left a comment
Contributor
There was a problem hiding this comment.
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_tokensentries 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-sguard keepspass/class/dressintact, the empty-stem guard prevents./-/黑色from bucketing under one bogus canonical, and the number+unit fuse keeps42w≠42. 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):
-ed/-ingover-stemming can collide distinct values — I confirmed_hit("material","coating",[("material","coated")])isTrue(both stem tocoat), as doprocessed↔processingandbooted↔boots. 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.- The
esregex alternative is effectively dead: greedy[a-z]{3,}reaches thesbranch first on backtracking, soboxes→boxeand_hit("t","boxes",[("t","box")])isFalse. 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 insrc/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):
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_formhelpers._build_attr_indexgains stem-side entries inval_keys+key_tokens._attr_constraint_hitgains 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-consonantpass→pasguard, 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):
Documentation
Documentation Changes:
_attr_constraint_hitdocstring now enumerates path (4) with examples. Inline comments on the stem helpers cite the backtest numbers.Checklist
Additional Notes
v1.2.7, GHA buildsghcr.io/oro-ai/oro/validator:stable+:latest, Watchtower rolls prod + staging validator (~10 min). No CDK change.s/es/ed/ingoff ≥3-char bases with a double-sguard. No-er/-est/-ly/-tioncascade — each has known catalog collisions.boxes → boxe(regex greedy) is a known no-op; broadening to a Porter-style-esrule is deferred until real data shows the gap.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."32 ohms"for a slot the catalog stores as"32 ohm"), and stuffs junk values intocolorfamily(.,-,@). Fixing at the source would shrink the reliance on the grader stem entirely.