feat(challenge): D5 stemmed prereq matcher + D9 cache removal (0.21.0)#120
feat(challenge): D5 stemmed prereq matcher + D9 cache removal (0.21.0)#120
Conversation
Closes the last two vodka anti-pattern remnants in oddkit_challenge per P1.3.3, mirroring the matchers and the no-microsecond-caching discipline gate shipped in 0.20.0. Item 1 — D5 split-by-fit applied to challenge: - evaluatePrerequisiteCheck migrated from regex-per-check to stemmed set intersection over PrereqMatchVocab (parsed once at canon-fetch). - New parseCheckColumn helper extracts quoted vocabulary -> stemmedTokens Set and detects the four structural-test hints (URL, numeric, proper-noun, citation). - BasePrerequisite + ChallengeTypeDef.prerequisiteOverlays both extended with PrereqMatchVocab via interface mixin. - Runtime: tokenize(input) hoisted out of the per-prereq loop; per-prereq cost is now a Set lookup not a regex compile. - Strictly additive: every input that matched the prior regex still matches; stemmed variations newly match; structural side-tests preserved verbatim from pre-refactor. Item 2 — D9 applied to cachedChallengeTypeIndex: - Module-level cachedChallengeTypeIndex + URL companion deleted. - getOrBuildChallengeTypeIndex function deleted. - cleanup_storage resets deleted. - runChallengeAction call site rebuilds the BM25 type index inline per request (microsecond derivation; plumbing tax removed). Same pattern gate shipped in 0.20.0. Item 3 — graduates new canon principle (separate canon PR, merged first): - klappy://canon/principles/cache-fetches-and-parses live at klappy.dev 3726073 (PR #125). Third deciding-argument recurrence satisfied. Verification: - typecheck clean - governance-parser.test.mjs 105/105 pass - ~9 new smoke assertions covering stemmed base + per-type matches, structural-test preservation (URL, proper-noun, citation), rebuild stability, and pre-refactor backward compat. - Lockfile re-synced to 0.21.0 (was stale at 0.18.0 since 0.19.0 release). PRD: /home/claude/work/prd-p1-3-3.md (working dir, not committed). Handoff: klappy://odd/handoffs/2026-04-20-p1-3-3-challenge-revisit.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
oddkit | 400eb57 | Commit Preview URL Branch Preview URL |
Apr 20 2026, 04:05 AM |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Stop-word filtering silently drops quoted canon keywords
- Passed an empty stop-words Set to tokenize() in parseCheckColumn so canon vocab like "from" is preserved and the length>=20 fallback is no longer triggered by all-stop-word vocabularies.
- ✅ Fixed:
BasePrerequisiteduplicatesPrereqMatchVocabfields instead of reusing- Redefined BasePrerequisite as a type alias intersecting its own fields with PrereqMatchVocab so future field additions to the shared shape propagate automatically.
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 400eb57. Configure here.
| // "according to" contribute multiple stems; stop-words are dropped | ||
| // by tokenize(). This preserves semantic coverage while normalizing | ||
| // morphology (problems → problem, considered → consid, etc.). | ||
| for (const stem of tokenize(m[1])) { |
There was a problem hiding this comment.
Stop-word filtering silently drops quoted canon keywords
Medium Severity
parseCheckColumn calls tokenize(m[1]) on each quoted keyword, which applies default STOP_WORDS filtering. Canon vocabulary words that are English stop words — notably "from", explicitly listed in the CHANGELOG as source-named vocab — are silently dropped from stemmedTokens. The old regex evaluator matched these literally against raw input with no stop-word filtering. This creates two behavioral changes: (1) when a stop word is the only matching keyword for an input, the old code passes but the new code fails (false negative); (2) when all quoted keywords are stop words, stemmedTokens is empty, causing the length >= 20 fallback to trigger inappropriately (false positive). This violates the documented "strictly additive" guarantee. Passing an empty Set as the second argument to tokenize in parseCheckColumn would preserve the old matching semantics.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 400eb57. Configure here.
| hasNumericCheck: boolean; | ||
| hasProperNounCheck: boolean; | ||
| hasCitationCheck: boolean; | ||
| } |
There was a problem hiding this comment.
BasePrerequisite duplicates PrereqMatchVocab fields instead of reusing
Low Severity
PrereqMatchVocab was introduced as a "shared shape" to keep BasePrerequisite and ChallengeTypeDef.prerequisiteOverlays in sync (per the PR description and the interface's own doc comment). However, BasePrerequisite manually declares the same five fields rather than using & PrereqMatchVocab intersection or extending PrereqMatchVocab. Only prerequisiteOverlays actually uses the shared interface. This defeats the stated DRY purpose — the two interfaces can drift independently if PrereqMatchVocab is later updated.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 400eb57. Configure here.
….21.1) Two fixes from Cursor Bugbot findings on PR #120 / #121 that should have blocked the 0.21.0 ship and didn't because the orchestrator treated Bugbot's in_progress state as non-blocking. The findings are real and the medium-severity one is a prod regression vs pre-refactor behavior. Bug #1 (medium) — Stop-word filtering silently drops canon vocab keywords: - parseCheckColumn called tokenize(m[1]) with default STOP_WORDS filter. Canon vocab includes 'from' (in source-named) and 'to' (inside 'according to'); STOP_WORDS includes both. Result: 'from' is dropped from stemmedTokens; the runtime call site applied the same filter to inputStems so 'from' is dropped there too. - Pre-refactor regex evaluator matched 'from' literally as new RegExp('\\bfrom\\b', 'i') against raw input. Inputs like 'I learned this from my colleague' passed source-named pre-refactor and fail post-refactor — the strictly-additive invariant the 0.21.0 CHANGELOG and PR description claimed is broken. - Latent landmine: any prereq whose canon vocab is entirely stop-words would have stemmedTokens.size === 0 and trigger the conservative length>=20 fallback inappropriately (false positive). Not currently exploited by canon but the structural risk is there. - Fix: pass new Set() (empty stop-words) to both tokenize() calls so canon keywords survive on both sides and shape matches. Bug #2 (low) — DRY violation in BasePrerequisite: - 0.21.0 introduced PrereqMatchVocab interface to share shape between BasePrerequisite and ChallengeTypeDef.prerequisiteOverlays[]. The inline type on prerequisiteOverlays uses '& PrereqMatchVocab' intersection; BasePrerequisite re-listed all five fields manually. Future PrereqMatchVocab additions would not propagate to BasePrerequisite — defeats the DRY purpose. - Fix: split into BasePrerequisiteCore (3 core fields) and type BasePrerequisite = BasePrerequisiteCore & PrereqMatchVocab. Verification: - Typecheck clean - governance-parser.test.mjs 105/105 pass - 2 new regression assertions in canon-tool-envelope.smoke.mjs: (10) source-named via 'from'-only canon keyword (11) source-named via 'according to' multi-word phrase - Bugbot Autofix preview diff for PR #121 used as the basis for the Bug #1 fix (cherry-picked the change locally rather than applying via Autofix UI to keep the audit trail in this branch). Process post-mortem to follow in P1.3.3 closeout ledger.
….21.1) (#122) Two fixes from Cursor Bugbot findings on PR #120 / #121 that should have blocked the 0.21.0 ship and didn't because the orchestrator treated Bugbot's in_progress state as non-blocking. The findings are real and the medium-severity one is a prod regression vs pre-refactor behavior. Bug #1 (medium) — Stop-word filtering silently drops canon vocab keywords: - parseCheckColumn called tokenize(m[1]) with default STOP_WORDS filter. Canon vocab includes 'from' (in source-named) and 'to' (inside 'according to'); STOP_WORDS includes both. Result: 'from' is dropped from stemmedTokens; the runtime call site applied the same filter to inputStems so 'from' is dropped there too. - Pre-refactor regex evaluator matched 'from' literally as new RegExp('\\bfrom\\b', 'i') against raw input. Inputs like 'I learned this from my colleague' passed source-named pre-refactor and fail post-refactor — the strictly-additive invariant the 0.21.0 CHANGELOG and PR description claimed is broken. - Latent landmine: any prereq whose canon vocab is entirely stop-words would have stemmedTokens.size === 0 and trigger the conservative length>=20 fallback inappropriately (false positive). Not currently exploited by canon but the structural risk is there. - Fix: pass new Set() (empty stop-words) to both tokenize() calls so canon keywords survive on both sides and shape matches. Bug #2 (low) — DRY violation in BasePrerequisite: - 0.21.0 introduced PrereqMatchVocab interface to share shape between BasePrerequisite and ChallengeTypeDef.prerequisiteOverlays[]. The inline type on prerequisiteOverlays uses '& PrereqMatchVocab' intersection; BasePrerequisite re-listed all five fields manually. Future PrereqMatchVocab additions would not propagate to BasePrerequisite — defeats the DRY purpose. - Fix: split into BasePrerequisiteCore (3 core fields) and type BasePrerequisite = BasePrerequisiteCore & PrereqMatchVocab. Verification: - Typecheck clean - governance-parser.test.mjs 105/105 pass - 2 new regression assertions in canon-tool-envelope.smoke.mjs: (10) source-named via 'from'-only canon keyword (11) source-named via 'according to' multi-word phrase - Bugbot Autofix preview diff for PR #121 used as the basis for the Bug #1 fix (cherry-picked the change locally rather than applying via Autofix UI to keep the audit trail in this branch). Process post-mortem to follow in P1.3.3 closeout ledger.


Closes the last two vodka anti-pattern remnants in
oddkit_challengeper P1.3.3, mirroring the matchers and the no-microsecond-caching discipline gate shipped in 0.20.0. Ships as 0.21.0.Three items
Item 1 — D5 split-by-fit applied to challenge
Migrates
evaluatePrerequisiteCheckfrom regex-per-check to stemmed set intersection over a parse-timePrereqMatchVocabstruct.parseCheckColumn(check: string): PrereqMatchVocabhelper at canon-fetch time:/"([^"]+)"/gtokenize()frombm25.tsinterface PrereqMatchVocabmixed into bothBasePrerequisiteandChallengeTypeDef.prerequisiteOverlays[]via intersection — keeps the per-type and base-prereq structs structurally compatible (DRY)discoverChallengeTypes+fetchBasePrerequisites) populate via...parseCheckColumn(cols[1])spreadtokenize(input)hoisted out of the per-prereq loop; per-prereq cost is now a Set lookup, not a regex compileproblems identified→problem+identif,considered alternatives→consid+altern); structural side-tests preserved verbatimItem 2 — D9 applied to
cachedChallengeTypeIndexRemoves the BM25 type-detection index cache per
klappy://canon/principles/cache-fetches-and-parses.cachedChallengeTypeIndex+cachedChallengeTypeIndexKnowledgeBaseUrlmodule-level fields deletedgetOrBuildChallengeTypeIndexfunction deletedcleanup_storageresets deletedrunChallengeActioncall site rebuilds the BM25 index inline per request viabuildBM25Index(types.map(t => ({id: t.slug, text: t.detectionText})), vocab.stopWords)— same pattern gate shipped in 0.20.0Removes the four-cost plumbing tax the new canon principle enumerates.
Item 3 — Canon principle (already merged)
klappy://canon/principles/cache-fetches-and-parsesshipped first in klappy.dev#125 (3726073). Third deciding-argument recurrence satisfies the graduation test.Verification (Option A — smoke-heavy)
npx tsc --noEmit(workers)node workers/test/governance-parser.test.mjschallenge-d5-d9-parity-oddkit.klappy.workers.dev× 3 consecutiveInitial cold-start run had 1 flaky failure (network-timing class), then 3 consecutive 170/170 runs at steady state. +12 assertions over P1.3.2's 158-assertion baseline (the 9 P1.3.3 assertion blocks expand to 12 individual
ok()invocations).New smoke coverage (workers/test/canon-tool-envelope.smoke.mjs)
evidence-citedvocabalternatives-considered+risk-acknowledgedvia stemmed matchevidence-citedgap correctlyaccording totriggers citation path)observedstill passes (pre-refactor regex semantics preserved)believe→believDiff stat
Lockfile re-synced from stale 0.18.0 → 0.21.0 (was unchanged through 0.19.0 and 0.20.0 releases — pre-existing drift).
Sweep status after this lands
oddkit_encodeoddkit_challengeoddkit_gateChallenge now has full canon parity with gate's matcher and caching discipline.
References
/home/claude/work/prd-p1-3-3.md(working dir, ephemeral per P1.3.2 precedent)klappy://odd/handoffs/2026-04-20-p1-3-3-challenge-revisitklappy://odd/ledger/2026-04-20-p1-3-2-gate-canary-landed(D5 + D9 origin)klappy://canon/principles/cache-fetches-and-parses(klappy.dev#125)Note
Medium Risk
Changes
oddkit_challengeprerequisite evaluation and type-detection indexing behavior, which can alter when prerequisites are considered met and when types are detected. While intended to be additive, regressions could affect challenge output consistency and performance under load.Overview
Bumps release to 0.21.0 and updates the changelog to document the new challenge matcher and caching principle.
oddkit_challengeprerequisite checks now use stemmed token set intersection computed at canon-parse time (via newPrereqMatchVocab+parseCheckColumn) withtokenize(input)hoisted out of the per-prereq loop, while preserving the prior structural checks (URL/numeric/proper-noun/citation) and the non-trivial-input fallback.Removes the cached BM25 index used for challenge type detection (
cachedChallengeTypeIndex/getOrBuildChallengeTypeIndex) and instead rebuilds the BM25 index inline per request. Adds new smoke-test coverage asserting stemmed prereq matching, structural-check preservation, and deterministic results across consecutive calls.Reviewed by Cursor Bugbot for commit 400eb57. Bugbot is set up for automated code reviews on this repo. Configure here.