Skip to content

feat(challenge): D5 stemmed prereq matcher + D9 cache removal (0.21.0)#120

Merged
klappy merged 1 commit intomainfrom
challenge/d5-d9-parity
Apr 20, 2026
Merged

feat(challenge): D5 stemmed prereq matcher + D9 cache removal (0.21.0)#120
klappy merged 1 commit intomainfrom
challenge/d5-d9-parity

Conversation

@klappy
Copy link
Copy Markdown
Owner

@klappy klappy commented Apr 20, 2026

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. Ships as 0.21.0.

Three items

Item 1 — D5 split-by-fit applied to challenge

Migrates evaluatePrerequisiteCheck from regex-per-check to stemmed set intersection over a parse-time PrereqMatchVocab struct.

  • New parseCheckColumn(check: string): PrereqMatchVocab helper at canon-fetch time:
    • Extracts quoted keywords via /"([^"]+)"/g
    • Tokenizes each into stems via the existing Porter-style tokenize() from bm25.ts
    • Detects the four structural-test hints (URL / numeric / proper-noun / citation) as boolean flags
  • New interface PrereqMatchVocab mixed into both BasePrerequisite and ChallengeTypeDef.prerequisiteOverlays[] via intersection — keeps the per-type and base-prereq structs structurally compatible (DRY)
  • Both parsers (discoverChallengeTypes + fetchBasePrerequisites) populate via ...parseCheckColumn(cols[1]) spread
  • 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 (problems identifiedproblem + identif, considered alternativesconsid + altern); structural side-tests preserved verbatim

Item 2 — D9 applied to cachedChallengeTypeIndex

Removes the BM25 type-detection index cache per klappy://canon/principles/cache-fetches-and-parses.

  • cachedChallengeTypeIndex + cachedChallengeTypeIndexKnowledgeBaseUrl module-level fields deleted
  • getOrBuildChallengeTypeIndex function deleted
  • cleanup_storage resets deleted
  • runChallengeAction call site rebuilds the BM25 index inline per request via buildBM25Index(types.map(t => ({id: t.slug, text: t.detectionText})), vocab.stopWords) — same pattern gate shipped in 0.20.0

Removes the four-cost plumbing tax the new canon principle enumerates.

Item 3 — Canon principle (already merged)

klappy://canon/principles/cache-fetches-and-parses shipped first in klappy.dev#125 (3726073). Third deciding-argument recurrence satisfies the graduation test.

Verification (Option A — smoke-heavy)

Check Result
npx tsc --noEmit (workers) clean
node workers/test/governance-parser.test.mjs 105/105 pass
Preview smoke @ challenge-d5-d9-parity-oddkit.klappy.workers.dev × 3 consecutive 170/170 pass × 3

Initial 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)

  • Stemmed base-prereq match for 3 inflected variants of evidence-cited vocab
  • Per-type proposal alternatives-considered + risk-acknowledged via stemmed match
  • Non-match input surfaces evidence-cited gap correctly
  • URL structural test preservation (input with no keyword overlap passes via URL path)
  • Proper-noun structural test preservation
  • Citation structural test preservation (according to triggers citation path)
  • Inline rebuild stability across consecutive identical-input calls (D9 attestation)
  • Backward-compat: literal canon keyword observed still passes (pre-refactor regex semantics preserved)
  • Confidence-signaled stemmed match on believebeliev

Diff stat

 CHANGELOG.md                               |  18 +++
 package.json                               |   2 +-
 workers/package-lock.json                  |   4 +-
 workers/package.json                       |   2 +-
 workers/src/orchestrate.ts                 | 194 +++++++++++++++++++----------
 workers/test/canon-tool-envelope.smoke.mjs | 133 ++++++++++++++++++++
 6 files changed, 284 insertions(+), 69 deletions(-)

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

Tool Version Envelope Matcher BM25 index cached?
oddkit_encode 0.18.0 governance_uri (singular) regex (separate beat — P8) parse product cached
oddkit_challenge 0.21.0 governance_uris × 4 stemmed set intersection + structural rebuild inline
oddkit_gate 0.20.0 governance_uris × 2 BM25 + stemmed set intersection rebuild inline

Challenge now has full canon parity with gate's matcher and caching discipline.

References

  • PRD: /home/claude/work/prd-p1-3-3.md (working dir, ephemeral per P1.3.2 precedent)
  • Handoff: klappy://odd/handoffs/2026-04-20-p1-3-3-challenge-revisit
  • Inheritance: P1.3.2 ledger klappy://odd/ledger/2026-04-20-p1-3-2-gate-canary-landed (D5 + D9 origin)
  • Canon principle: klappy://canon/principles/cache-fetches-and-parses (klappy.dev#125)

Note

Medium Risk
Changes oddkit_challenge prerequisite 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_challenge prerequisite checks now use stemmed token set intersection computed at canon-parse time (via new PrereqMatchVocab + parseCheckColumn) with tokenize(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.

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.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@klappy klappy merged commit 33ca5bf into main Apr 20, 2026
5 checks passed
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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: BasePrerequisite duplicates PrereqMatchVocab fields 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])) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 400eb57. Configure here.

hasNumericCheck: boolean;
hasProperNounCheck: boolean;
hasCitationCheck: boolean;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 400eb57. Configure here.

klappy added a commit that referenced this pull request Apr 20, 2026
….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.
klappy added a commit that referenced this pull request Apr 20, 2026
….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.
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.

1 participant