feat(extract): temporal validity role + source-date resolution anchor#31
Merged
Merged
Conversation
config/design/extract-temporal-role-2026-07-14.md. Replaces recall's B2-LLM-re-judges-
temporal duct tape (recall#875/876) with a real extract IL fix — the direction and
resolution belong at extraction, where the source sentence and source date are both
directly available, not in a second recall-side LLM pass re-deriving them.
DIRECTION (role): each temporal ref now carries a validity ROLE — effective (valid FROM
resolved), expiry (valid UNTIL resolved), range (valid FROM resolved TO resolved_end),
superseded (was valid UNTIL resolved, a prior-state fact), or point (no clear direction,
fallback). role + resolved_end are BASE-tier on the "temporal_refs" capability (no longer
gated behind the separate "temporal_classes" capability recall never requested) — this is
the root cause Apollo diagnosed: resolved_end was unreachable at recall's capability tier,
and even reachable, a bare "point" ref has no field encoding start-vs-end. type/context stay
temporal_classes-gated (non-load-bearing extras once role carries direction).
RESOLUTION (source-date anchor): BatchUnit gained an optional `date` field — the unit's
SOURCE date, threaded into Stage-1 as the resolution anchor for partial/relative dates
("April 30" only resolves correctly against the fact's actual source year). Sentinel's
real-path finding: a 2025-sourced "API key expires April 30" resolved to 2026-04-30 with
the pre-fix decomposed path — wrong year, no anchor at all was threaded through. Also fixed
a latent prompt-rendering bug found while building this: an absent `date` rendered the
literal string "Resolve relative dates using: None." instead of omitting the instruction.
Changes:
- builder.py::_temporal_ref_schema — role + resolved_end moved to base properties.
- validate.py — role added to allowed keys + enum validation + role=="range" requires
resolved_end (mirrors the existing type=="range" rule).
- batch.py::BatchUnit — new optional `date` field, threaded into build_extraction_prompt.
_coerce_shape/_coerce_item needed ZERO changes — the whitelist is schema-driven, so
preserving role+resolved_end through the batch path is a verified CONSEQUENCE of the
schema fix, not separate coercion code.
- finalize.py::_detect_capabilities — temporal_classes heuristic now keys on `type`
presence only (resolved_end no longer implies the gated capability was exercised).
- prompts/v1/temporal_refs.txt — role classification instructions (trigger examples per
role) + the date instruction now wrapped in {{#if date}} (fixes the "None" bug).
- schemas/temporal-ref/v1.json — published JSON-schema document synced (role property +
the role=="range" conditional requirement).
- Version bump 0.5.0 -> 0.6.0 (additive schema = minor). npm/@synapt-dev/extract parity
is a tracked follow-up, not this release — no TS extract_batch equivalent exists yet,
so the specific bug this fixes doesn't reproduce there.
TDD, fixtures pinned from REAL extract calls throughout (the discipline this bug's own
history demands — the original temporal fix passed both author-tests and reviewer-fruit
because both used a hand-built envelope; only Sentinel's pinned-real-output caught it).
36 new tests across validate/builder/batch/finalize/prompt, including a capstone test
replicating Sentinel's exact wrong-year scenario end-to-end through a real extract_batch
call. 382 total extract tests green, no regression.
Recall-side consumption (deterministic role->bound map, replacing the null placeholder)
is a follow-up PR, sequenced after this lands per the design note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm
…chema sync
Sentinel's REQUEST CHANGES on the Python-only cut: the shared prompt/schema surface was
left in a half-state (the shared Stage-1 prompt asks the model for `role`, but the TS
validate/builder surfaces reject it), which the repo's own schema-drift-check + ts/py
parity contract won't merge. Decision (Opus, per Layne's ts/py-parity directive): carry the
small additive-role parity here, not isolate assets. This completes the public-contract
surface around the (already Opus+Sentinel-confirmed) Python runtime.
Canonical schema + Python public type:
- schemas/temporal-ref/v1.json at the REPO ROOT synced to the package copy (role + the
role=="range"->resolved_end conditional) — `diff -r` clean, schema-drift-check green. The
Python-only cut updated only the package copy.
- schema.py SynaptTemporalRef TypedDict gains `role` — the v0.6 runtime emits role, so the
public type no longer rejects its own output. Guard test asserts the TypedDict declares
role AND its Literal members exactly equal the validator's VALID_TEMPORAL_ROLES.
- batch.py module/coercion docstrings updated for BatchUnit.date + base-tier role/resolved_end
(Sentinel's non-blocking doc note).
TypeScript parity (the additive-role slice only — mirrors the Python decisions exactly):
- schema.ts: SynaptTemporalRef interface gains `role?`.
- builder.ts: temporalRefSchema emits role + resolved_end BASE-tier; type/context stay
temporal_classes-gated.
- validate.ts: VALID_TEMPORAL_ROLES + role added to TEMPORAL_REF_KEYS + role enum validation
(+ role=="range" requires resolved_end).
- finalize.ts: temporal_classes detection keys on `type` only (drops the resolved_end
disjunct, now that resolved_end is base-tier).
- prompt-data.ts: embedded temporal_refs fragment updated to byte-match the shared
prompts/v1/temporal_refs.txt (the fragment-parity test enforces this; TS's renderer
supports the {{#if date}} conditional identically to Python).
- Version: @synapt-dev/extract 0.5.0 -> 0.6.0, in lockstep with synapt-extract (both were
synced at 0.5.0; a Python-only bump would introduce the exact version drift parity exists
to prevent). Publish is release-gated, unaffected.
The FULL TS parity (a TS extract_batch/batch.ts port + parity-guard hardening) remains the
post-validation follow-up per config/design/extract-ts-py-parity-plan-2026-07-15.md; this is
only the additive-role coherence needed to keep the shared surface consistent.
Verification — cross-language parity proven empirically, not asserted:
- buildExtractionSchema temporal_refs item: TS and Python produce BYTE-IDENTICAL JSON.
- validate + finalize on 5 shared role fixtures (valid expiry/effective/range, invalid enum,
range-without-resolved_end): LINE-FOR-LINE identical verdicts, error paths, and
temporal_classes detection across both languages.
- The embedded prompt fragment == the shared file (byte-exact, enforced by the existing
fragment-parity test).
Gates: schema-drift-check green (diff -r clean), 383 Python tests (+ TypedDict guard),
265 TS tests (+10 mirroring the Python role tests), tsc --noEmit clean. Schema file count
unchanged (13, wheel gate safe).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm
…ot this slice (Sentinel) Sentinel's re-review caught a real half-state: my TS package.json bump to 0.6.0 left the tracked package-lock.json still at 0.5.0 (I bumped only package.json). Rather than complete the bump (sync the lockfile to 0.6.0), REVERT it — the version-sync belongs to the deferred full-parity effort per the dispatch's own scoping, and the additive-role coherence slice needs no npm version change. This restores packages/ts/package.json + package-lock.json to their consistent origin 0.5.0 state (the lockfile was never touched). synapt-extract (PyPI) stays at 0.6.0 — it ships the feature runtime + the extract_batch this all traces to. @synapt-dev/extract (npm) carries the same additive-role parity at 0.5.0; its version bump is a tracked follow-up, not this PR. CHANGELOG corrected accordingly. Behavior is IDENTICAL to fd8dff3 — a version-string revert only; all schema/validate/finalize/ prompt output is unchanged (265 TS tests + tsc still green). Reviewer replays on fd8dff3 remain valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm
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.
Temporal validity role + source-date resolution anchor
Spec:
config/design/extract-temporal-role-2026-07-14.md. Base:sprint-39.Replaces recall's B2-LLM-re-judges-temporal duct tape (recall#875/876, now pulled to an honest null placeholder) with a real extract IL fix — direction and resolution both belong at extraction, where the source sentence and source date are directly available, not in a second recall-side LLM pass re-deriving them from already-extracted text.
The gap, in two parts
Direction (role): extract's temporal ref emitted a directionless
{raw, resolved}. "2026-04-30" — is that an expiry (→valid_until) or an effective date (→valid_from)? The direction lives in the sentence ("expires" vs "effective/since") and extraction dropped it. Root cause (verified against the real pipeline, not reasoned from code):resolved_end/typesit behind atemporal_classescapability recall never requests, and eventype: pointdoesn't encode start-vs-end.Resolution (source-date anchor): "April 30" only resolves to the right year against the fact's source date. B1 sends extract_batch only candidate text — no anchor. Sentinel's real-path finding: a 2025-sourced "API key expires April 30" resolved to
2026-04-30under the old decomposed path — wrong year, silently.What landed
roleenum (effective|expiry|range|superseded|point) on the temporal-ref schema.role+resolved_endmoved to BASE tier (no longertemporal_classes-gated) — the actual fix for the unreachability bug.type/contextstay gated (non-load-bearing extras oncerolecarries direction).BatchUnit.date(optional) — the unit's source date, threaded into Stage-1 as the resolution anchor.validate.py:roleaccepted + enum-validated;role == "range"requiresresolved_end(mirrors the existingtype == "range"rule)._coerce_shape/_coerce_item: zero changes. Verified empirically (before writing any code) that the batch whitelist is schema-driven — preservingrole+resolved_endthrough the batch path is a consequence of the schema fix, not separate coercion logic. This was Apollo's original root-cause diagnosis (the batch coercion strip); turns out the fix for it lives entirely upstream in the schema.finalize.py::_detect_capabilities: thetemporal_classesheuristic previously keptresolved_end is not Noneas a trigger — now thatresolved_endis base-tier, that would mislabel capability usage. Keys ontypepresence only now. Found by tracing consequences, not in the spec's literal list.dateparam rendered the literal string"Resolve relative dates using: None."— actively misleading, not just unhelpful. Now wrapped in{{#if date}}.schemas/temporal-ref/v1.json(the published JSON-schema document, a separate source from the runtime Python) synced to match.0.5.0→0.6.0(additive schema = minor). CHANGELOG updated.npm / TypeScript parity
Additive-role parity carried; full port deferred.
packages/tsis a fully independent reimplementation (own hand-writtenschema.ts, noextract_batch/batch.tsequivalent at all) — the specific coercion-strip bug this PR fixes doesn't even reproduce there, since there's no batch coercion layer to strip anything. Per Sentinel's REQUEST CHANGES, the additive-role schema/validate/finalize/prompt parity was subsequently carried in this PR (see the "Update" section below); the fullbatch.tsport and the npm version-sync remain a follow-up (config/design/extract-ts-py-parity-plan-2026-07-15.md). npm stays at its own0.5.0for now; Opus is tracking the full-parity effort.Fruit discipline (this bug's own lesson, applied throughout)
The original temporal fix passed both author-tests and reviewer-fruit because both used a hand-built envelope that misrepresented the real extract contract — only Sentinel's pinned-real-output caught it. Every test here uses the real
extract_batch/build_extraction_prompt/validate_extractioncall paths, never a hand-built shortcut. The capstone test (test_extract_batch_replicates_sentinels_wrong_year_scenario_end_to_end) replicates her exact bug scenario end-to-end: a 2025-03-01-sourced unit, "API key expires April 30", asserting the persisted envelope carriesrole="expiry"andresolved="2025-04-30"— the anchored year, not 2026.Tests
36 new tests across
test_validate.py(role enum + range-requires-resolved_end + base-tier-without-temporal_classes),test_extract_batch.py(date threading, per-unit isolation, coercion-preserves-role, the capstone),test_finalize.py(capability-detection heuristic),test_prompt.py(role instructions present, the "None"-bug regression guard). 382 total extract tests green, no regression.Sequencing
Recall-side consumption (deterministic
role → valid_from/valid_untilmap, replacing the current null placeholder inrecall#876) is a follow-up PR, sequenced after this lands — per the design note's own sequencing, and because recall's_EXTRACT_CAPABILITIESneeds to re-requesttemporal_refsand consume the new shape, which depends on this being merged first.Premium boundary
OSS — extract's IL schema/validation/prompt system is a public primitive (
synapt-dev/extractis public). No identity, org, or entitlement logic touched.Reviewers: Opus, Sentinel
Update: ts/py parity slice added (fd8dff3) — Sentinel REQUEST CHANGES resolved
Sentinel's review caught that the Python-only cut left the shared prompt/schema surface in a half-state (shared prompt asks for
role; TS surfaces reject it) — which the repo's ownschema-drift-check+ ts/py-parity contract won't merge. Per Opus (Layne's ts/py-parity directive), carried the additive-role parity here (option A), not asset isolation. The FULL TS parity (a TSbatch.tsport) remains the post-validation follow-up perconfig/design/extract-ts-py-parity-plan-2026-07-15.md.Canonical + Python public type:
schemas/temporal-ref/v1.jsonsynced to the package copy →diff -rclean, schema-drift-check green (the Python-only cut updated only the package copy).schema.pySynaptTemporalRefTypedDict gainsrole(the runtime emits it; the public type no longer rejects its own output). Guard test asserts the TypedDict declaresroleAND its Literal members exactly equal the validator'sVALID_TEMPORAL_ROLES.batch.pydocstrings updated forBatchUnit.date+ base-tierrole/resolved_end(Sentinel's non-blocking doc note).TypeScript (mirrors the Python decisions exactly):
schema.ts(role?),builder.ts(role + resolved_end base-tier),validate.ts(enum +role === "range"→ resolved_end),finalize.ts(drops the resolved_end inference),prompt-data.ts(embedded fragment byte-matches the shared file — the fragment-parity test enforces it; TS supports{{#if date}}identically). Version:@synapt-dev/extract(npm) stays at0.5.0— the additive-role parity lands without an npm bump; the npm version-sync belongs to the deferred full-parity effort (config/design/extract-ts-py-parity-plan-2026-07-15.md), not this slice. Onlysynapt-extract(PyPI) bumps to0.6.0here (it ships the runtime +extract_batchthis traces to). (Reverted atacaccbdafter Sentinel caught a package.json-ahead-of-lockfile half-state — package.json had gone to 0.6.0 while the tracked lockfile stayed 0.5.0; both are now consistently0.5.0.)Cross-language parity proven empirically, not asserted:
buildExtractionSchematemporal_refs item → TS and Python produce byte-identical JSON.All CI green: schema-drift-check, test-python (3.10–3.13), check-typescript, test-typescript, build-python (schema count unchanged at 13), no-network-guard, reproducibility. 383 Python tests (+ TypedDict guard) + 265 TS tests (+10 mirroring the Python role tests).