Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 20 additions & 5 deletions packages/python/src/synapt/extract/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}],
Expand Down
9 changes: 8 additions & 1 deletion packages/python/src/synapt/extract/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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")

Expand Down
6 changes: 5 additions & 1 deletion packages/python/src/synapt/extract/finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions packages/python/src/synapt/extract/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/python/src/synapt/extract/schemas/temporal-ref/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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})?)?$",
Expand Down Expand Up @@ -46,6 +51,15 @@
"required": ["resolved_end"]
}
},
{
"if": {
"properties": { "role": { "const": "range" } },
"required": ["role"]
},
"then": {
"required": ["resolved_end"]
}
},
{
"if": {
"properties": { "type": { "const": "unresolved" } },
Expand Down
17 changes: 16 additions & 1 deletion packages/python/src/synapt/extract/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"))
Expand Down
9 changes: 8 additions & 1 deletion packages/ts/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,17 @@ function sourceMetadataSchema(finalized = false): JsonSchema {
}

function temporalRefSchema(capabilities: Set<ExtractionCapability>, 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"];

Expand All @@ -419,7 +427,6 @@ function temporalRefSchema(capabilities: Set<ExtractionCapability>, 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");
}
Expand Down
5 changes: 4 additions & 1 deletion packages/ts/src/finalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ function detectCapabilities(doc: Record<string, unknown>): 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<string, unknown>[];
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");
Expand Down
2 changes: 1 addition & 1 deletion packages/ts/src/prompt-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,6 @@ export const EMBEDDED_PROMPT_FRAGMENTS: Record<string, string> = {
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",
};
1 change: 1 addition & 0 deletions packages/ts/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion packages/ts/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ const VALID_TEMPORAL_TYPES: Set<string> = 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<string> = new Set([
"effective", "expiry", "range", "superseded", "point",
]);

const VALID_SENTIMENT_VALENCES: Set<string> = new Set([
"positive", "negative", "neutral", "mixed",
]);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading