diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a50e34..4d14ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.6.0 + +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 +- `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 +- 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`, 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 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..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. """ @@ -88,11 +92,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 +163,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/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/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/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/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/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_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..042ba53 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -333,6 +333,85 @@ 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.""" + + 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"} + 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):