matrix: roleNormalize allowlist for non-plural-s tokens (scrum role_gate_v1)

3-lineage scrum review of the role-gate work (commits 7f2f112..0331288)
ran Opus 4.7 / Kimi K2.6 / Qwen3-coder via scripts/scrum_review.sh.
All three flagged the same edge case: the homegrown plural-stripper
in roleNormalize would collapse non-plural-s tokens like "Sales" →
"Sale", "Logistics" → "Logistic", "Operations" → "Operation". In a
staffing domain those are real role names; the silent normalization
would have caused false role-equality matches and re-opened the
cross-role bleed for those clusters.

Fix:
- nonPluralSWords allowlist for known staffing-domain non-plural-s
  tokens (sales, logistics, operations, facilities, premises, news,
  physics, economics, mathematics, analytics).
- Last-word-only stripping ("Sales Associate" stays whole; only
  "Associates" head noun is plural-checked).
- -ss ending check so "Press Operator", "Boss" don't lose their s.
- strings.ToLower + strings.TrimSpace replace the homegrown rune-
  loop ASCII normalizer (Opus INFO — minor cleanup, folded in).

Tests:
- TestRoleNormalize_NonPluralS: 18 cases covering the allowlist,
  -ss ending, real plurals (Operators → Operator, Boxes → Box),
  multi-word real plurals (Forklift Operators → forklift operator),
  whitespace/case tolerance.
- TestRoleEqual_NonPluralS: gate-level pairing — proves equal-
  shape allowlisted tokens compare equal AND that "Sales" ≠ "Sale"
  (the original bug shape).
- Existing TestRoleEqual_PluralAndCase still green (refactor
  preserved behavior).

Other scrum findings dispositioned (not actioned):
- Opus WARN on empty-role fail-open semantics: documented
  backward-compat behavior; production path closes via opt-in LLM
  extractor (real_004).
- Opus INFO on unsynchronized package-global cache map: harness is
  single-goroutine; add sync.Mutex when/if it parallelizes.
- Opus INFO on parallel constructor (NewPlaybookEntryWithRole vs
  optional arg): API smell only, both forms preserved.
- Kimi 2 BLOCKs (NewPlaybookEntryWithRole missing, ApplyPlaybookBoost
  signature breakage): FALSE positives. Pre-push smoke chain green
  on 0331288, both symbols + all call sites compile clean. Matches
  feedback_cross_lineage_review.md's documented Kimi truncation
  behavior — Kimi BLOCKs warrant trace verification before action.

Disposition (local): reports/scrum/_evidence/2026-04-30/verdicts/role_gate_v1_disposition.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-30 22:58:02 -05:00
co-authored by Claude Opus 4.7
parent 0331288641
commit 434f466288
3 changed files with 134 additions and 32 deletions
+74
View File
@@ -556,6 +556,80 @@ func TestRoleEqual_PluralAndCase(t *testing.T) {
}
}
// TestRoleNormalize_NonPluralS locks the convergent finding from the
// role_gate_v1 scrum (Opus + Kimi + Qwen): words ending in 's' that
// aren't plurals must NOT be stripped. Bug shape per Opus' write-up:
// the original implementation collapsed "Sales" to "Sale", "Logistics"
// to "Logistic", etc., which would silently fail role-equality on
// every staffing client whose roles include those tokens.
func TestRoleNormalize_NonPluralS(t *testing.T) {
cases := []struct {
in, want string
}{
// Allowlist: end in 's' but represent role names as-is.
{"Sales", "sales"},
{"Sales Associate", "sales associate"},
{"Sales Associates", "sales associate"}, // plural-stripped on the head noun only
{"Logistics", "logistics"},
{"Logistics Coordinator", "logistics coordinator"},
{"Operations Manager", "operations manager"},
{"Facilities", "facilities"},
// -ss (not plural): must not strip.
{"Press", "press"},
{"Press Operator", "press operator"},
{"Boss", "boss"},
// Real plurals still strip correctly.
{"Operators", "operator"},
{"Pickers", "picker"},
{"Boxes", "box"},
{"Dishes", "dish"},
// Multi-word real plurals: strip head only.
{"Forklift Operators", "forklift operator"},
{"Production Workers", "production worker"},
// Whitespace + case tolerance.
{" CNC Operator ", "cnc operator"},
{"FORKLIFT OPERATOR", "forklift operator"},
// Empty.
{"", ""},
}
for _, c := range cases {
got := roleNormalize(c.in)
if got != c.want {
t.Errorf("roleNormalize(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestRoleEqual_NonPluralS is the gate-level pairing for
// TestRoleNormalize_NonPluralS — proves that two equal-shape role
// strings compare equal AND two different-role strings (one plural,
// one allowlisted) compare different.
func TestRoleEqual_NonPluralS(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
// Same role, allowlisted token: must stay equal.
{"Sales", "sales", true},
{"Logistics Coordinator", "logistics coordinator", true},
{"Press Operator", "PRESS OPERATOR", true},
// Allowlisted vs naïve-singular (the original bug): Sales
// would have falsely matched "Sale" if normalize stripped 's'.
// "Sale" isn't in the allowlist + isn't a plural-of-something,
// so they should NOT match.
{"Sales", "Sale", false},
// Plural ↔ singular cross-comparison still works for legit plurals.
{"Sales Associates", "Sales Associate", true},
{"Forklift Operators", "Forklift Operator", true},
}
for _, c := range cases {
got := roleEqual(c.a, c.b)
if got != c.want {
t.Errorf("roleEqual(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
func abs(f float64) float64 {
if f < 0 {
return -f