Skip to content

feat(extract): temporal validity role + source-date resolution anchor#31

Merged
laynepenney merged 3 commits into
sprint-39from
feat/temporal-role-resolution-anchor
Jul 15, 2026
Merged

feat(extract): temporal validity role + source-date resolution anchor#31
laynepenney merged 3 commits into
sprint-39from
feat/temporal-role-resolution-anchor

Conversation

@laynepenney

@laynepenney laynepenney commented Jul 15, 2026

Copy link
Copy Markdown
Member

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/type sit behind a temporal_classes capability recall never requests, and even type: point doesn'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-30 under the old decomposed path — wrong year, silently.

What landed

  • role enum (effective | expiry | range | superseded | point) on the temporal-ref schema. role + resolved_end moved to BASE tier (no longer temporal_classes-gated) — the actual fix for the unreachability bug. type/context stay gated (non-load-bearing extras once role carries direction).
  • BatchUnit.date (optional) — the unit's source date, threaded into Stage-1 as the resolution anchor.
  • validate.py: role accepted + enum-validated; role == "range" requires resolved_end (mirrors the existing type == "range" rule).
  • _coerce_shape/_coerce_item: zero changes. Verified empirically (before writing any code) that the batch whitelist is schema-driven — preserving role+resolved_end through 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: the temporal_classes heuristic previously kept resolved_end is not None as a trigger — now that resolved_end is base-tier, that would mislabel capability usage. Keys on type presence only now. Found by tracing consequences, not in the spec's literal list.
  • A latent prompt bug, found while building this: an absent date param 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.
  • Version: 0.5.00.6.0 (additive schema = minor). CHANGELOG updated.

npm / TypeScript parity

Additive-role parity carried; full port deferred. packages/ts is a fully independent reimplementation (own hand-written schema.ts, no extract_batch/batch.ts equivalent 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 full batch.ts port and the npm version-sync remain a follow-up (config/design/extract-ts-py-parity-plan-2026-07-15.md). npm stays at its own 0.5.0 for 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_extraction call 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 carries role="expiry" and resolved="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_until map, replacing the current null placeholder in recall#876) is a follow-up PR, sequenced after this lands — per the design note's own sequencing, and because recall's _EXTRACT_CAPABILITIES needs to re-request temporal_refs and 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/extract is 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 own schema-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 TS batch.ts port) remains the post-validation follow-up per config/design/extract-ts-py-parity-plan-2026-07-15.md.

Canonical + Python public type:

  • Repo-root schemas/temporal-ref/v1.json synced to the package copy → diff -r clean, schema-drift-check green (the Python-only cut updated only the package copy).
  • schema.py SynaptTemporalRef TypedDict gains role (the runtime emits it; 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 docstrings updated for BatchUnit.date + base-tier role/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 at 0.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. Only synapt-extract (PyPI) bumps to 0.6.0 here (it ships the runtime + extract_batch this traces to). (Reverted at acaccbd after 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 consistently 0.5.0.)

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 → line-for-line identical verdicts, error paths, and temporal_classes detection.
  • Embedded prompt fragment == the shared file (byte-exact, existing parity test).

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

laynepenney and others added 3 commits July 14, 2026 19:54
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
@laynepenney
laynepenney merged commit 6abcf44 into sprint-39 Jul 15, 2026
11 checks passed
@laynepenney
laynepenney deleted the feat/temporal-role-resolution-anchor branch July 15, 2026 03:55
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