fix: harden Knowledge Graph client pagination, code normalization, and ambiguity detection - #148
fix: harden Knowledge Graph client pagination, code normalization, and ambiguity detection#148adnanrhussain wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Hardens the internal Knowledge Graph client for upcoming batch evaluation workflows.
Changes:
- Adds cursor pagination with malformed/repeated-cursor protection.
- Normalizes statement-code lookups and cache keys.
- Detects ambiguous matches and fixes subject-aware caching.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
client.ts |
Implements pagination, normalization, and ambiguity detection. |
types.ts |
Extends resolved standard metadata. |
index.ts |
Re-exports normalization helper. |
standards-alignment.ts |
Removes obsolete lookup limits. |
client.test.ts |
Adds KG hardening regressions. |
standards-alignment.test.ts |
Updates evaluator expectations. |
spec.md |
Documents requirements and acceptance criteria. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7a60394 to
40a2a1b
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
40a2a1b to
33cc470
Compare
009d334 to
0192bb5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
sdks/typescript/src/evaluators/math/standards-alignment.ts:455
- When coarse filtering is enabled, this still uses only the first candidate's description and discards
ambiguous. If that description is irrelevant but another standard sharing the code is relevant, the model can filter the code out;_evaluateCorethen never runs, so the new ambiguity warning is also skipped. Until candidate resolution is available, ambiguous codes should bypass coarse filtering (or all candidate descriptions should be supplied) so an arbitrary first match cannot create a false negative.
this.kgClient.getStandardInfo(code, { jurisdiction, academicSubject: KG_SUBJECT })
sdks/typescript/src/knowledge-graph/client.ts:121
- This cached request closes over the first caller's raw spelling. Concurrent normalized variants share the promise, so if the lookup is empty, later callers receive
Standard not foundwith the first caller's code rather than their own, contrary to the new error-message contract. Cache an empty candidate result and construct the not-found error after each caller awaits it, preserving both request deduplication and caller-specific messages.
p = this.limit(() => this._fetchStandardCandidates(statementCode, normalized, opts));
0192bb5 to
bb2154e
Compare
|
Both suppressed comments were valid and are fixed in Cached not-found message — real bug, and my own test missed it by only exercising a single caller. Coarse filter — an ambiguous code is now treated as relevant rather than filtered on one arbitrary candidate's description, matching the filter's existing fail-open behaviour for omitted codes. Test covers it. Both verified killed under mutation testing. |
bb2154e to
3a3777f
Compare
|
Good catch, and correct — that was a regression I introduced. Moving the not-found throw out of Fixed in Test added asserting a second call re-issues the request after an empty response; verified it fails without the eviction. |
…d ambiguity detection
3a3777f to
8ffea5b
Compare
|
Both fixed in Fan-out — Dedupe — the code list is now deduped, which fixes a bug the review didn't flag: a jurisdiction reusing a code across courses returned one item per course, so Two numbers in the finding were off, for the record: the grade listing is ~3 paginated list requests (1,127÷500), not 1,127 searches; and the ~1,950 ambiguous pairs are a global Mathematics figure, not per-run — within one (jurisdiction, grade) bucket it is a fraction of the per-jurisdiction counts (Arkansas 57 down to 1), so the coarse-filter fail-open adds a few percent rather than defeating filtering. Tests updated rather than added; every changed line verified killed under mutation testing. |
First of four foundational SDK PRs preparing the batch CLI to run Math Standards Alignment over CSV input. Confined to the KG client: nothing is added to or removed from the package's named exports.
One caveat on compatibility.
KnowledgeGraphClientis not exported, but it is structurally reachable through the exportedMathStandardsAlignmentEvaluatorConfig._kgClient(an@internaltest seam). A consumer with a typed mock of that seam would break here —StandardInfogained requiredstatementCode/normalizedCode,getStandardCandidates/getLearningComponentSetare new, andStandardInfoOptions.limitis gone. Mocks that cast (as unknown as KnowledgeGraphClient, as our tests do) are unaffected.Three fixes
getStandardsByGradethrew on paginated results — any grade with >500 standards was a hard failure. Now walks cursors.3.md.c.7.dand3.MD.C.7.dare the same standard but cost two cache entries and two round trips.Validated against the Knowledge Graph, not assumed
I scanned the KG (read-only Neo4j) to check whether each fix addresses something real. All three do:
Concrete example: Utah
F.IF.7.bresolves to five different standards — Secondary Math I/II/III, Sec III Honors, and Precalculus — with 3/5/0/0/10 learning components and different expectations each. All five shareisCurrent=true,adoptionStatus=Adopted,normalizedStatementType=Standard, and identicalgradeLevel. The only disambiguator is the course, which lives on an ancestor node and is not queryable via search. So detection is the only lever available.Notes for review
statementCodevsnormalizedCode.StandardInfocarries both: the KG's own spelling (CCSS sub-standards are lowercase, so the uppercased form is not a valid code) and the canonical dedupe key.limitremoved fromStandardInfoOptions, and the request now stateslimit: 50explicitly. The search endpoint's default is 5, not the cap — omitting it would silently truncate a candidate list that reaches 40 in practice. The cache key doesn't includelimit, so leaving it caller-configurable let two callers poison each other's results.getStandardCandidates()exposes the list;getStandardInfo()is unchanged externally (first match, setsambiguous). This means the resolver in the follow-up PR reuses one request — asserted by a test.getStandardInfocache key omittedacademicSubject, so the same code under Mathematics and ELA returned whichever landed first._paginateis shared by both paginated endpoints and adds a repeated-cursor guard the old LC loop lacked; the standards query is typed against the generated spec rather thanRecord<string, unknown>.limit: 1, which made ambiguity detection unreachable on the only path that runs today. Those are updated here.ambiguousis observable, not just recorded. The evaluator warns with the chosen uuid and description so a surprising result is traceable to the choice rather than the model, and an ambiguous code is treated as relevant during coarse filtering — that filter sees one arbitrary candidate's description and could otherwise produce a false negative._paginatebounds itself withMAX_PAGESagainst a server minting a fresh cursor per page.descriptionas optional, and alignment is judged from that text, so an undescribed component is unevaluable — but discarding it silently makes a populated standard look unauthored.getLearningComponentSetreturns{ components, undescribedCount }so a caller can tell the two apart;getLearningComponentsis unchanged and still returns evaluable components only.Consequence of fixing pagination:
evaluateByGradefan-outThe old
>500throw was the only de-facto cap onevaluateByGrade. Removing it means a grade with 1,127 standards now succeeds where it previously errored — 10 questions against it is ~11,270 LLM calls.Rather than reintroduce a cap (which would re-break exactly the grades this PR fixes),
evaluateByGradenow warns above 500 projected pairs, reporting question count, standard count and total, so an expensive run is an informed one. Hard limits stay in the batch layer, wheremaxInputRowsalready lives and where the CLI can prompt.It also dedupes the code list, which fixes a real bug the pagination change made more visible: a jurisdiction reusing a code across courses returns one item per course, so
byStandardpreviously emitted duplicate rows for every such code.evaluateItemsdeduped internally, so LLM cost was unaffected — only the report was wrong.The evaluator still evaluates the first candidate; pruning by learning components lands in #149, and this PR's ambiguity warning covers the interval.
Verification
npm run lint(0 errors),typecheck,test:unit— 343 passing; KG suite 19 → 40 tests.Mutation tested with Stryker: 82% on covered code (149 killed, 33 survivors). Several tests here exist specifically because a mutant survived without them, and two that earned nothing measurable were deleted. One earlier attempt asserted the wrong thing entirely — a URL assertion that passed while its mutant lived, because the line under test builds the cache key, not the request.