diff --git a/decisions.md b/decisions.md index 2e257d8c..89d6521d 100644 --- a/decisions.md +++ b/decisions.md @@ -943,6 +943,15 @@ restructuring, concluded the claim/relation split, D6, and relation-only superse upgrade is an expressivity child table (btree-indexed, D23-restamped), built only on measured demand. Full detail: `e2_e3_claims_relations_design.md` §5/§7, `postgres_schema_design.md` §8/§15/§17. +**Amendment (2026-07-29, issue #158).** When a source uses a relative time such as "yesterday" or +"last year" and its document header supplies an absolute date, E2 must resolve the relative time +against that in-document anchor and store the result in `claim_valid_from` / `claim_valid_until` with +the honest available precision. The claim text keeps the source's relative wording; the computed date +exists only in the structured valid-time fields. If the document has no absolute anchor, E2 leaves the +fields empty rather than guessing. D32's text-membership gate remains unchanged: it checks added claim +text, not these structured fields. Evidence payloads, including `claims_verbatim` and `explain`, now +surface `claim_valid_from` and `claim_valid_until`, so an answer agent can use the extracted time. + --- ## D42. E0 records document origin at ingestion (external vs. system-generated) diff --git a/plan/designs/e2_e3_claims_relations_design.md b/plan/designs/e2_e3_claims_relations_design.md index c569b220..282fd7e8 100644 --- a/plan/designs/e2_e3_claims_relations_design.md +++ b/plan/designs/e2_e3_claims_relations_design.md @@ -31,9 +31,11 @@ source**, and only trustworthy if it is **actually supported** by that source. T > *"It launched last year in three markets. The team considers it a runaway success."* -- **Understandability fails.** In isolation the model cannot know what *It* is or when *last year* - was, so it emits `"It launched last year in three markets"` — a claim no downstream step can use - (you can't resolve an entity called "It" or a date called "last year"). +- **Understandability fails.** In isolation the model cannot know what *It* is or which valid-time + bounds *last year* denotes, so it emits `"It launched last year in three markets"` — a claim whose + entity and valid-time cannot be resolved downstream. With the context bundle, the entity is + decontextualized in `claim_text`; the relative wording stays there, while its anchored resolution + lands only in the structured valid-time fields. - **Faithfulness is mis-aimed.** A *verbatim-quote* requirement rewards copying surface text, which is the opposite of making a claim standalone — and it has no opinion about whether `"The team considers it a runaway success"` (an opinion, not a checkable fact) should be a claim at all. @@ -79,19 +81,19 @@ Over that bundle the model does three things, in order (the "Claimify" shape). E success" with no holder would drop. *(This stage is the single biggest quality lever — in the source research, removing it was the largest quality drop of any component.)* -2. **Decontextualization — make it stand alone.** Resolve every pronoun, partial name, acronym, and - relative date **using the bundle, never outside knowledge**, and add the **minimum** context needed +2. **Decontextualization — make it stand alone.** Resolve every pronoun, partial name, and acronym + **using the bundle, never outside knowledge**, and add the **minimum** context needed — over-stuffing both bloats the claim and risks asserting something the source didn't. Coreference is handled right here, in the same call (D19): no claim leaves E2 with a dangling pronoun. The discipline that makes this safe: **if a careful reader could not pick one interpretation from the bundle, drop the candidate** rather than guess. In the example, the neighbours name *Project Atlas* - and the header dates the document to 2024, so "It launched last year" becomes "Project Atlas - launched in 2024." + so "It launched last year" becomes "Project Atlas launched last year." The relative wording remains + in claim text; its absolute resolution is structured valid-time, described below. 3. **Decomposition — split into atoms.** Break the disambiguated sentence into the simplest standalone claims, preserving attribution ("*X said* Y" stays attributed, it does not become a bare "Y"). The - example yields two: `"Project Atlas launched in 2024."` and `"Project Atlas launched in three - markets."` + example yields two: `"Project Atlas launched last year."` (with the resolved interval only in its + structured valid-time fields) and `"Project Atlas launched in three markets."` **Two calls, not one (D31).** Selection is run as its own (optionally voted) call, then decontextualization + decomposition + grounding run as a second fused call. Selection is split out @@ -114,11 +116,25 @@ Grounding therefore stores **two things per claim** and accepts via **layered ch source-asserted valid-time as *nullable typed scalars only* (no free-form objects — strict-schema constraint from #145): `valid_kind` (`proposition_validity|event_time|measurement_period|effective_period`), `valid_from_iso` / `valid_until_iso` (ISO-8601 date or datetime strings), and `valid_precision` -(`unknown|instant|day|month|quarter|year|open`). Relative dates resolve from bundle timestamps only, -the same rule as decontextualization. E2 parses them deterministically into +(`unknown|instant|day|month|quarter|year|open`). Relative dates resolve from bundle timestamps only; +the #158 amendment below specifies their structured-only output. E2 parses them deterministically into `claims.claim_valid_*`; a malformed string falls back to unknown/null for the temporal fields without rejecting the claim. Most claims have no stated world-time and leave these null/unknown. +**Amendment (2026-07-29, issue #158):** relative temporal expressions are resolved against an +absolute timestamp in the document header whenever that arithmetic supports an honest interval. +The computed date goes only into `valid_from_iso` / `valid_until_iso`; `claim_text` keeps the relative +phrase as the source spoke it. For example, with a 2023-05-08 header, "visited yesterday" keeps that +wording and emits the day bounds 2023-05-07, while "painted last year" emits the year bounds +2022-01-01 through 2022-12-31 with `valid_precision=year`. With no absolute in-document anchor, or +when a vague phrase cannot fit the available precision vocabulary without invention, E2 omits the +structured time. D32 layer 2 does not gate these structured fields: its membership union applies only +to text in `added_context`. The evidence-row builder used by `claims_verbatim`, claim hydration, and +`explain` now returns `claim_valid_from` and `claim_valid_until`. A #158 non-goal is surfacing +`valid_precision` or `valid_kind` on `EvidenceResult`: it exposes only `claim_valid_from` and +`claim_valid_until`. Precision is inferable from the bounds for these cases (equal ends = day; +calendar-year span = year); surfacing the enums is a possible follow-up, not part of #158. + **Amendment (2026-07-29, union grounding):** layer-2 membership is checked against the **union of source-derived bundle elements**: the TARGET CHUNK slice, deterministic document header, both available same-section neighbours, and the stored context prefix. The model-emitted @@ -152,9 +168,10 @@ Acceptance layers four checks, cheapest first: for media: claim → `source_span`, exact and deterministic; span → source map → raw locator, at the converter's disclosed precision — `media_design.md` §4.) -So in the example, `"Project Atlas launched in 2024"` is accepted: its anchor is the verbatim "It -launched last year", and the additions "Project Atlas" (→ neighbour) and "2024" (→ header) both exist -in the bundle. The dropped opinion never reaches grounding. +So in the example, `"Project Atlas launched last year"` is accepted: its anchor is the verbatim "It +launched last year", and "Project Atlas" (→ neighbour) is the only `added_context` entry. The resolved +bounds are emitted only in the structured valid-time fields and do not enter the membership gate. +The attributed stance is grounded separately. ### 3.4 Nothing is silently lost (D33, D35) @@ -273,16 +290,16 @@ internals (entity resolution, predicate registry, the supersession cascade) are ## 6. End-to-end, in one example -> Source chunk (inside a *Results* section of a 2024 product memo): *"It launched last year in three +> Source chunk (inside a *Results* section of a 2025 product memo): *"It launched last year in three > markets. The team considers it a runaway success."* Neighbour text names **Project Atlas**. | Stage | What happens | |---|---| -| **E1** | chunk + a context prefix ("…from the Results section of the Project Atlas 2024 memo…") | +| **E1** | chunk + a context prefix ("…from the Results section of the Project Atlas 2025 memo…") | | **E2 Selection** | keep "launched last year in three markets"; **keep** "The team considers it a runaway success" as the team's attributed stance (D59 — a bare, holderless version would drop → ledger) | -| **E2 Decontextualize** | "It"→Project Atlas (neighbour), "last year"→2024 (header) → *"Project Atlas launched in 2024 in three markets"* | -| **E2 Decompose** | `"Project Atlas launched in 2024."` (emits `valid_kind=event_time`, `valid_from_iso`/`valid_until_iso` for 2024's bounds, `valid_precision=year` — D41; amendment 2026-07-27 / #146) + `"Project Atlas launched in three markets."` | -| **E2 Grounding** | each accepted: anchor span present, additions trace to bundle, entailed; the date "2024" verbatim-exists in the bundle, so the asserted interval is grounded (D32) | +| **E2 Decontextualize** | "It"→Project Atlas (neighbour), while "last year" stays source-faithful in claim text → *"Project Atlas launched last year in three markets"* | +| **E2 Decompose** | `"Project Atlas launched last year."` (against the 2025 header, emits `valid_kind=event_time`, 2024 `valid_from_iso`/`valid_until_iso` bounds, `valid_precision=year` — D41; amendment 2026-07-29 / #158) + `"Project Atlas launched in three markets."` | +| **E2 Grounding** | each accepted: anchor span present, textual additions trace to the bundle, entailed; structured valid-time does not enter D32's `added_context` membership gate | | **E3** | the stance claim becomes a **stance observation** on the team entity ("Acme's team considers Project Atlas a runaway success" — D59); neither decomposed launch claim yields a relation — "three markets" is a quantity and "2024" a date, neither a second entity (D2/D18); the temporal one carries `claim_valid_from = 2024` (**D41**), queryable as evidence. A later memo asserting 2023 makes a *second* immutable claim (`claim_valid_from = 2023`); with no relation to host them, **both stand** as evidence and there is no adjudicated supersession — the documented non-goal (`postgres_schema_design.md` §15). | ## 7. Decisions, and what is still a spike diff --git a/src/rememberstack/model/envelope.py b/src/rememberstack/model/envelope.py index ed448985..e1a25a6d 100644 --- a/src/rememberstack/model/envelope.py +++ b/src/rememberstack/model/envelope.py @@ -209,6 +209,8 @@ class EvidenceResult(BaseModel): char_end: int is_attributed: bool is_current_testimony: bool + claim_valid_from: UTCDateTime | None = None + claim_valid_until: UTCDateTime | None = None class SourceRecord(BaseModel): diff --git a/src/rememberstack/surfaces/query_engine.py b/src/rememberstack/surfaces/query_engine.py index fd74a8bb..2413fd68 100644 --- a/src/rememberstack/surfaces/query_engine.py +++ b/src/rememberstack/surfaces/query_engine.py @@ -1215,7 +1215,8 @@ def _co_member(row: dict[str, object]) -> CoMember: _CONFIRM_CLAIMS = text( """ SELECT claim_id, doc_id, chunk_id, claim_text, source_span, - char_start, char_end, is_attributed, is_current_testimony + char_start, char_end, is_attributed, is_current_testimony, + claim_valid_from, claim_valid_until FROM claims WHERE deployment_id = :deployment_id AND claim_id = ANY(:claim_ids) @@ -1237,7 +1238,8 @@ def _co_member(row: dict[str, object]) -> CoMember: _HYDRATE_EVIDENCE_CLAIMS = text( """ SELECT c.claim_id, c.doc_id, c.chunk_id, c.claim_text, c.source_span, - c.char_start, c.char_end, c.is_attributed, c.is_current_testimony + c.char_start, c.char_end, c.is_attributed, c.is_current_testimony, + c.claim_valid_from, c.claim_valid_until FROM relation_evidence e JOIN claims c ON c.claim_id = e.claim_id WHERE e.relation_id = :relation_id AND e.stance = 'supports' diff --git a/src/rememberstack/workers/e1.py b/src/rememberstack/workers/e1.py index aebeb3f1..1e64fae0 100644 --- a/src/rememberstack/workers/e1.py +++ b/src/rememberstack/workers/e1.py @@ -57,16 +57,16 @@ 07b pins temperature=0.0 — generation parameters are part of provenance. 07c adds D79's bounded current-generation section-summary orientation.""" -E2_EXTRACTOR_VERSION: Final = ( - f"e2-extract-2026.07g:union-grounding-1:{SECTION_ORIENTATION_VERSION}" -) +E2_EXTRACTOR_VERSION: Final = f"e2-extract-2026.07h:union-grounding-1:temporal-anchor-1:{SECTION_ORIENTATION_VERSION}" """The extractor generation baked into extraction_input_hash (D56); the E2 -stage (WP-1.3) binds its handler to this same constant. 07g makes D32 layer-2 -grounding union-based across source-derived bundle texts with advisory source -tags; 07f adds D79 summary orientation to the bundle without making summaries -hash or grounding inputs; 07e ledgers Claimify omissions and grounding-gate -rejections on the D33 transcript (#161); 07d pinned temperature=0.0 on the -Selection call (Claimify already carried it).""" +stage (WP-1.3) binds its handler to this same constant. 07h requires relative +temporal expressions to resolve against an in-document absolute anchor into +structured D41 valid-time while claim text keeps the source wording (#158); +07g makes D32 layer-2 grounding union-based across source-derived bundle texts +with advisory source tags; 07f adds D79 summary orientation to the bundle +without making summaries hash or grounding inputs; 07e ledgers Claimify +omissions and grounding-gate rejections on the D33 transcript (#161); 07d +pinned temperature=0.0 on the Selection call (Claimify already carried it).""" _PREFIX_PROMPT_TEMPLATE: Final = ( "In one sentence, state where this passage sits in the document — " diff --git a/src/rememberstack/workers/e2.py b/src/rememberstack/workers/e2.py index 1323a23a..bc65de24 100644 --- a/src/rememberstack/workers/e2.py +++ b/src/rememberstack/workers/e2.py @@ -81,30 +81,57 @@ _CLAIMIFY_PROMPT: Final = """You are the decontextualize+decompose+ground stage of a claim extractor. For each KEPT proposition below: resolve every pronoun, -partial name, acronym, and relative date USING ONLY THE BUNDLE (never outside -knowledge), adding the minimum context needed; split into the simplest -standalone claims, preserving attribution ("X said Y" stays attributed); if a -careful reader could not pick one interpretation from the bundle, omit the -candidate. For each claim return: claim_text (standalone), source_span (the -verbatim chunk substring it derives from), added_context (every substring you -ADDED that is not already present in the TARGET CHUNK; in-chunk text needs no -added_context entry). Tag each addition header|neighbour|prefix as a best-effort -provenance pointer, but the tag is advisory: every addition must exist verbatim -somewhere in the bundle's source-derived texts (TARGET CHUNK, DOCUMENT HEADER, +partial name, and acronym USING ONLY THE BUNDLE (never outside knowledge), +adding the minimum context needed; split into the simplest standalone claims, +preserving attribution ("X said Y" stays attributed); if a careful reader +could not pick one interpretation from the bundle, omit the candidate. For +each claim return: claim_text (standalone), source_span (the verbatim chunk +substring it derives from), added_context (every substring you ADDED that is +not already present in the TARGET CHUNK; in-chunk text needs no added_context +entry). Tag each addition header|neighbour|prefix as a best-effort provenance +pointer, but the tag is advisory: every addition must exist verbatim somewhere +in the bundle's source-derived texts (TARGET CHUNK, DOCUMENT HEADER, same-section PREVIOUS/NEXT CHUNK, or stored CONTEXT PREFIX). SECTION SUMMARIES are orientation only, never quotable and never an added_context source. Also return entailment_self_verdict (does chunk+bundle entail the claim) and -is_attributed. When the source states or implies WHEN a fact holds or happened, -resolve relative dates USING ONLY THE BUNDLE (as with decontextualization) and -emit valid_kind, valid_from_iso, valid_until_iso, and valid_precision. Use -ISO-8601 dates (YYYY-MM-DD) or datetimes WITH an explicit offset or Z; never -emit a datetime without an offset. Otherwise leave valid_kind/from/until null -and valid_precision unknown. Event on a calendar day → -valid_kind=event_time, valid_precision=day, both ISO ends for that day. -Year-only → precision=year with that year's [start,end] ISO bounds; quarters -are calendar quarters. Bounded -precisions (day|month|quarter|year) require both ends; open requires from only; -instant sets both ends equal. +is_attributed. + +TEMPORAL RESOLUTION IS REQUIRED. Whenever the source utterance contains a +relative temporal expression ("yesterday", "last Saturday", "last year", +"this morning", "a few weeks ago", and similar) AND the DOCUMENT HEADER +provides an absolute date or timestamp, you MUST resolve the expression against +that anchor and emit valid_kind, valid_from_iso, valid_until_iso, and +valid_precision. Put the computed absolute time ONLY in those structured +valid-time fields. The claim_text MUST stay faithful to the source: keep the +relative phrase as spoken and never replace it with the computed date. + +Use ISO-8601 dates (YYYY-MM-DD) or datetimes WITH an explicit offset or Z; +never emit a datetime without an offset. Calendar-day expressions use +valid_kind=event_time, valid_precision=day, and the resolved date as both ISO +ends. Year-only expressions use precision=year with that calendar year's +[start,end] ISO bounds; months and quarters likewise use their calendar +bounds. Bounded precisions (day|month|quarter|year) require both ends; open +requires from only; instant sets both ends equal. Use only the precision the +expression supports. For a vague expression that the schema cannot encode +honestly ("a few weeks ago", or "last summer" without source-defined season +bounds), use a coarser honest year only when the source supports it; otherwise +omit valid-time. If the document has no absolute anchor, leave valid_kind, +valid_from_iso, and valid_until_iso null and valid_precision unknown. Never +invent an anchor or a date. + +Examples (DOCUMENT HEADER date → structured output): +- date 2023-05-08; "went to the support group yesterday" → + claim_text="went to the support group yesterday", + valid_kind=event_time, valid_from_iso=2023-05-07, + valid_until_iso=2023-05-07, valid_precision=day. +- date 2023-05-08; "painted a lake sunrise last year" → + claim_text="painted a lake sunrise last year", valid_kind=event_time, + valid_from_iso=2022-01-01, valid_until_iso=2022-12-31, + valid_precision=year. +- date 2023-05-08; "met the organizer last Saturday" → + claim_text="met the organizer last Saturday", valid_kind=event_time, + valid_from_iso=2023-05-06, valid_until_iso=2023-05-06, + valid_precision=day. KEPT PROPOSITIONS: {keeps} diff --git a/src/tests/surfaces/test_retrieval_api.py b/src/tests/surfaces/test_retrieval_api.py index 75b3edbb..b7fb8e4f 100644 --- a/src/tests/surfaces/test_retrieval_api.py +++ b/src/tests/surfaces/test_retrieval_api.py @@ -487,12 +487,30 @@ def test_s5_sources_via_the_hydration_chain(rig: _ApiRig) -> None: relation = rig.client.get( "/lookup/relations", params={"subject_entity_id": alice["entity_id"]} ).json()["facts"][0] + with rig.engine.begin() as connection: + connection.execute( + text( + "UPDATE claims" + " SET claim_valid_from = '2024-01-01+00'," + " claim_valid_until = '2024-12-31+00'," + " claim_valid_precision = 'year'," + " claim_valid_kind = 'event_time'" + " WHERE claim_text = 'Alice Novak joined Acme in 2024.'" + ) + ) hydrated = rig.client.get(f"/hydrate/relation/{relation['fact_id']}").json() assert hydrated["grain"] == "composite" assert len(hydrated["evidence"]) == 2 # both asserting claims for claim in hydrated["evidence"]: assert _SOURCE[claim["char_start"] : claim["char_end"]] == claim["source_span"] + by_text = {claim["claim_text"]: claim for claim in hydrated["evidence"]} + stamped = by_text["Alice Novak joined Acme in 2024."] + assert stamped["claim_valid_from"] == "2024-01-01T00:00:00Z" + assert stamped["claim_valid_until"] == "2024-12-31T00:00:00Z" + unstamped = by_text["Alice Novak works for Acme."] + assert unstamped["claim_valid_from"] is None + assert unstamped["claim_valid_until"] is None (source,) = hydrated["sources"] assert source["title"] == "staffing" assert source["markdown_uri"].endswith("/document.md") @@ -624,6 +642,17 @@ def test_search_claims_is_evidence_grain_with_drop_count_honesty( lost currency is dropped and counted — never served. Claims answers are EVIDENCE grain, never current-fact. The first read also crosses an artificially small batch boundary through the real confirmation path.""" + with rig.engine.begin() as connection: + connection.execute( + text( + "UPDATE claims" + " SET claim_valid_from = '2024-01-01+00'," + " claim_valid_until = '2024-12-31+00'," + " claim_valid_precision = 'year'," + " claim_valid_kind = 'event_time'" + " WHERE claim_text = 'Alice Novak joined Acme in 2024.'" + ) + ) monkeypatch.setattr(query_engine_module, "INTERACTIVE_HYDRATION_BATCH_SIZE", 1) confirmation_calls = 0 @@ -652,6 +681,13 @@ def count_confirmation_calls( assert first["dropped_by_hydration"] == 0 for claim in first["evidence"]: assert claim["is_current_testimony"] + by_text = {claim["claim_text"]: claim for claim in first["evidence"]} + stamped = by_text["Alice Novak joined Acme in 2024."] + assert stamped["claim_valid_from"] == "2024-01-01T00:00:00Z" + assert stamped["claim_valid_until"] == "2024-12-31T00:00:00Z" + unstamped = by_text["Alice Novak works for Acme."] + assert unstamped["claim_valid_from"] is None + assert unstamped["claim_valid_until"] is None # currency flips on one claim in the spine; Lance still nominates it: with rig.engine.begin() as connection: diff --git a/src/tests/workers/test_claim_valid_time.py b/src/tests/workers/test_claim_valid_time.py index 67f94fab..0c8c42c9 100644 --- a/src/tests/workers/test_claim_valid_time.py +++ b/src/tests/workers/test_claim_valid_time.py @@ -12,10 +12,35 @@ from rememberstack.model import ClaimValidPrecision from rememberstack.spine.catalog_contract import CLAIM_VALID_KIND_VALUES from rememberstack.spine.catalog_contract import CLAIM_VALID_PRECISION_VALUES +from rememberstack.workers.e2 import _CLAIMIFY_PROMPT from rememberstack.workers.e2 import _parse_claim_valid_time from rememberstack.workers.e2 import _parse_iso_timestamp +def test_rendered_claimify_prompt_requires_anchored_temporal_resolution() -> None: + """The extraction request makes #158's structured-only rule unmistakable.""" + rendered = _CLAIMIFY_PROMPT.format( + keeps="- Melanie painted a lake sunrise last year.", + bundle=( + "DOCUMENT HEADER: title chat; source upload; date 2023-05-08;" + " language en\n" + "TARGET CHUNK:\nMelanie painted a lake sunrise last year." + ), + ) + + assert "TEMPORAL RESOLUTION IS REQUIRED" in rendered + assert "you MUST resolve the expression" in rendered + assert "computed absolute time ONLY in those structured" in rendered + assert "valid-time fields" in rendered + assert 'claim_text="painted a lake sunrise last year"' in rendered + assert "valid_from_iso=2022-01-01" in rendered + assert "valid_until_iso=2022-12-31" in rendered + assert "valid_precision=year" in rendered + assert "valid_from_iso=2023-05-07" in rendered + assert "valid_from_iso=2023-05-06" in rendered + assert "If the document has no absolute anchor" in rendered + + def test_claim_valid_enums_match_catalog_contract() -> None: """Python D41 temporal enums must equal the catalog_contract vocabularies.""" assert tuple(kind.value for kind in ClaimValidKind) == CLAIM_VALID_KIND_VALUES diff --git a/website/src/app/docs/reference/api/page.mdx b/website/src/app/docs/reference/api/page.mdx index 4ca3a32a..129ab648 100644 --- a/website/src/app/docs/reference/api/page.mdx +++ b/website/src/app/docs/reference/api/page.mdx @@ -155,6 +155,13 @@ plans. Each returns the envelope. | `GET /hydrate/relation/{relation_id}` | the audit chain: a relation → its evidence claims → source documents | | `GET /transcript/relation/{relation_id}` | the decision history: why the system believes what it believes | +Evidence rows returned by claim search and relation hydration include +`claim_valid_from` and `claim_valid_until`. These are nullable ISO-8601 +timestamps bounding the source-asserted world interval (valid-time) during which +the fact holds or happened, including bounds resolved from relative phrases +against an in-document timestamp; they are evidence, not the fact layer's +current-validity verdict. + `resolve` always returns every exact-name candidate. Supplying focal entities only reorders those candidates: a candidate related to more of the supplied entities ranks first, and each candidate discloses its `context_hits`. Context therefore