From 69f66ce6e174fdf85da97dc71c0def69f99571f1 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Tue, 14 Jul 2026 19:54:54 -0500 Subject: [PATCH 1/3] feat(extract): temporal validity role + source-date resolution anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm --- CHANGELOG.md | 12 ++ packages/python/pyproject.toml | 2 +- packages/python/src/synapt/extract/batch.py | 15 ++- packages/python/src/synapt/extract/builder.py | 9 +- .../python/src/synapt/extract/finalize.py | 6 +- .../extract/schemas/temporal-ref/v1.json | 14 ++ .../python/src/synapt/extract/validate.py | 17 ++- prompts/v1/temporal_refs.txt | 3 +- tests/python/test_extract_batch.py | 123 ++++++++++++++++++ tests/python/test_finalize.py | 19 +++ tests/python/test_prompt.py | 21 +++ tests/python/test_validate.py | 62 +++++++++ 12 files changed, 296 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a50e34..8e4d62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.6.0 (Python) + +Temporal validity role + resolution anchor — additive Stage-1 IL enrichment (config/design/extract-temporal-role-2026-07-14.md). + +- Added `role` (`effective` | `expiry` | `range` | `superseded` | `point`) to the temporal-ref schema, capturing the validity DIRECTION a date constrains (e.g. "expires April 30" → `expiry`, vs "effective March 2026" → `effective`) — a semantic distinction the source sentence carries but prior extraction dropped +- `role` and `resolved_end` are now BASE-tier on the `temporal_refs` capability (no longer gated behind the separate `temporal_classes` capability) — always available to any caller requesting `temporal_refs`; `type`/`context` remain `temporal_classes`-gated +- `BatchUnit` gained an optional `date` field — the unit's SOURCE date, threaded into Stage-1 as the temporal resolution anchor so partial/relative dates (e.g. "April 30") resolve against the fact's actual source year, not an unanchored guess +- Fixed a prompt-rendering gap where an absent `date` param rendered the literal string "Resolve relative dates using: None." instead of omitting the instruction +- `_detect_capabilities`'s `temporal_classes` heuristic now keys on `type` presence only (`resolved_end` no longer implies the gated capability was exercised, since it moved to base tier) +- Published JSON schema (`schemas/temporal-ref/v1.json`) updated to match +- TypeScript (`@synapt-dev/extract`) parity is tracked as a follow-up, not included in this release — the batch/coercion bug this fix traces back to is Python-specific (no TS `extract_batch` equivalent exists yet) + ## v0.5.0 0.5.0 universal host-boundary groundwork. diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 5f899c3..9349d84 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "synapt-extract" -version = "0.5.0" +version = "0.6.0" description = "SynaptExtraction IL v1 -- schema, validation, and finalization" readme = "README.md" license = "MIT" diff --git a/packages/python/src/synapt/extract/batch.py b/packages/python/src/synapt/extract/batch.py index 46ad515..f0050d9 100644 --- a/packages/python/src/synapt/extract/batch.py +++ b/packages/python/src/synapt/extract/batch.py @@ -88,11 +88,17 @@ class BatchInferRequest(TypedDict): class BatchUnit: """One pre-identified unit to extract (Q1). ``id`` is stable and rides into the output as ``source_unit_id`` so merge/split/drop is detectable. ``capabilities`` - optionally overrides the per-call default for this unit.""" + optionally overrides the per-call default for this unit. ``date`` is the unit's SOURCE + date (config/design/extract-temporal-role-2026-07-14.md) — the resolution anchor Stage-1 + uses to resolve partial/relative dates in ``unit.text`` (e.g. "expires April 30") against + the ACTUAL date the source material was written, not "today" or an unanchored guess. + Optional: a caller with no source date (or extracting non-temporal-sensitive units) + simply omits it, degrading gracefully to unanchored resolution.""" id: str text: str capabilities: list[str] | None = None + date: str | None = None @dataclass @@ -153,7 +159,12 @@ def _extract_unit( for _attempt in range(_MAX_ATTEMPTS): # Out-of-band: the model sees the unit TEXT only — never its id or a boundary # tag (Q-D). The id lives in bookkeeping and rides into the packet post-hoc. - prompt = build_extraction_prompt(unit.text, capabilities=list(capabilities), stage="stage1") + # unit.date threads as the temporal RESOLUTION anchor (config/design/extract- + # temporal-role-2026-07-14.md) — None degrades gracefully (build_extraction_prompt + # already handles an absent date). + prompt = build_extraction_prompt( + unit.text, capabilities=list(capabilities), stage="stage1", date=unit.date, + ) request: BatchInferRequest = { "prompt": prompt, "messages": [{"role": "user", "content": prompt}], diff --git a/packages/python/src/synapt/extract/builder.py b/packages/python/src/synapt/extract/builder.py index 104c1f1..14db8bf 100644 --- a/packages/python/src/synapt/extract/builder.py +++ b/packages/python/src/synapt/extract/builder.py @@ -334,9 +334,17 @@ def _source_metadata_schema(finalized: bool = False) -> JsonSchema: def _temporal_ref_schema(capabilities: set[str], finalized: bool = False) -> JsonSchema: + # role + resolved_end are BASE-tier (config/design/extract-temporal-role-2026-07-14.md): + # always available with just the "temporal_refs" capability, NOT gated behind + # "temporal_classes" — role is the load-bearing direction signal recall's deterministic + # mapper needs, and role=="range" needs resolved_end to be usable at all. type/context stay + # temporal_classes-gated: non-load-bearing extras once role carries the direction recall + # needs; type used to be the only (weak, ambiguous) direction hint role now replaces. properties: JsonSchema = { "raw": {"type": "string"}, "resolved": {"type": "string"}, + "resolved_end": {"type": "string"}, + "role": {"type": "string", "enum": ["effective", "expiry", "range", "superseded", "point"]}, } required = ["raw"] @@ -346,7 +354,6 @@ def _temporal_ref_schema(capabilities: set[str], finalized: bool = False) -> Jso if "temporal_classes" in capabilities: properties["type"] = {"type": "string", "enum": ["point", "range", "duration", "unresolved"]} - properties["resolved_end"] = {"type": "string"} properties["context"] = {"type": "string"} required.append("type") diff --git a/packages/python/src/synapt/extract/finalize.py b/packages/python/src/synapt/extract/finalize.py index f04d19a..c59c790 100644 --- a/packages/python/src/synapt/extract/finalize.py +++ b/packages/python/src/synapt/extract/finalize.py @@ -127,7 +127,11 @@ def _detect_capabilities(doc: dict[str, Any]) -> list[str]: temporal = doc.get("temporal_refs", []) if isinstance(temporal, list) and temporal: caps.append("temporal_refs") - if any(r.get("type") is not None or r.get("resolved_end") is not None for r in temporal): + # `type` alone still implies temporal_classes was exercised (it stays gated behind + # that capability). `resolved_end` no longer does — it moved to the BASE temporal_refs + # tier alongside `role` (config/design/extract-temporal-role-2026-07-14.md), so its + # presence can no longer be used to infer temporal_classes was requested. + if any(r.get("type") is not None for r in temporal): caps.append("temporal_classes") if isinstance(doc.get("language"), str): diff --git a/packages/python/src/synapt/extract/schemas/temporal-ref/v1.json b/packages/python/src/synapt/extract/schemas/temporal-ref/v1.json index a82c100..cb25560 100644 --- a/packages/python/src/synapt/extract/schemas/temporal-ref/v1.json +++ b/packages/python/src/synapt/extract/schemas/temporal-ref/v1.json @@ -19,6 +19,11 @@ "enum": ["point", "range", "duration", "unresolved"], "description": "Value class. Not all temporal expressions resolve to a single timestamp." }, + "role": { + "type": "string", + "enum": ["effective", "expiry", "range", "superseded", "point"], + "description": "Validity direction this date constrains: 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)." + }, "resolved": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2})?(\\.\\d+)?(Z|[+\\-]\\d{2}:?\\d{2})?)?$", @@ -46,6 +51,15 @@ "required": ["resolved_end"] } }, + { + "if": { + "properties": { "role": { "const": "range" } }, + "required": ["role"] + }, + "then": { + "required": ["resolved_end"] + } + }, { "if": { "properties": { "type": { "const": "unresolved" } }, diff --git a/packages/python/src/synapt/extract/validate.py b/packages/python/src/synapt/extract/validate.py index c19ed53..009e63c 100644 --- a/packages/python/src/synapt/extract/validate.py +++ b/packages/python/src/synapt/extract/validate.py @@ -10,6 +10,8 @@ VALID_GOAL_STATUSES = frozenset(["open", "resolved", "abandoned", "in_progress"]) VALID_TEMPORAL_TYPES = frozenset(["point", "range", "duration", "unresolved"]) +# The validity ROLE (direction) enrichment — config/design/extract-temporal-role-2026-07-14.md. +VALID_TEMPORAL_ROLES = frozenset(["effective", "expiry", "range", "superseded", "point"]) VALID_SENTIMENT_VALENCES = frozenset(["positive", "negative", "neutral", "mixed"]) VALID_ACTION_ORIGINS = frozenset(["extracted", "proposed_from_goals"]) @@ -51,7 +53,7 @@ _RELATION_KEYS = frozenset(["target", "type", "origin", "signals"]) _SOURCE_REF_KEYS = frozenset(["version", "snippet", "offset_start", "offset_end", "sentence_index"]) _SIGNALS_KEYS = frozenset(["version", "confidence", "negated", "hedged", "condition"]) -_TEMPORAL_REF_KEYS = frozenset(["version", "raw", "type", "resolved", "resolved_end", "context"]) +_TEMPORAL_REF_KEYS = frozenset(["version", "raw", "type", "role", "resolved", "resolved_end", "context"]) _EMBEDDING_KEYS = frozenset(["version", "vector", "model", "input", "dimensions", "space", "computed_at"]) _PRODUCER_KEYS = frozenset([ "version", "model", "model_version", "deployment", "configuration", @@ -392,6 +394,19 @@ def _check_temporal_ref(obj: Any, path: str, errors: list[ValidationError]) -> N errors.append(ValidationError(f"{path}.resolved", "must not be present when type is 'unresolved'")) if "resolved_end" in obj: errors.append(ValidationError(f"{path}.resolved_end", "must not be present when type is 'unresolved'")) + # Validity ROLE (direction) — BASE-tier, optional, independent of `type` (config/design/ + # extract-temporal-role-2026-07-14.md). A separate role=="range"->resolved_end check + # mirrors the type=="range" one above, since role can appear without type now that role + # doesn't require the temporal_classes capability. + if "role" in obj: + role = obj.get("role") + if not isinstance(role, str) or role not in VALID_TEMPORAL_ROLES: + errors.append(ValidationError( + f"{path}.role", + "must be one of: effective, expiry, range, superseded, point", + )) + elif role == "range" and "resolved_end" not in obj: + errors.append(ValidationError(f"{path}.resolved_end", "required when role is 'range'")) if "resolved" in obj: if not isinstance(obj["resolved"], str) or not _is_iso_datetime(obj["resolved"]): errors.append(ValidationError(f"{path}.resolved", "must be a valid ISO 8601 date/datetime")) diff --git a/prompts/v1/temporal_refs.txt b/prompts/v1/temporal_refs.txt index 8144a79..b4a0e1c 100644 --- a/prompts/v1/temporal_refs.txt +++ b/prompts/v1/temporal_refs.txt @@ -1 +1,2 @@ -- "temporal_refs": array of objects with "raw" (as it appeared in text), "type" ("point", "range", "duration", or "unresolved"), "resolved" (ISO 8601 date/datetime for points and range starts), optional "resolved_end" (required for ranges), and optional "context". Resolve relative dates using: {{date}}. For durations, omit "resolved" and "resolved_end" unless the source gives concrete dates; never put ISO duration strings such as "P30D" or "P1Y" in "resolved". +- "temporal_refs": array of objects with "raw" (as it appeared in text), "role" ("effective", "expiry", "range", "superseded", or "point" — the validity direction this date constrains, see below), "type" ("point", "range", "duration", or "unresolved"), "resolved" (ISO 8601 date/datetime for points and range starts), optional "resolved_end" (required for ranges and for role "range"), and optional "context". {{#if date}}Resolve relative dates using: {{date}}.{{/if}} For durations, omit "resolved" and "resolved_end" unless the source gives concrete dates; never put ISO duration strings such as "P30D" or "P1Y" in "resolved". +- Classify each date's "role" — which validity boundary it constrains: "effective" when the text signals a start (since / from / as of / effective / started / "migrated in March 2026"); "expiry" when it signals an end (expires / until / by / deadline / ends); "range" when it spans from one date to another (from X to Y / between) — requires "resolved_end"; "superseded" when it describes a prior state that ended (before / "until the refactor" / previously); "point" only when there is no clear directional signal. diff --git a/tests/python/test_extract_batch.py b/tests/python/test_extract_batch.py index a31d09a..8be040c 100644 --- a/tests/python/test_extract_batch.py +++ b/tests/python/test_extract_batch.py @@ -155,6 +155,43 @@ def test_temporal_prompt_schema_conflict_is_explicitly_normalized(case): assert set(normalized["temporal_refs"][0]) <= {"raw", "resolved"} +def test_role_and_resolved_end_survive_coercion_at_base_capability_tier(): + """THE root cause this whole fix chain traces back to (config/design/extract-temporal- + role-2026-07-14.md): role + resolved_end are BASE-tier now, not gated behind + temporal_classes — _coerce_shape's whitelist is schema-driven (build_extraction_schema), + so this is a pure consequence of the builder.py fix, not separate coercion code (VERIFIED + empirically before this test existed, ad hoc; formalized here as a permanent regression + guard). Uses ONLY RECALL_CAPABILITIES ("temporal_refs", no "temporal_classes") — the exact + capability set recall's B1 requests.""" + stage1 = _stage1(temporal_refs=[{ + "raw": "March to April 2026", "role": "range", + "resolved": "2026-03-01", "resolved_end": "2026-04-30", + }]) + + normalized = _coerce_shape(stage1, RECALL_CAPABILITIES) + + assert normalized["temporal_refs"][0] == { + "raw": "March to April 2026", "role": "range", + "resolved": "2026-03-01", "resolved_end": "2026-04-30", + } + + +def test_type_and_context_still_stripped_at_base_capability_tier(): + """Negative control: type/context remain temporal_classes-gated (non-load-bearing extras + once role carries the direction signal) — confirms the fix is precisely scoped to + role+resolved_end, not an accidental full unlock of every temporal_classes field.""" + stage1 = _stage1(temporal_refs=[{ + "raw": "expires April 30", "role": "expiry", "type": "point", + "resolved": "2026-04-30", "context": "API key", + }]) + + normalized = _coerce_shape(stage1, RECALL_CAPABILITIES) + + assert normalized["temporal_refs"][0] == { + "raw": "expires April 30", "role": "expiry", "resolved": "2026-04-30", + } + + def test_entity_refs_scalar_is_coerced_in_scope_and_dropped_out_of_scope(): case = FIXTURES["contract_derived_cases"][0] assert case["must_not_be_reported_as_empirically_observed"] is True @@ -237,6 +274,92 @@ def infer(request): _assert_success(outputs[1], "decision-only") +def test_extract_batch_threads_unit_date_as_temporal_resolution_anchor(): + """config/design/extract-temporal-role-2026-07-14.md 'Temporal RESOLUTION needs the + source date': each unit's SOURCE date threads into Stage-1 as the resolution anchor for + partial/relative dates. Uses a NON-2026 source date (Sentinel's explicit ask — every prior + temporal test used 2026, which masked exactly this class of bug).""" + unit = BatchUnit(id="anchored", text="the API key expires April 30", date="2025-03-01") + seen_prompts = [] + + def infer(request): + seen_prompts.append(_request_prompt(request)) + return json.dumps(_stage1(temporal_refs=[ + {"raw": "expires April 30", "role": "expiry", "resolved": "2025-04-30"}, + ])) + + outputs = _run_batch([unit], infer, capabilities=["temporal_refs"]) + + assert len(seen_prompts) == 1 + assert "2025-03-01" in seen_prompts[0] # the anchor reached the model-visible prompt + _assert_success(outputs[0], "anchored") + + +def test_extract_batch_replicates_sentinels_wrong_year_scenario_end_to_end(): + """THE capstone: role (direction) + resolution (source-date anchor) working TOGETHER + through a REAL extract_batch call, replicating Sentinel's exact real-path finding + (config/design/extract-temporal-role-2026-07-14.md 'Temporal RESOLUTION needs the source + date') — a 2025-03-01-sourced unit with "API key expires April 30" must NOT silently + resolve to 2026 (the c791018 duct-tape bug this whole fix chain traces back to). This test + proves the CONTRACT end-to-end: given a correctly-anchored+classified model response, the + persisted envelope carries role="expiry" and the ANCHORED year — not whether a real model + reliably produces that response (a model-quality question for Phase-C), but that nothing + in the wiring between the anchor and the final envelope silently discards or corrupts it.""" + unit = BatchUnit( + id="clu:0:done:0", text="the API key expires April 30", date="2025-03-01", + ) + + def infer(request): + assert "2025-03-01" in _request_prompt(request) # the anchor reached the model + return json.dumps(_stage1(temporal_refs=[ + {"raw": "expires April 30", "role": "expiry", "resolved": "2025-04-30"}, + ])) + + outputs = _run_batch([unit], infer, capabilities=["temporal_refs"]) + + _assert_success(outputs[0], "clu:0:done:0") + ref = outputs[0].extraction["temporal_refs"][0] + assert ref["role"] == "expiry" # direction preserved through coercion + validation + assert ref["resolved"] == "2025-04-30" # ANCHORED year, not 2026 (Sentinel's bug) + + +def test_extract_batch_unit_without_date_omits_resolution_anchor_gracefully(): + """Backward compat: a BatchUnit with no date (existing callers, or a candidate whose + source entry has no timestamp) must not crash — extract_batch degrades gracefully rather + than injecting a bogus anchor.""" + unit = BatchUnit(id="no-date", text="a fact with no date anchor") + + def infer(request): + return json.dumps(_stage1(facts=[{"text": unit.text}])) + + outputs = _run_batch([unit], infer, capabilities=["facts"]) + _assert_success(outputs[0], "no-date") + + +def test_extract_batch_unit_date_is_per_unit_not_shared_across_the_batch(): + """Two units in the SAME batch with DIFFERENT source dates — each unit's OWN prompt must + carry its OWN anchor, not bleed the other unit's date (per-unit call shape, v1).""" + units = [ + BatchUnit(id="unit-2024", text="fact from an older entry", date="2024-06-01"), + BatchUnit(id="unit-2026", text="fact from a newer entry", date="2026-01-15"), + ] + seen_by_unit = {} + + def infer(request): + prompt = _request_prompt(request) + for unit in units: + if unit.text in prompt: + seen_by_unit[unit.id] = prompt + return json.dumps(_stage1(facts=[{"text": "a fact"}])) + + _run_batch(units, infer, capabilities=["facts"]) + + assert "2024-06-01" in seen_by_unit["unit-2024"] + assert "2026-01-15" not in seen_by_unit["unit-2024"] + assert "2026-01-15" in seen_by_unit["unit-2026"] + assert "2024-06-01" not in seen_by_unit["unit-2026"] + + def test_extract_batch_uses_the_standard_profile_when_call_capabilities_are_omitted(): unit = BatchUnit(id="standard-default", text="The standard profile remains the default.") seen = [] diff --git a/tests/python/test_finalize.py b/tests/python/test_finalize.py index 248081d..1c9d916 100644 --- a/tests/python/test_finalize.py +++ b/tests/python/test_finalize.py @@ -371,6 +371,25 @@ def test_detects_temporal_classes(self): assert "temporal_refs" in caps assert "temporal_classes" in caps + def test_resolved_end_alone_does_not_imply_temporal_classes(self): + """role + resolved_end are BASE-tier (config/design/extract-temporal-role-2026-07-14.md) + — a range-role ref can legitimately carry resolved_end WITHOUT the temporal_classes + capability ever being requested/exercised. The old heuristic (`type is not None OR + resolved_end is not None`) would have mislabeled this as having used temporal_classes; + only `type`'s presence (still temporal_classes-gated) should trigger detection now.""" + result = finalize_extraction( + _llm_output(temporal_refs=[{ + "raw": "March to April 2026", + "role": "range", + "resolved": "2026-03-01", + "resolved_end": "2026-04-30", + }]), + FinalizeContext(produced_by="test://model"), + ) + caps = result.extraction["capabilities"] + assert "temporal_refs" in caps + assert "temporal_classes" not in caps + class TestStage3Warnings: diff --git a/tests/python/test_prompt.py b/tests/python/test_prompt.py index 682a5c2..43dd25e 100644 --- a/tests/python/test_prompt.py +++ b/tests/python/test_prompt.py @@ -261,6 +261,27 @@ def test_temporal_refs_fragment_present(self): result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["temporal_refs"]) assert '"temporal_refs"' in result + def test_temporal_refs_role_instructions_present(self): + """config/design/extract-temporal-role-2026-07-14.md: the Stage-1 prompt classifies + each temporal ref's validity role, with the 5 enum values named.""" + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["temporal_refs"]) + assert '"role"' in result + for role in ("effective", "expiry", "range", "superseded", "point"): + assert role in result + + def test_temporal_refs_omits_resolve_instruction_when_no_date_given(self): + """Regression guard: WITHOUT a date, the fragment must NOT render the literal string + "None" as a resolution instruction (the {{date}} template var was previously + unconditional — an absent date rendered as "Resolve relative dates using: None.", + actively misleading the model). Found while building the resolution-anchor fix.""" + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["temporal_refs"]) + assert "using: None" not in result + assert "None." not in result + + def test_temporal_refs_includes_resolve_instruction_when_date_given(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["temporal_refs"], date="2025-03-01") + assert "Resolve relative dates using: 2025-03-01" in result + def test_relations_fragment_present(self): result = build_extraction_prompt( SAMPLE_TEXT, diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index 3b06fe6..4c07825 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -333,6 +333,68 @@ def test_invalid_temporal_type(self): assert any("type" in e.path for e in result.errors) +class TestTemporalRefRole: + """The validity ROLE (direction) enrichment — config/design/extract-temporal-role- + 2026-07-14.md. role is BASE-tier (no temporal_classes capability needed), optional + (existing {raw, resolved} consumers still validate), and enum-constrained.""" + + @pytest.mark.parametrize("role", ["effective", "expiry", "range", "superseded", "point"]) + def test_valid_role_values(self, role): + temporal_ref = {"version": "1", "raw": "some date reference", "role": role, "resolved": "2026-04-28"} + if role == "range": + temporal_ref["resolved_end"] = "2026-05-01" + doc = _minimal_extraction(temporal_refs=[temporal_ref]) + result = validate_extraction(doc) + assert result.valid, result.errors + + def test_role_absent_still_valid(self): + """Backward compat: a temporal ref with NO role (existing/older extractions, or a + caller not requesting role classification) still validates.""" + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "next Tuesday", "resolved": "2026-04-28", + }]) + result = validate_extraction(doc) + assert result.valid + + def test_invalid_role_value_rejected(self): + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "sometime", "role": "urgent", "resolved": "2026-04-28", + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("role" in e.path for e in result.errors) + + def test_role_range_without_resolved_end_rejected(self): + """role=="range" requires resolved_end, mirroring the existing type=="range" rule + (TestTemporalRangeConstraints) — a SEPARATE check, since role can appear without type + now that role is base-tier and type stays temporal_classes-gated.""" + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "April 20 to May 1", "role": "range", "resolved": "2026-04-20", + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("resolved_end" in e.path or "resolved_end" in e.message for e in result.errors) + + def test_role_present_without_temporal_classes_capability_still_valid(self): + """The load-bearing base-tier requirement: role must validate WITHOUT temporal_classes + being present anywhere in the document — it is not gated behind that capability.""" + doc = _minimal_extraction( + temporal_refs=[{"version": "1", "raw": "expires soon", "role": "expiry", "resolved": "2026-04-30"}], + capabilities=["entities", "goals", "themes", "temporal_refs"], + ) + result = validate_extraction(doc) + assert result.valid, result.errors + + def test_role_effective_without_type_valid(self): + """role can appear on its own, without the temporal_classes-gated `type` field at + all — confirms role and type are independent, not a hidden pairing requirement.""" + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "effective March 2026", "role": "effective", "resolved": "2026-03-01", + }]) + result = validate_extraction(doc) + assert result.valid, result.errors + + class TestProducedByFormat: def test_produced_by_requires_scheme(self): From fd8dff329a784106da01726ef883805b57cc3b82 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Tue, 14 Jul 2026 22:32:47 -0500 Subject: [PATCH 2/3] feat(extract): ts/py parity for the temporal-role slice + canonical schema sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm --- CHANGELOG.md | 11 +++-- packages/python/src/synapt/extract/batch.py | 10 ++-- packages/python/src/synapt/extract/schema.py | 1 + packages/ts/package.json | 2 +- packages/ts/src/builder.ts | 9 +++- packages/ts/src/finalize.ts | 5 +- packages/ts/src/prompt-data.ts | 2 +- packages/ts/src/schema.ts | 1 + packages/ts/src/validate.ts | 18 ++++++- packages/ts/tests/test_finalize.ts | 15 ++++++ packages/ts/tests/test_prompt.ts | 43 ++++++++++++++++ packages/ts/tests/test_validate.ts | 52 ++++++++++++++++++++ schemas/temporal-ref/v1.json | 14 ++++++ tests/python/test_validate.py | 17 +++++++ 14 files changed, 187 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4d62e..43d9720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,17 @@ # Changelog -## v0.6.0 (Python) +## v0.6.0 -Temporal validity role + resolution anchor — additive Stage-1 IL enrichment (config/design/extract-temporal-role-2026-07-14.md). +Temporal validity role + resolution anchor — additive Stage-1 IL enrichment (config/design/extract-temporal-role-2026-07-14.md). Both `synapt-extract` (PyPI) and `@synapt-dev/extract` (npm) bump to 0.6.0 in lockstep for the additive-role coherence slice. - Added `role` (`effective` | `expiry` | `range` | `superseded` | `point`) to the temporal-ref schema, capturing the validity DIRECTION a date constrains (e.g. "expires April 30" → `expiry`, vs "effective March 2026" → `effective`) — a semantic distinction the source sentence carries but prior extraction dropped - `role` and `resolved_end` are now BASE-tier on the `temporal_refs` capability (no longer gated behind the separate `temporal_classes` capability) — always available to any caller requesting `temporal_refs`; `type`/`context` remain `temporal_classes`-gated - `BatchUnit` gained an optional `date` field — the unit's SOURCE date, threaded into Stage-1 as the temporal resolution anchor so partial/relative dates (e.g. "April 30") resolve against the fact's actual source year, not an unanchored guess -- Fixed a prompt-rendering gap where an absent `date` param rendered the literal string "Resolve relative dates using: None." instead of omitting the instruction +- The exported `SynaptTemporalRef` type declares `role` in both Python (`TypedDict`) and TypeScript (`interface`), so the public type matches the runtime emission +- Fixed a prompt-rendering gap where an absent `date` param rendered the literal string "Resolve relative dates using: None." instead of omitting the instruction (wrapped in `{{#if date}}`; supported identically by both prompt renderers) - `_detect_capabilities`'s `temporal_classes` heuristic now keys on `type` presence only (`resolved_end` no longer implies the gated capability was exercised, since it moved to base tier) -- Published JSON schema (`schemas/temporal-ref/v1.json`) updated to match -- TypeScript (`@synapt-dev/extract`) parity is tracked as a follow-up, not included in this release — the batch/coercion bug this fix traces back to is Python-specific (no TS `extract_batch` equivalent exists yet) +- Published JSON schema (`schemas/temporal-ref/v1.json`, both the repo-root canonical copy and the Python package copy) updated to match — schema-drift-check green +- **ts/py parity — the additive `role` coherence slice IS included** (`@synapt-dev/extract` TypeScript): `schema.ts` types `role`, `builder.ts` emits `role`/`resolved_end` base-tier, `validate.ts` accepts/enum-validates `role` (+ `role === "range"` → `resolved_end`), `finalize.ts` drops the `resolved_end` inference, and the embedded prompt fragment matches the shared file byte-for-byte. TS and Python produce identical schema/validation/finalize output on the same inputs (verified cross-language). The FULL parity effort (a TS `extract_batch`/`batch.ts` port + version-sync) remains the post-validation follow-up per config/design/extract-ts-py-parity-plan-2026-07-15.md — this release only carries the additive-role coherence needed to keep the shared prompt/schema surface consistent. ## v0.5.0 diff --git a/packages/python/src/synapt/extract/batch.py b/packages/python/src/synapt/extract/batch.py index f0050d9..c42d982 100644 --- a/packages/python/src/synapt/extract/batch.py +++ b/packages/python/src/synapt/extract/batch.py @@ -17,9 +17,11 @@ Contract (pinned + spec-confirmed) ---------------------------------- - • Input: list[BatchUnit(id, text, capabilities?)] — explicit attribution; the + • Input: list[BatchUnit(id, text, capabilities?, date?)] — explicit attribution; the id rides into the output as source_unit_id (boundaries stay out-of-band, never - in model-visible text). + in model-visible text). `date` (optional) is the unit's SOURCE date, threaded into + Stage-1 as the temporal resolution anchor (config/design/extract-temporal-role- + 2026-07-14.md) so partial/relative dates resolve against the source, not a guess. • Inference: an injected `infer` seam receiving a request {prompt, messages, capabilities} and returning a completion string. ZERO recall dependency. • v1 strategy: PER-UNIT (one infer call per unit) — trivially out-of-band, clean @@ -37,7 +39,9 @@ coerced (scalar→array; null/non-string OPTIONAL fields like category or decided_at are omitted; an invalid REQUIRED field is kept so strict validation rejects it), out-of-scope dropped; temporal_refs → schema-valid - raw/resolved only; non-dict leaves preserved into strict validation. + raw/resolved + base-tier role/resolved_end (type/context stay temporal_classes- + gated, so they are stripped at the base tier); non-dict leaves preserved into + strict validation. Harvest map: scratchpad/extract_batch_craft_harvest.md. Boundary: OSS. """ diff --git a/packages/python/src/synapt/extract/schema.py b/packages/python/src/synapt/extract/schema.py index 7b47b91..6312025 100644 --- a/packages/python/src/synapt/extract/schema.py +++ b/packages/python/src/synapt/extract/schema.py @@ -35,6 +35,7 @@ class SynaptTemporalRef(TypedDict, total=False): version: str raw: str type: Literal["point", "range", "duration", "unresolved"] + role: Literal["effective", "expiry", "range", "superseded", "point"] resolved: str resolved_end: str context: str diff --git a/packages/ts/package.json b/packages/ts/package.json index b1e95b4..474977f 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -1,6 +1,6 @@ { "name": "@synapt-dev/extract", - "version": "0.5.0", + "version": "0.6.0", "description": "SynaptExtraction IL v1 -- schema, validation, and finalization", "type": "module", "main": "dist/index.js", diff --git a/packages/ts/src/builder.ts b/packages/ts/src/builder.ts index 276f5c3..fc71bb7 100644 --- a/packages/ts/src/builder.ts +++ b/packages/ts/src/builder.ts @@ -406,9 +406,17 @@ function sourceMetadataSchema(finalized = false): JsonSchema { } function temporalRefSchema(capabilities: Set, finalized = false): JsonSchema { + // role + resolved_end are BASE-tier (config/design/extract-temporal-role-2026-07-14.md): + // always available with just the "temporal_refs" capability, NOT gated behind + // "temporal_classes" — role is the load-bearing direction signal recall's deterministic + // mapper needs, and role === "range" needs resolved_end to be usable at all. type/context + // stay temporal_classes-gated (non-load-bearing extras once role carries the direction). + // Mirrors the Python _temporal_ref_schema. const properties: JsonSchema = { raw: { type: "string" }, resolved: { type: "string" }, + resolved_end: { type: "string" }, + role: { type: "string", enum: ["effective", "expiry", "range", "superseded", "point"] }, }; const required = ["raw"]; @@ -419,7 +427,6 @@ function temporalRefSchema(capabilities: Set, finalized = if (capabilities.has("temporal_classes")) { properties.type = { type: "string", enum: ["point", "range", "duration", "unresolved"] }; - properties.resolved_end = { type: "string" }; properties.context = { type: "string" }; required.push("type"); } diff --git a/packages/ts/src/finalize.ts b/packages/ts/src/finalize.ts index 483985b..bc0c0e7 100644 --- a/packages/ts/src/finalize.ts +++ b/packages/ts/src/finalize.ts @@ -143,7 +143,10 @@ function detectCapabilities(doc: Record): ExtractionCapability[ if (Array.isArray(doc.temporal_refs) && (doc.temporal_refs as unknown[]).length > 0) { caps.push("temporal_refs"); const refs = doc.temporal_refs as Record[]; - if (refs.some((r) => r.type !== undefined || r.resolved_end !== undefined)) caps.push("temporal_classes"); + // `type` alone still implies temporal_classes was exercised (it stays gated behind that + // capability). `resolved_end` no longer does — it moved to the BASE temporal_refs tier + // alongside `role` (config/design/extract-temporal-role-2026-07-14.md). Mirrors Python. + if (refs.some((r) => r.type !== undefined)) caps.push("temporal_classes"); } if (typeof doc.language === "string") caps.push("language"); diff --git a/packages/ts/src/prompt-data.ts b/packages/ts/src/prompt-data.ts index 7d9e4d4..0afbe1e 100644 --- a/packages/ts/src/prompt-data.ts +++ b/packages/ts/src/prompt-data.ts @@ -243,6 +243,6 @@ export const EMBEDDED_PROMPT_FRAGMENTS: Record = { structured_sentiment: "- \"sentiment\": object with \"valence\" (one of \"positive\", \"negative\", \"neutral\", \"mixed\"), optional \"intensity\" (0.0-1.0), optional \"confidence\" (0.0-1.0)\n", summary: "- \"summary\": one sentence, max 200 characters\n", temporal_classes: " - include \"type\" (\"point\", \"range\", \"duration\", \"unresolved\") and \"resolved_end\" for ranges\n", - temporal_refs: "- \"temporal_refs\": array of objects with \"raw\" (as it appeared in text), \"type\" (\"point\", \"range\", \"duration\", or \"unresolved\"), \"resolved\" (ISO 8601 date/datetime for points and range starts), optional \"resolved_end\" (required for ranges), and optional \"context\". Resolve relative dates using: {{date}}. For durations, omit \"resolved\" and \"resolved_end\" unless the source gives concrete dates; never put ISO duration strings such as \"P30D\" or \"P1Y\" in \"resolved\".\n", + temporal_refs: "- \"temporal_refs\": array of objects with \"raw\" (as it appeared in text), \"role\" (\"effective\", \"expiry\", \"range\", \"superseded\", or \"point\" — the validity direction this date constrains, see below), \"type\" (\"point\", \"range\", \"duration\", or \"unresolved\"), \"resolved\" (ISO 8601 date/datetime for points and range starts), optional \"resolved_end\" (required for ranges and for role \"range\"), and optional \"context\". {{#if date}}Resolve relative dates using: {{date}}.{{/if}} For durations, omit \"resolved\" and \"resolved_end\" unless the source gives concrete dates; never put ISO duration strings such as \"P30D\" or \"P1Y\" in \"resolved\".\n- Classify each date's \"role\" — which validity boundary it constrains: \"effective\" when the text signals a start (since / from / as of / effective / started / \"migrated in March 2026\"); \"expiry\" when it signals an end (expires / until / by / deadline / ends); \"range\" when it spans from one date to another (from X to Y / between) — requires \"resolved_end\"; \"superseded\" when it describes a prior state that ended (before / \"until the refactor\" / previously); \"point\" only when there is no clear directional signal.\n", themes: "- \"themes\": array of topic strings{{#if categories}} chosen from: {{categories}}{{/if}}\n", }; diff --git a/packages/ts/src/schema.ts b/packages/ts/src/schema.ts index fc672fa..879de5f 100644 --- a/packages/ts/src/schema.ts +++ b/packages/ts/src/schema.ts @@ -28,6 +28,7 @@ export interface SynaptTemporalRef { version: "1"; raw: string; type?: "point" | "range" | "duration" | "unresolved"; + role?: "effective" | "expiry" | "range" | "superseded" | "point"; resolved?: string; resolved_end?: string; context?: string; diff --git a/packages/ts/src/validate.ts b/packages/ts/src/validate.ts index 279cedd..5f38461 100644 --- a/packages/ts/src/validate.ts +++ b/packages/ts/src/validate.ts @@ -20,6 +20,11 @@ const VALID_TEMPORAL_TYPES: Set = new Set([ "point", "range", "duration", "unresolved", ]); +// The validity ROLE (direction) enrichment — config/design/extract-temporal-role-2026-07-14.md. +const VALID_TEMPORAL_ROLES: Set = new Set([ + "effective", "expiry", "range", "superseded", "point", +]); + const VALID_SENTIMENT_VALENCES: Set = new Set([ "positive", "negative", "neutral", "mixed", ]); @@ -58,7 +63,7 @@ const SOURCE_METADATA_KEYS = new Set(["version", "token_count", "character_count const RELATION_KEYS = new Set(["target", "type", "origin", "signals"]); const SOURCE_REF_KEYS = new Set(["version", "snippet", "offset_start", "offset_end", "sentence_index"]); const SIGNALS_KEYS = new Set(["version", "confidence", "negated", "hedged", "condition"]); -const TEMPORAL_REF_KEYS = new Set(["version", "raw", "type", "resolved", "resolved_end", "context"]); +const TEMPORAL_REF_KEYS = new Set(["version", "raw", "type", "role", "resolved", "resolved_end", "context"]); const EMBEDDING_KEYS = new Set(["version", "vector", "model", "input", "dimensions", "space", "computed_at"]); const PRODUCER_KEYS = new Set([ "version", "model", "model_version", "deployment", "configuration", @@ -533,6 +538,17 @@ function validateTemporalRef(obj: unknown, path: string, errors: ValidationError } } } + // Validity ROLE (direction) — BASE-tier, optional, independent of `type` (config/design/ + // extract-temporal-role-2026-07-14.md). A separate role === "range" -> resolved_end check + // mirrors the type === "range" one above, since role can appear without type now that role + // doesn't require the temporal_classes capability. Mirrors the Python _check_temporal_ref. + if (ref.role !== undefined) { + if (typeof ref.role !== "string" || !VALID_TEMPORAL_ROLES.has(ref.role)) { + errors.push({ path: `${path}.role`, message: "must be one of: effective, expiry, range, superseded, point" }); + } else if (ref.role === "range" && ref.resolved_end === undefined) { + errors.push({ path: `${path}.resolved_end`, message: "required when role is 'range'" }); + } + } if (ref.resolved !== undefined) { if (typeof ref.resolved !== "string" || !isIsoDatetime(ref.resolved)) { errors.push({ path: `${path}.resolved`, message: "must be a valid ISO 8601 date/datetime" }); diff --git a/packages/ts/tests/test_finalize.ts b/packages/ts/tests/test_finalize.ts index 2f2d289..b652b93 100644 --- a/packages/ts/tests/test_finalize.ts +++ b/packages/ts/tests/test_finalize.ts @@ -193,6 +193,21 @@ describe("finalizeExtraction", () => { expect(result.extraction.capabilities).toContain(capability); }); + test("resolved_end alone does not imply temporal_classes (base-tier role)", () => { + // role + resolved_end are BASE-tier (config/design/extract-temporal-role-2026-07-14.md) — + // a range-role ref can carry resolved_end WITHOUT temporal_classes ever being exercised. + // The old heuristic (type OR resolved_end) would mislabel this; only `type` should trigger + // detection now. Mirrors the Python test_resolved_end_alone_does_not_imply_temporal_classes. + const result = finalizeExtraction( + llmOutput({ + temporal_refs: [{ raw: "March to April 2026", role: "range", resolved: "2026-03-01", resolved_end: "2026-04-30" }], + }), + { produced_by: "test://model" }, + ); + expect(result.extraction.capabilities).toContain("temporal_refs"); + expect(result.extraction.capabilities).not.toContain("temporal_classes"); + }); + test("warns on mismatched capabilities hint", () => { const result = finalizeExtraction(llmOutput(), { produced_by: "test://model", diff --git a/packages/ts/tests/test_prompt.ts b/packages/ts/tests/test_prompt.ts index 4d9d7ae..203ca5b 100644 --- a/packages/ts/tests/test_prompt.ts +++ b/packages/ts/tests/test_prompt.ts @@ -161,6 +161,25 @@ describe("buildExtractionPrompt", () => { expect(result).toContain("2026-04-25"); }); + test("temporal_refs fragment carries role classification instructions", () => { + // config/design/extract-temporal-role-2026-07-14.md — the Stage-1 prompt classifies each + // temporal ref's validity role, with the 5 enum values named. Mirrors the Python test. + const result = buildExtractionPrompt(SAMPLE_TEXT, { capabilities: ["temporal_refs"] }); + expect(result).toContain('"role"'); + for (const role of ["effective", "expiry", "range", "superseded", "point"]) { + expect(result).toContain(role); + } + }); + + test("temporal_refs omits the resolve instruction (no literal 'None') when no date given", () => { + // Regression guard mirroring the Python test: without a date the {{#if date}} conditional + // must render nothing, NOT the literal "Resolve relative dates using: None." The TS + // renderer supports {{#if}} identically to Python (prompt.ts), so this holds cross-language. + const result = buildExtractionPrompt(SAMPLE_TEXT, { capabilities: ["temporal_refs"] }); + expect(result).not.toContain("using: None"); + expect(result).not.toContain("None."); + }); + test.each([ ["minimal", false, false], ["standard", false, true], @@ -481,6 +500,30 @@ describe("buildExtractionSchema", () => { expect(temporalRef.required).toEqual(["version", "raw"]); }); + test("temporal role + resolved_end are base-tier; type/context stay temporal_classes-gated", () => { + // config/design/extract-temporal-role-2026-07-14.md — role is the load-bearing direction + // signal, always available with just "temporal_refs"; type/context remain gated. Mirrors + // the Python base-tier coverage (test_role_and_resolved_end_survive_coercion + the + // type/context negative control). Requesting ONLY temporal_refs, NOT temporal_classes. + const base = buildExtractionSchema({ capabilities: ["temporal_refs"] }); + const baseProps = ((base.properties as Record).temporal_refs as { + items: { properties: Record }; + }).items.properties; + expect(baseProps.role).toBeDefined(); + expect(baseProps.resolved_end).toBeDefined(); + expect(baseProps.type).toBeUndefined(); // temporal_classes-gated + expect(baseProps.context).toBeUndefined(); // temporal_classes-gated + expect(baseProps.role).toEqual({ type: "string", enum: ["effective", "expiry", "range", "superseded", "point"] }); + + const gated = buildExtractionSchema({ capabilities: ["temporal_refs", "temporal_classes"] }); + const gatedProps = ((gated.properties as Record).temporal_refs as { + items: { properties: Record }; + }).items.properties; + expect(gatedProps.type).toBeDefined(); // now present with temporal_classes + expect(gatedProps.context).toBeDefined(); + expect(gatedProps.role).toBeDefined(); // role still present (base-tier, unaffected) + }); + test("schema covers all v1.2 capability fields", () => { const schema = buildExtractionSchema({ profile: "full" }); const properties = schema.properties as Record; diff --git a/packages/ts/tests/test_validate.ts b/packages/ts/tests/test_validate.ts index 98a044d..282d890 100644 --- a/packages/ts/tests/test_validate.ts +++ b/packages/ts/tests/test_validate.ts @@ -310,6 +310,58 @@ describe("validateExtraction", () => { false, ["temporal_refs[0].resolved_end"], ], + // Validity ROLE (direction) enrichment — config/design/extract-temporal-role-2026-07-14.md. + // Mirrors the Python TestTemporalRefRole cases. role + resolved_end are BASE-tier (no + // temporal_classes capability needed); role is enum-constrained; role === "range" needs + // resolved_end. + [ + "valid role effective (base-tier, no type)", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "effective March 2026", role: "effective", resolved: "2026-03-01" }], + }), + true, + [], + ], + [ + "valid role expiry", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "expires April 30", role: "expiry", resolved: "2026-04-30" }], + }), + true, + [], + ], + [ + "valid role range with resolved_end", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "March to April", role: "range", resolved: "2026-03-01", resolved_end: "2026-04-30" }], + }), + true, + [], + ], + [ + "role absent still valid (backward compat)", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "next Tuesday", resolved: "2026-04-28" }], + }), + true, + [], + ], + [ + "invalid role value rejected", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "sometime", role: "urgent", resolved: "2026-04-28" }], + }), + false, + ["temporal_refs[0].role"], + ], + [ + "role range without resolved_end rejected", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "April 20 to May 1", role: "range", resolved: "2026-04-20" }], + }), + false, + ["temporal_refs[0].resolved_end"], + ], ])("%s", (_name, doc, expectedValid, paths) => { const result = validateExtraction(doc); expect(result.valid).toBe(expectedValid); diff --git a/schemas/temporal-ref/v1.json b/schemas/temporal-ref/v1.json index a82c100..cb25560 100644 --- a/schemas/temporal-ref/v1.json +++ b/schemas/temporal-ref/v1.json @@ -19,6 +19,11 @@ "enum": ["point", "range", "duration", "unresolved"], "description": "Value class. Not all temporal expressions resolve to a single timestamp." }, + "role": { + "type": "string", + "enum": ["effective", "expiry", "range", "superseded", "point"], + "description": "Validity direction this date constrains: 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)." + }, "resolved": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}(:\\d{2})?(\\.\\d+)?(Z|[+\\-]\\d{2}:?\\d{2})?)?$", @@ -46,6 +51,15 @@ "required": ["resolved_end"] } }, + { + "if": { + "properties": { "role": { "const": "range" } }, + "required": ["role"] + }, + "then": { + "required": ["resolved_end"] + } + }, { "if": { "properties": { "type": { "const": "unresolved" } }, diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index 4c07825..042ba53 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -338,6 +338,23 @@ class TestTemporalRefRole: 2026-07-14.md. role is BASE-tier (no temporal_classes capability needed), optional (existing {raw, resolved} consumers still validate), and enum-constrained.""" + def test_public_typeddict_declares_role(self): + """GUARD (Sentinel's finding): the exported public SynaptTemporalRef TypedDict must + declare `role`. The v0.6 runtime EMITS role (builder schema + validation accept it), + so a public type that omitted it would reject its own runtime output — the exact + type/runtime mismatch this guards against. Also confirms the declared enum members + exactly match the validator's accepted set (single source of truth for the role + vocabulary across the type + the validator).""" + import typing + from synapt.extract.schema import SynaptTemporalRef + from synapt.extract.validate import VALID_TEMPORAL_ROLES + + assert "role" in SynaptTemporalRef.__annotations__ + # schema.py uses `from __future__ import annotations`, so raw __annotations__ are + # ForwardRef strings — resolve with get_type_hints before reading the Literal members. + role_type = typing.get_type_hints(SynaptTemporalRef)["role"] + assert set(typing.get_args(role_type)) == set(VALID_TEMPORAL_ROLES) + @pytest.mark.parametrize("role", ["effective", "expiry", "range", "superseded", "point"]) def test_valid_role_values(self, role): temporal_ref = {"version": "1", "raw": "some date reference", "role": role, "resolved": "2026-04-28"} From acaccbd6957a591e7d3fcdb3b10ceb83fec385d1 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Tue, 14 Jul 2026 22:42:47 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(extract):=20keep=20npm=20package=20at?= =?UTF-8?q?=200.5.0=20=E2=80=94=20version-sync=20is=20deferred,=20not=20th?= =?UTF-8?q?is=20slice=20(Sentinel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm --- CHANGELOG.md | 2 +- packages/ts/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d9720..4d14ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v0.6.0 -Temporal validity role + resolution anchor — additive Stage-1 IL enrichment (config/design/extract-temporal-role-2026-07-14.md). Both `synapt-extract` (PyPI) and `@synapt-dev/extract` (npm) bump to 0.6.0 in lockstep for the additive-role coherence slice. +Temporal validity role + resolution anchor — additive Stage-1 IL enrichment (config/design/extract-temporal-role-2026-07-14.md). `synapt-extract` (PyPI) bumps to 0.6.0. `@synapt-dev/extract` (npm) carries the same additive-role parity but keeps its version at 0.5.0 for now — the npm version-sync is part of the deferred full-parity effort, not this additive-role slice. - Added `role` (`effective` | `expiry` | `range` | `superseded` | `point`) to the temporal-ref schema, capturing the validity DIRECTION a date constrains (e.g. "expires April 30" → `expiry`, vs "effective March 2026" → `effective`) — a semantic distinction the source sentence carries but prior extraction dropped - `role` and `resolved_end` are now BASE-tier on the `temporal_refs` capability (no longer gated behind the separate `temporal_classes` capability) — always available to any caller requesting `temporal_refs`; `type`/`context` remain `temporal_classes`-gated diff --git a/packages/ts/package.json b/packages/ts/package.json index 474977f..b1e95b4 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -1,6 +1,6 @@ { "name": "@synapt-dev/extract", - "version": "0.6.0", + "version": "0.5.0", "description": "SynaptExtraction IL v1 -- schema, validation, and finalization", "type": "module", "main": "dist/index.js",