From 35cd808fbb240c9dc310891d8a27c88c80326446 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 18:37:55 +0200 Subject: [PATCH 1/4] feat(temporal): canonical half-open bounds for claim windows (D107 WP-T.0a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored D41 window is inclusive and carries a precision, and the engine compared those raw values directly: a day-precision claim was a zero-width point that an intraday claims_as_of window missed, adjacent units touched without overlapping, and D106's disjointness test inherited the same point semantics. Add one canonicalisation with two twins that must stay equal: the pure `core/temporal.py` (`canonical_bounds`, `inclusive_request`, `point_request`) and IMMUTABLE SQL functions `claim_canonical_start` / `claim_canonical_end` (migration p9_26_0047, with an expression index so the as-of scan stays indexed). Both ends align to the precision unit in UTC; an instant is a non-empty point; an open window has no end; unknown is no interval; the caller's inclusive `to` becomes an exclusive `to + 1 µs`, so `(t, t)` is a point query. `claims_as_of` filters on the canonical functions; the D106 block lateral, claim-timing reads and `_windows_disjoint` use the same half-open bounds, and the prompt renders the last instant inside a window. Claim storage and its CHECKs are unchanged. Roll the observation adjudicator and obs-flush generations and the LoCoMo protocol (Full-v21 -> Full-v22). Tests: the pure table, the SQL twin row by row, an intraday `claims_as_of` window and a point query, and a day-vs-instant adjudication proof. WP-T.0 is split into T.0a (this) and T.0b (query-space exposure) in the plan. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016p9xB9T7VYREuVmohDPKjR --- benchmarks/locomo/README.md | 11 +- benchmarks/locomo/__init__.py | 2 +- benchmarks/locomo/cli.py | 2 +- benchmarks/locomo/dataset.py | 4 +- benchmarks/locomo/model.py | 10 +- benchmarks/locomo/protocol.py | 14 +- benchmarks/locomo/runner.py | 10 +- benchmarks/locomo/sharding/README.md | 2 +- benchmarks/locomo/sharding/run_shard.sh | 4 +- plan/designs/locomo_benchmark_design.md | 22 ++- plan/designs/temporal_clocks_design.md | 11 +- plan/plans/temporal_clocks.md | 5 +- src/rememberstack/core/temporal.py | 145 ++++++++++++++++++ .../versions/p9_26_0047_canonical_bounds.py | 84 ++++++++++ .../spine/observation_adjudication.py | 87 +++++++---- src/rememberstack/surfaces/query_engine.py | 16 +- src/rememberstack/workers/e3.py | 4 +- src/tests/benchmarks/test_locomo_protocol.py | 14 +- src/tests/benchmarks/test_locomo_runner.py | 10 +- .../benchmarks/test_locomo_store_backup.py | 6 +- src/tests/core/test_temporal.py | 135 ++++++++++++++++ src/tests/spine/test_canonical_bounds_sql.py | 86 +++++++++++ .../spine/test_observation_adjudication.py | 58 +++++++ src/tests/surfaces/test_retrieval_batch_b.py | 72 +++++++++ website/src/app/docs/project-status/page.mdx | 6 +- 25 files changed, 731 insertions(+), 89 deletions(-) create mode 100644 src/rememberstack/core/temporal.py create mode 100644 src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py create mode 100644 src/tests/core/test_temporal.py create mode 100644 src/tests/spine/test_canonical_bounds_sql.py diff --git a/benchmarks/locomo/README.md b/benchmarks/locomo/README.md index c9e997b0b..be95659c9 100644 --- a/benchmarks/locomo/README.md +++ b/benchmarks/locomo/README.md @@ -1,4 +1,4 @@ -# RS-LoCoMo-Full-v21 setup +# RS-LoCoMo-Full-v22 setup This directory contains the unshipped full-system LoCoMo adapter. It does not vendor or auto-download LoCoMo. Supply the exact pinned `locomo10.json` only after confirming its @@ -16,12 +16,12 @@ The safe first command is local and makes no API or model call: uv run --extra benchmark python -m benchmarks.locomo prepare \ --dataset /absolute/path/locomo10.json \ --tier smoke \ - --protocol full-v21 \ + --protocol full-v22 \ --output .benchmark-runs/locomo-smoke ``` The harness validates the pinned bytes, renders session documents, and fingerprints the -eight-question smoke plan. `--protocol` is prepare-only; `full-v21` is the one +eight-question smoke plan. `--protocol` is prepare-only; `full-v22` is the one current-system protocol, and every later stage reads that immutable choice from `run.json`. Do not run remote stages until reviewing [`locomo_benchmark_design.md`](../../plan/designs/locomo_benchmark_design.md). @@ -64,6 +64,11 @@ call sees the complete bounded candidate snapshot and returns a supplied candidate id or `new`. There is no insufficient-evidence result or confidence-routed frontier call. +V22 fingerprints D107 WP-T.0a: the observation adjudicator and `claims_as_of` +compare canonical half-open bounds (a day is the whole calendar day, an +instant a non-empty point, adjacent units do not overlap); everything else is +v21's. + V21 fingerprints the D106 observation adjudicator: dated events with disjoint resolved windows never collapse onto or supersede each other (they may only contradict or stay distinct), a dated event is never `evidence` for an undated diff --git a/benchmarks/locomo/__init__.py b/benchmarks/locomo/__init__.py index d7dc5aa03..23ea50f99 100644 --- a/benchmarks/locomo/__init__.py +++ b/benchmarks/locomo/__init__.py @@ -1 +1 @@ -"""The pinned RS-LoCoMo-Full-v21 benchmark adapter.""" +"""The pinned RS-LoCoMo-Full-v22 benchmark adapter.""" diff --git a/benchmarks/locomo/cli.py b/benchmarks/locomo/cli.py index ece293744..e71eaf8b1 100644 --- a/benchmarks/locomo/cli.py +++ b/benchmarks/locomo/cli.py @@ -118,7 +118,7 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m benchmarks.locomo", description=( - "RS-LoCoMo-Full-v21: prepare is local; ingest/answer/judge require " + "RS-LoCoMo-Full-v22: prepare is local; ingest/answer/judge require " "explicit execution acknowledgements" ), ) diff --git a/benchmarks/locomo/dataset.py b/benchmarks/locomo/dataset.py index cbca706ed..ef36ea6e0 100644 --- a/benchmarks/locomo/dataset.py +++ b/benchmarks/locomo/dataset.py @@ -140,10 +140,10 @@ def load_manifest(tier: str) -> QuestionManifest: ) if manifest.dataset_commit != DATASET_COMMIT: raise DatasetValidationError( - "manifest dataset commit is not RS-LoCoMo-Full-v21" + "manifest dataset commit is not RS-LoCoMo-Full-v22" ) if manifest.dataset_sha256 != DATASET_SHA256: - raise DatasetValidationError("manifest dataset hash is not RS-LoCoMo-Full-v21") + raise DatasetValidationError("manifest dataset hash is not RS-LoCoMo-Full-v22") actual = item_ids_hash(item_ids=manifest.item_ids) if actual != manifest.item_ids_sha256: raise DatasetValidationError( diff --git a/benchmarks/locomo/model.py b/benchmarks/locomo/model.py index 890cc1cf6..6ed60a241 100644 --- a/benchmarks/locomo/model.py +++ b/benchmarks/locomo/model.py @@ -1,4 +1,4 @@ -"""Typed values for the full-system RS-LoCoMo-Full-v21 protocol.""" +"""Typed values for the full-system RS-LoCoMo-Full-v22 protocol.""" from __future__ import annotations @@ -28,8 +28,8 @@ Category = Literal[1, 2, 3, 4, 5] RetainedCategory = Literal[1, 2, 3, 4] Tier = Literal["smoke", "development", "publication"] -ProtocolKey = Literal["full-v21"] -ProtocolName = Literal["RS-LoCoMo-Full-v21"] +ProtocolKey = Literal["full-v22"] +ProtocolName = Literal["RS-LoCoMo-Full-v22"] SourceTimezoneBasis = Literal["assumed_utc"] AnswerAgentModel = Literal["openai/gpt-5.6-luna"] JudgeModel = Literal["openai/gpt-5.6-luna"] @@ -120,7 +120,7 @@ class QuestionManifest(FrozenModel): class RunConfiguration(FrozenModel): """Immutable identity of one prepared benchmark run.""" - protocol_name: ProtocolName = "RS-LoCoMo-Full-v21" + protocol_name: ProtocolName = "RS-LoCoMo-Full-v22" adapter_version: NonEmpty prepared_at: datetime repository_revision: NonEmpty @@ -393,7 +393,7 @@ class SessionDiagnosticSummary(FrozenModel): class RunSummary(FrozenModel): """Publication-ready local aggregate with no hidden denominator.""" - protocol_name: ProtocolName = "RS-LoCoMo-Full-v21" + protocol_name: ProtocolName = "RS-LoCoMo-Full-v22" protocol_fingerprint: NonEmpty tier: Tier questions: int = Field(ge=1) diff --git a/benchmarks/locomo/protocol.py b/benchmarks/locomo/protocol.py index 33d683e10..1ee300cb1 100644 --- a/benchmarks/locomo/protocol.py +++ b/benchmarks/locomo/protocol.py @@ -29,9 +29,9 @@ from rememberstack.model import Envelope from rememberstack.model import ToolDescriptor -PROTOCOL_NAME: Final = "RS-LoCoMo-Full-v21" -DEFAULT_PROTOCOL_KEY: Final = "full-v21" -ADAPTER_VERSION: Final = "locomo-full-adapter-2026.09-temporal-gate-v21" +PROTOCOL_NAME: Final = "RS-LoCoMo-Full-v22" +DEFAULT_PROTOCOL_KEY: Final = "full-v22" +ADAPTER_VERSION: Final = "locomo-full-adapter-2026.09-canonical-bounds-v22" MAX_TOOL_CALLS: Final = 8 MAX_AGENT_CALLS: Final = 9 ANSWER_READER_RETRY_BUDGET: Final = 2 @@ -74,7 +74,7 @@ "no-types-1:binary-t4-1:document-t0-1" ), "adjudicate_observations": ( - "e3-obs-flush-2026.09a:temporal-gate-1:claim-fanout-1:entity-fanout-1" + "e3-obs-flush-2026.09b:canonical-bounds-1:temporal-gate-1:claim-fanout-1:entity-fanout-1" ), "adjudicate_supersession": "adjudicator-2026.07b:temp0-1", "embed_claim": "p1-embed-claims-2026.07", @@ -211,8 +211,8 @@ class LoCoMoProtocol: answer_word_cap: int | None = None -_FULL_V21 = LoCoMoProtocol( - key="full-v21", +_FULL_V22 = LoCoMoProtocol( + key="full-v22", name=PROTOCOL_NAME, answer_agent_model=ANSWER_AGENT_MODEL, judge_model=JUDGE_MODEL, @@ -234,7 +234,7 @@ class LoCoMoProtocol: ) PROTOCOL_REGISTRY: Final[Mapping[ProtocolKey, LoCoMoProtocol]] = MappingProxyType( - {_FULL_V21.key: _FULL_V21} + {_FULL_V22.key: _FULL_V22} ) diff --git a/benchmarks/locomo/runner.py b/benchmarks/locomo/runner.py index 60e037e33..8854c44cb 100644 --- a/benchmarks/locomo/runner.py +++ b/benchmarks/locomo/runner.py @@ -411,7 +411,7 @@ def ingest_sample( _require_current_ingest_bindings(model_bindings=build.model_bindings) if build.document_binding_generation != EXPECTED_DOCUMENT_BINDING_GENERATION: raise ExecutionGuardError( - "deployment document binding generation differs from RS-LoCoMo-Full-v21" + "deployment document binding generation differs from RS-LoCoMo-Full-v22" ) _require_current_query_surface(context=context, client=client) _require_exact_live_ingests( @@ -570,7 +570,7 @@ def answer_sample( ): raise ExecutionGuardError( "the deployment did not report the exact completed" - " RS-LoCoMo-Full-v21 pipeline, live graph, and fresh P3 projection" + " RS-LoCoMo-Full-v22 pipeline, live graph, and fresh P3 projection" ) _require_serving_revision(context=context, readiness=readiness) prior_readiness = context.state.readiness.get(sample_id) @@ -1177,7 +1177,7 @@ def _validate_run( """Recompute immutable run identity before any local or remote stage.""" selected_protocol = protocol_for_name(configuration.protocol_name) if configuration.dataset_sha256 != DATASET_SHA256: - raise BenchmarkRunError("run dataset hash is not RS-LoCoMo-Full-v21") + raise BenchmarkRunError("run dataset hash is not RS-LoCoMo-Full-v22") if item_ids_hash(item_ids=manifest.item_ids) != manifest.item_ids_sha256: raise BenchmarkRunError("run manifest item hash changed") if manifest_bytes_hash(manifest=manifest) != configuration.manifest_sha256: @@ -1187,7 +1187,7 @@ def _validate_run( if manifest.tier != configuration.tier: raise BenchmarkRunError("run manifest tier changed") if configuration.dataset_commit != DATASET_COMMIT: - raise BenchmarkRunError("run dataset commit is not RS-LoCoMo-Full-v21") + raise BenchmarkRunError("run dataset commit is not RS-LoCoMo-Full-v22") if configuration.adapter_version != ADAPTER_VERSION: raise BenchmarkRunError("run adapter version differs from current code") if _models_hash(values=documents) != configuration.documents_sha256: @@ -1445,7 +1445,7 @@ def _require_current_ingest_bindings(*, model_bindings: dict[str, str]) -> None: if model_bindings.get(name) != expected.get(name) ) raise ExecutionGuardError( - "deployment ingest model bindings differ from RS-LoCoMo-Full-v21: " + "deployment ingest model bindings differ from RS-LoCoMo-Full-v22: " + ", ".join(mismatches) ) diff --git a/benchmarks/locomo/sharding/README.md b/benchmarks/locomo/sharding/README.md index 1fe312992..9a570762d 100644 --- a/benchmarks/locomo/sharding/README.md +++ b/benchmarks/locomo/sharding/README.md @@ -178,7 +178,7 @@ as source-derived data. | --- | ---: | --- | | `LOCOMO_PYTHON` | `.venv/bin/python` | repository virtual-environment Python | | `LOCOMO_TIER` | `publication` | prepared manifest tier | -| `LOCOMO_PROTOCOL` | `full-v21` | prepare-time protocol key | +| `LOCOMO_PROTOCOL` | `full-v22` | prepare-time protocol key | | `LOCOMO_MOUNT_ROOT` | `$RUN_DIR/.mounts` | host/container-identical P3 mount root | | `LOCOMO_MAX_DOCUMENTS` | `100` | per-sample ingest authorization | | `LOCOMO_MAX_QUESTIONS` | `1540` | run-absolute answer item authorization | diff --git a/benchmarks/locomo/sharding/run_shard.sh b/benchmarks/locomo/sharding/run_shard.sh index 934a83ee2..a0c17eb24 100755 --- a/benchmarks/locomo/sharding/run_shard.sh +++ b/benchmarks/locomo/sharding/run_shard.sh @@ -25,7 +25,7 @@ run_dir=$2 dataset_path=$3 python_bin=${LOCOMO_PYTHON:-.venv/bin/python} tier=${LOCOMO_TIER:-publication} -protocol=${LOCOMO_PROTOCOL:-full-v21} +protocol=${LOCOMO_PROTOCOL:-full-v22} mount_root=${LOCOMO_MOUNT_ROOT:-$run_dir/.mounts} max_documents=${LOCOMO_MAX_DOCUMENTS:-100} max_questions=${LOCOMO_MAX_QUESTIONS:-1540} @@ -50,7 +50,7 @@ export GOOGLE_APPLICATION_CREDENTIALS=${LOCOMO_GCP_CREDENTIALS_FILE:-/etc/rememb export GOOGLE_API_CERTIFICATE_CONFIG=${LOCOMO_GCP_CERTIFICATE_CONFIG_FILE:-/etc/rememberstack/locomo-gcs/certificate-config.json} export GOOGLE_API_USE_CLIENT_CERTIFICATE=true -# RS-LoCoMo-Full-v21's non-secret ingest identity. Override ambient self-host +# RS-LoCoMo-Full-v22's non-secret ingest identity. Override ambient self-host # defaults so every shard runs the exact Luna/Qwen pipeline the protocol checks. export REMEMBERSTACK_STRUCTURER_MODEL=openai/gpt-5.6-luna export REMEMBERSTACK_SKELETON_CHECK_MODEL=openai/gpt-5.6-luna diff --git a/plan/designs/locomo_benchmark_design.md b/plan/designs/locomo_benchmark_design.md index 7c0f73a85..d24dc97f0 100644 --- a/plan/designs/locomo_benchmark_design.md +++ b/plan/designs/locomo_benchmark_design.md @@ -7,8 +7,18 @@ > assured surface rolls this protocol; the sequencing is > `plan/plans/temporal_clocks.md`. Contract: `temporal_clocks_design.md` §6–§8. -> **Binding D106 amendment (2026-09-03).** The current protocol is -> `RS-LoCoMo-Full-v21`. It retains v20's dataset, rendered documents, models, +> **Binding D107 amendment, WP-T.0a (2026-09-03).** The current protocol is +> `RS-LoCoMo-Full-v22`. It retains v21's dataset, rendered documents, models, +> tools, budgets, prompts, and scoring. Its pinned `adjudicate_observations` +> component version now carries canonical half-open bounds (D107 §5): the +> observation adjudicator compares a day as the whole calendar day and an +> instant as a non-empty point, and `claims_as_of` finds a day-precision +> claim from an intraday window. Ingestion provenance, protocol identity, and +> fingerprint roll; no retrieval, retry, model-effort, or call-budget behavior +> changes. + +> **Historical D106 amendment (2026-09-03; superseded by D107 WP-T.0a).** The +> D106 protocol was `RS-LoCoMo-Full-v21`. It retained v20's dataset, rendered documents, models, > tools, budgets, answer and judge prompts, counterfactual and complete-answer > instructions, content-before-`Unknown` harness guard, and no-review scoring > rule. Its pinned `adjudicate_observations` component version now carries the @@ -98,7 +108,7 @@ and spend ceiling. ## 2. Fixed protocol ```text -protocol RS-LoCoMo-Full-v21 +protocol RS-LoCoMo-Full-v22 dataset commit 3eb6f2c585f5e1699204e3c3bdf7adc5c28cb376 dataset SHA-256 79fa87e90f04081343b8c8debecb80a9a6842b76a7aa537dc9fdf651ea698ff4 categories 1, 2, 3, 4 @@ -670,7 +680,7 @@ compatibility form. The response contains: same-snapshot proven-absent-anchor execution checks when live graph is required; - an overall `ready` that is the conjunction of the requested capabilities; - every non-secret ingestion/query model binding; and -- the non-secret `document_binding_generation`, which Full-v21 requires to be +- the non-secret `document_binding_generation`, which Full-v22 requires to be exactly `document-t0-v1` and stores in `run.json` plus the protocol fingerprint. @@ -816,11 +826,11 @@ Local preparation: uv run --extra benchmark python -m benchmarks.locomo prepare \ --dataset /absolute/path/locomo10.json \ --tier smoke \ - --protocol full-v21 \ + --protocol full-v22 \ --output .benchmark-runs/locomo-smoke ``` -`--protocol` exists only on `prepare`. The sole choice is `full-v21`; ingest, +`--protocol` exists only on `prepare`. The sole choice is `full-v22`; ingest, answer, judge, and summarize read it from the prepared run and expose no protocol override. diff --git a/plan/designs/temporal_clocks_design.md b/plan/designs/temporal_clocks_design.md index 1cdd335af..b39f6ad17 100644 --- a/plan/designs/temporal_clocks_design.md +++ b/plan/designs/temporal_clocks_design.md @@ -348,12 +348,15 @@ ends as +∞. A caller's inclusive request `claims_as_of(from, to)` converts to `[from, to + 1 µs)`, so `from == to` is a point-in-time query, not an empty one. -**Where it runs.** The function ships as one immutable SQL function in the -`memory_v1` query space (`memory_v1.canonical_bounds`) with a companion +**Where it runs.** The engine's own SQL calls two IMMUTABLE public-schema +functions, `claim_canonical_start(from, precision)` and +`claim_canonical_end(from, until, precision)`, backed by an expression index +so the as-of scan stays indexed; the query space exposes the same +canonicalisation as `memory_v1.canonical_bounds` with a companion `claims_canonical` view exposing `canon_start`/`canon_end` beside the raw columns, so saved examples, open SQL, the catalog metadata and the -open-query prose use the same canonicalisation as the engine; the Python -side calls the same definition. **Fact windows are stored canonical** — +open-query prose use it too; the Python side (`core/temporal.py`) mirrors +the same table and a test pins the twins equal. **Fact windows are stored canonical** — `valid_from`/`valid_until` and `occurs_from`/`occurs_until` are written from `canonical_bounds` at seeding, so every fact predicate the engine already has (`valid_until > :as_of`, `tstzrange(valid_from, valid_until)` in the diff --git a/plan/plans/temporal_clocks.md b/plan/plans/temporal_clocks.md index 35d3b574c..9ab7af5c7 100644 --- a/plan/plans/temporal_clocks.md +++ b/plan/plans/temporal_clocks.md @@ -17,14 +17,15 @@ tests that assert the *clock*, not only the outcome. | WP | Scope | Audit findings closed | Generations / surfaces rolled | Acceptance | | --- | --- | --- | --- | --- | -| **WP-T.0 — canonical bounds** | `canonical_bounds` in Python and as `memory_v1.canonical_bounds` + the `claims_canonical` view (design §5): both ends truncated to the unit; instants as non-empty points; inclusive request conversion; used by `claims_as_of`, D106's disjointness test, the shipped `claims_as_of` example (with its `unknown` count fixed), open-query prose and catalog metadata | 4.13, 4.21 | query-space manifest; LoCoMo protocol (result semantics change) | an intraday `claims_as_of` window finds a day-precision claim; `claims_as_of(t, t)` is a point query; a year stored 2022-01-01…12-31 canonicalises to `[2022-01-01, 2023-01-01)`; a day whose stored start is noon canonicalises to the calendar day; an `instant` overlaps itself; adjacent day windows do not overlap; the example's `unknown` count is non-zero on a corpus with unstamped claims | +| **WP-T.0a — canonical bounds, engine-internal** | `canonical_bounds` in Python (`core/temporal.py`) and as the public SQL twins `claim_canonical_start` / `claim_canonical_end` (migration `p9_26_0047`, IMMUTABLE, with an expression index): both ends truncated to the unit in UTC; instants as non-empty points; inclusive request conversion; used by the `claims_as_of` candidate scan and by D106's block lateral and `_windows_disjoint` (half-open) | 4.13 | observation adjudicator + obs flush generations; LoCoMo protocol (result semantics change) | an intraday `claims_as_of` window finds a day-precision claim; `claims_as_of(t, t)` is a point query; a year stored 2022-01-01…12-31 canonicalises to `[2022-01-01, 2023-01-01)`; a day whose stored start is noon canonicalises to the calendar day; an `instant` overlaps itself; adjacent day windows do not overlap; a day and an instant inside it overlap in the adjudicator; the SQL twins equal the Python function row by row | +| **WP-T.0b — canonical bounds in the query space** | `memory_v1.canonical_bounds` wrapping the public functions, the `claims_canonical` view (`canon_start`/`canon_end` beside the raw columns) with catalog declaration, fixtures and deletion-matrix cells; the shipped `claims_as_of` example rewritten over the view with its `unknown` count fixed; open-query prose and catalog metadata | 4.21 | query-space manifest and surface manifest; LoCoMo protocol | the example's `unknown` count is non-zero on a corpus with unstamped claims; an open-SQL overlap over `claims_canonical` matches the engine's `claims_as_of` result set | | **WP-T.1 — fact model, matching, closing, cutover** | **gated on the four §12 spikes — #365 D88 relation staging, #366 locked review application, #367 artifact staleness, #368 D74 inventory — each landing as a design amendment first (program: #364);** migration: `temporal_kind`, `valid_from_basis`, `valid_until_basis`, `occurs_from`/`occurs_until`/`occurs_precision`, `seed_claim_id`, populating the existing `relation_adjudications.triggering_claim_id` on `add`, the `migrate`/`migration` adjudication enum values, the non-empty state check, `EXCLUDE` partial on states (an unknown-bounds state slice is one unbounded range under it), `fact_expiry_schedule`, the `temporal_window` review kind and `temporal_window_verdicts`; seed once by kind with the seed recorded atomically on both planes; nomination as today with verdicts bounded by temporal relation, two candidate sets (state-ending includes finite-ended states), and the staged, idempotent relation write (§4.2); `temporal_window` verdicts with invariants, the documented reversal exception, and replay (§4.3); succession by any successor-supplied world-time instant incl. ending occurrences, the chronological guard on every cap source incl. D55, else contradict/coexist + review (§4.4); D55 by temporal kind with belief-time closes from the persisted reconciliation instant; D90 re-split by occurrence start (§4.5); the full containment current predicate on every current read incl. aggregate/absence, with activation and expiry scheduling (§7.1); occurrence windows over all attached evidence (D55 history retained); in-place conversion with migration adjudications, the `legacy` basis for unrecoverable seeds, legacy-cap recomputation, `legacy_unknown_boundary` review items, and the readiness gate (§9) | 4.2, 4.4, 4.10, 4.11, 4.17, 4.20, 4.22 | normaliser, both adjudicators, obs flush component; schema head; fact-layer generation in readiness; LoCoMo protocol | `lookup_relations(valid_at=2010)` returns the 2010 employer and excludes the 2024 one; a fact starting in 2030 is not current in 2026 and its profile activates when it starts; a January and an October visit are two occurrence relations, two same-day visits judged `new` coexist, a disjoint pair the ladder judges one disputed occurrence contradicts, and a union expansion bridging two occurrences merges nothing; a same-triple occurrence claim is held unattached until its verdict and a retried claim replays it; the same ingest replayed on different days yields byte-identical windows; both adjudicators orient one undated/dated pair identically (coexist); a dated resignation caps an undated "is CEO" state and a 2027 resignation shortens "CEO 2025–2030"; a purported cap at or before a known start routes to contradict/coexist plus a review item; a `state` with `valid_until = valid_from` is rejected by the schema; a `temporal_window` verdict applies, is reversed by a compensating verdict, and replays idempotently; the staggered D90 case with said-on and is-about reversed yields world-ordered slices; a withdrawn state with unknown or guard-refused source time leaves `valid_until` `NULL`/`unknown` and always sets `invalidated_at` to the recorded reconciliation instant (no zombie fact), and a withdrawn occurrence keeps its `occurs_*`; an expired relation no longer counts in `aggregate` nor blocks `predicate_absence`; a state with a future end is current today and its profile refreshes when the end passes, including after a restart; conversion keeps every fact id and every D55 historical fact, converts a legacy relation under basis `legacy` with a migration adjudication, recomputes a recoverable legacy cap at its successor's world-time start, removes a cap from a converted occurrence, turns a D55 fallback cap into a belief-time close, raises `legacy_unknown_boundary` for the rest, and readiness refuses an unconverted store and reports the open item count | | **WP-T.2 — two clocks in every temporal prompt** | relation supersession prompt and laterals show `said on` / `is about`; T4 candidate salient facts carry occurrence windows and kinds and rank by evidence then occurrence recency; K writer claims carry `asserted_at` + D41 fields; answer-agent prompt names the envelope fields | 4.3, 4.6, 4.8, 4.16 | relation adjudicator, resolver, K writer; LoCoMo protocol | a 2024 retrospective spell does not supersede a 2023 current fact in the supersession proofs; T4 sees windows and kinds in its candidate JSON; the answer prompt fixture contains the two-clock paragraph | | **WP-T.3 — retrieval keys, envelopes, skill** | dedupe on the full D41 tuple (or `asserted_at` when unknown) with `grouped_members`; `Validity`, `GraphEdge`, the K fact model and `memory_v1` fact views gain bases, kind and occurrence; `resolve_entity@2`, `testimony_context@2`, `fact_context@3`, `answer_context@3`; open-query confirmation returns the full D41 tuple and fact bases/occurrence; P1 is-about claim filters and the `occurs` fact mode; timeline by occurrence with an `undated` bucket; consumption skill teaches the three clocks and two fact kinds and defines `claims_as_of` over world-time | 4.1, 4.9, 4.12, 4.18, 4.19 | assured operation versions, surface manifest hash, query-space manifest, generated OpenAPI/SDK; LoCoMo protocol | identical text on two dates is two evidence rows in `testimony_context`, and a grouped row lists every member's times; a 2015–2020 archive imported today produces 2015–2020 timeline buckets; the regenerated skill text contains the corrected `claims_as_of` definition; an open-query claim row carries precision and kind | | **WP-T.4 — extraction vocabulary and anchor** | all four D41 kinds and `open` taught with examples and field descriptions; full-timestamp header | 4.14, 4.15 | extractor; LoCoMo protocol | "has been CEO since 2019" extracts as `proposition_validity` / `open`; "three hours ago" in a source stamped 19:30 resolves to an `instant` at 16:30 of that day, and the same words in a second same-day source stamped 22:00 resolve to a different instant | | **WP-T.5 — dated labels and consumer surfaces** | labels derived from statement + occurrence window (obs label, `FactResult.label`, profile lines); K fact sheet columns by basis with `about` and `legacy` handling; observation history by `occurs_from` | 4.5, 4.7 | P1 labels and selection, K page generation; LoCoMo protocol (answer inputs change) | an observation minted from "last week" carries the resolved date in its label but not its statement; a sheet never prints a said-on date under a world-time heading | -Dependencies: T.0 first. T.1 depends on T.0. T.2, T.3 and T.5 depend on +Dependencies: T.0a first; T.0b may follow at any time. T.1 depends on T.0a. T.2, T.3 and T.5 depend on T.1's columns and cutover. T.4 is independent and may run in parallel. Packages released together roll the protocol once. Every package updates the same-PR documentation the CLAUDE.md rule requires (concepts and API pages for diff --git a/src/rememberstack/core/temporal.py b/src/rememberstack/core/temporal.py new file mode 100644 index 000000000..587a41d1b --- /dev/null +++ b/src/rememberstack/core/temporal.py @@ -0,0 +1,145 @@ +"""Canonical bounds for D41 claim windows (D107 §5, WP-T.0). + +A claim's stored world-time window is *inclusive* and carries a precision: +``day`` / ``month`` / ``quarter`` / ``year`` store the resolved unit's first +and last calendar day, ``instant`` stores one timestamp in both ends, ``open`` +stores a start and no end, ``unknown`` stores nothing. Comparing those raw +values directly treats a day as a zero-width point (an intraday as-of window +misses it) and lets adjacent units touch without overlapping. + +``canonical_bounds`` turns a stored window into one **half-open** interval +``[start, end)`` whose ends are aligned to the precision unit in UTC, so one +overlap predicate — ``a.start < b.end AND b.start < a.end`` with a ``None`` +end as +∞ — is correct for every precision. Storage is unchanged; only +comparisons canonicalise. The SQL twins ``claim_canonical_start`` / +``claim_canonical_end`` (migration ``p9_26_0047``) implement the same table +and MUST stay equivalent; ``test_temporal.py`` pins both. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Final + +_MICROSECOND: Final = timedelta(microseconds=1) + +_BOUNDED_PRECISIONS: Final = frozenset({"day", "month", "quarter", "year"}) + + +@dataclass(frozen=True) +class CanonicalBounds: + """One half-open world-time interval, or no interval at all. + + ``start`` is inclusive; ``end`` is exclusive and ``None`` means unbounded. + Both are ``None`` when the claim carries no usable window (``unknown``). + """ + + start: datetime | None + end: datetime | None + + @property + def is_known(self) -> bool: + """True when the claim carries a window at all.""" + return self.start is not None + + def overlaps(self, other: CanonicalBounds) -> bool: + """Half-open overlap; an unknown side never overlaps anything.""" + if self.start is None or other.start is None: + return False + if self.end is not None and self.end <= other.start: + return False + if other.end is not None and other.end <= self.start: + return False + return True + + +def canonical_bounds( + *, valid_from: datetime | None, valid_until: datetime | None, precision: str +) -> CanonicalBounds: + """Canonicalise one stored D41 window by its precision. + + - ``day`` / ``month`` / ``quarter`` / ``year``: ``[trunc(unit, from), + trunc(unit, until) + unit)`` — a year stored 2022-01-01…2022-12-31 + becomes ``[2022-01-01, 2023-01-01)``; a day whose stored start is noon + still becomes the whole calendar day. + - ``instant``: ``[t, t + 1 µs)`` — a non-empty point. + - ``open``: ``[from, None)``. + - ``unknown`` (or a missing start): no interval. + + Timestamps are interpreted in UTC (D41 bounds are timezone-aware); naive + inputs are treated as UTC rather than rejected, mirroring the SQL twin. + """ + if precision == "unknown" or valid_from is None: + return CanonicalBounds(start=None, end=None) + start = _utc(valid_from) + if precision == "instant": + return CanonicalBounds(start=start, end=start + _MICROSECOND) + if precision == "open": + return CanonicalBounds(start=start, end=None) + if precision in _BOUNDED_PRECISIONS: + end_source = _utc(valid_until) if valid_until is not None else start + return CanonicalBounds( + start=_truncate(start, precision), + end=_advance(_truncate(end_source, precision), precision), + ) + raise ValueError(f"unknown claim_valid_precision {precision!r}") + + +def point_request(*, at: datetime) -> CanonicalBounds: + """The canonical form of an inclusive point-in-time request ``(t, t)``.""" + start = _utc(at) + return CanonicalBounds(start=start, end=start + _MICROSECOND) + + +def inclusive_request(*, from_: datetime, to: datetime) -> CanonicalBounds: + """The canonical form of an inclusive caller window ``[from, to]``. + + ``to`` is inclusive for the caller, so the exclusive end is one microsecond + later; ``from == to`` is therefore a point query, never an empty one. + """ + return CanonicalBounds(start=_utc(from_), end=_utc(to) + _MICROSECOND) + + +def _utc(value: datetime) -> datetime: + """Normalise to an aware UTC datetime.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _truncate(value: datetime, precision: str) -> datetime: + """Truncate to the first instant of the precision unit, in UTC.""" + if precision == "day": + return value.replace(hour=0, minute=0, second=0, microsecond=0) + if precision == "month": + return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if precision == "quarter": + first_month = ((value.month - 1) // 3) * 3 + 1 + return value.replace( + month=first_month, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + if precision == "year": + return value.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) + raise ValueError(f"not a bounded precision: {precision!r}") + + +def _advance(value: datetime, precision: str) -> datetime: + """Add exactly one precision unit to an already-truncated instant.""" + if precision == "day": + return value + timedelta(days=1) + if precision == "month": + return _add_months(value, 1) + if precision == "quarter": + return _add_months(value, 3) + if precision == "year": + return value.replace(year=value.year + 1) + raise ValueError(f"not a bounded precision: {precision!r}") + + +def _add_months(value: datetime, months: int) -> datetime: + """Advance a first-of-month instant by whole months.""" + total = value.month - 1 + months + return value.replace(year=value.year + total // 12, month=total % 12 + 1) diff --git a/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py b/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py new file mode 100644 index 000000000..10ad78f92 --- /dev/null +++ b/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py @@ -0,0 +1,84 @@ +"""Canonical half-open bounds for D41 claim windows (D107 §5, WP-T.0a). + +Two IMMUTABLE functions turn a stored claim window — inclusive ends plus a +precision — into the half-open interval its precision means, so that every +comparison in the engine (the as-of candidate scan, the D106 temporal rung) +reads the same bounds: a day is the whole calendar day, a year is the whole +year, an instant is a non-empty point, an open window has no end. The Python +twin is ``rememberstack.core.temporal.canonical_bounds``; both are pinned by +tests to stay equivalent. Claim storage and its CHECK constraints are +unchanged. + +revision: p9_26_0047 +""" + +from alembic import op + +revision: str = "p9_26_0047" +down_revision: str | None = "p9_25_0046" +branch_labels = None +depends_on = None + +_FUNCTIONS_DDL = r""" +CREATE FUNCTION claim_canonical_start( + valid_from timestamptz, + valid_precision claim_valid_precision +) RETURNS timestamptz +LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT CASE + WHEN valid_from IS NULL OR valid_precision = 'unknown' THEN NULL + WHEN valid_precision = 'day' THEN date_trunc('day', valid_from, 'UTC') + WHEN valid_precision = 'month' THEN date_trunc('month', valid_from, 'UTC') + WHEN valid_precision = 'quarter' THEN date_trunc('quarter', valid_from, 'UTC') + WHEN valid_precision = 'year' THEN date_trunc('year', valid_from, 'UTC') + ELSE valid_from + END +$$; +COMMENT ON FUNCTION claim_canonical_start(timestamptz, claim_valid_precision) IS + 'D107 §5: the inclusive start of a claim window aligned to its precision unit in UTC; NULL for unknown precision.'; + +CREATE FUNCTION claim_canonical_end( + valid_from timestamptz, + valid_until timestamptz, + valid_precision claim_valid_precision +) RETURNS timestamptz +LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT CASE + WHEN valid_from IS NULL OR valid_precision = 'unknown' THEN NULL + WHEN valid_precision = 'open' THEN NULL + WHEN valid_precision = 'instant' THEN valid_from + interval '1 microsecond' + WHEN valid_precision = 'day' THEN date_trunc('day', coalesce(valid_until, valid_from), 'UTC') + interval '1 day' + WHEN valid_precision = 'month' THEN date_trunc('month', coalesce(valid_until, valid_from), 'UTC') + interval '1 month' + WHEN valid_precision = 'quarter' THEN date_trunc('quarter', coalesce(valid_until, valid_from), 'UTC') + interval '3 months' + WHEN valid_precision = 'year' THEN date_trunc('year', coalesce(valid_until, valid_from), 'UTC') + interval '1 year' + END +$$; +COMMENT ON FUNCTION claim_canonical_end(timestamptz, timestamptz, claim_valid_precision) IS + 'D107 §5: the EXCLUSIVE end of a claim window aligned to its precision unit in UTC; NULL for open or unknown.'; + +-- The as-of candidate scan filters on the canonical ends; an expression +-- index keeps it indexed exactly as the raw-column partial index did. +CREATE INDEX ix_claims_canonical_window + ON claims ( + deployment_id, + claim_canonical_start(claim_valid_from, claim_valid_precision), + claim_canonical_end(claim_valid_from, claim_valid_until, claim_valid_precision) + ) + WHERE claim_valid_precision <> 'unknown'; +""" + +_FUNCTIONS_DROP = r""" +DROP INDEX IF EXISTS ix_claims_canonical_window; +DROP FUNCTION IF EXISTS claim_canonical_end(timestamptz, timestamptz, claim_valid_precision); +DROP FUNCTION IF EXISTS claim_canonical_start(timestamptz, claim_valid_precision); +""" + + +def upgrade() -> None: + """Create the canonical-bounds functions and their expression index.""" + op.execute(_FUNCTIONS_DDL) + + +def downgrade() -> None: + """Drop the index and functions; claim rows are untouched.""" + op.execute(_FUNCTIONS_DROP) diff --git a/src/rememberstack/spine/observation_adjudication.py b/src/rememberstack/spine/observation_adjudication.py index 3d9715684..d3f6ac1a2 100644 --- a/src/rememberstack/spine/observation_adjudication.py +++ b/src/rememberstack/spine/observation_adjudication.py @@ -18,6 +18,7 @@ from collections.abc import Mapping from collections.abc import Sequence from dataclasses import dataclass +from datetime import timedelta from typing import Any from typing import Final from uuid import UUID @@ -41,7 +42,7 @@ from rememberstack.spine.rank_embed_cache import RankEmbedCache OBSERVATION_ADJUDICATOR_VERSION: Final = ( - "obs-adjudicator-2026.09a:temp0-1:temporal-gate-1" + "obs-adjudicator-2026.09b:temp0-1:temporal-gate-1:canonical-bounds-1" ) """The observation adjudicator generation (D12; replayed on rebuild, D7). 07b pins temperature=0.0 — generation parameters are part of provenance. @@ -50,7 +51,10 @@ contradict or stay distinct), a dated event never collapses as evidence onto an undated statement (nor the reverse), identical text is collapsed only when temporally compatible, open-ended windows stay unbounded, and the verdict -prompt shows both statements' said-on dates and is-about windows.""" +prompt shows both statements' said-on dates and is-about windows. +09b (D107 §5, WP-T.0a) compares canonical half-open bounds: a day covers +the whole calendar day, an instant is a non-empty point, adjacent units do +not overlap.""" _VERDICT_PROMPT: Final = """You adjudicate observations for a memory system. Both statements are believed facts about the SAME entity: @@ -1182,7 +1186,9 @@ def _remember_candidate( class _ClaimTiming: """What the D41 record says about WHEN one piece of testimony applies. - Two clocks. ``asserted_at`` is when the SOURCE said it — the document's + Windows are D107 §5 canonical half-open intervals: ``about_from`` is the + inclusive start aligned to the claim's precision unit, ``about_until`` the + EXCLUSIVE end (``None`` = open). Two clocks. ``asserted_at`` is when the SOURCE said it — the document's or conversation's own timestamp (the supersession boundary the layer already used). ``about_from``/``about_until`` is the world-time the statement is ABOUT, resolved by the extractor from the source's wording @@ -1222,8 +1228,10 @@ def event_until(self) -> object: def _timing_from_row(row: Mapping[Any, Any]) -> _ClaimTiming: """Read one claim row's said-on time and resolved about-window. - A ``NULL`` ``claim_valid_until`` beside a resolved ``claim_valid_from`` is - a D41 open interval: the end is unbounded and stays ``None``. + The row's ``claim_valid_from`` / ``claim_valid_until`` are the D107 §5 + canonical half-open bounds (the SQL selects ``claim_canonical_start`` / + ``claim_canonical_end``): a ``NULL`` end is an open interval and stays + ``None``; an ``unknown`` precision yields a ``NULL`` start and is undated. """ about_from = row.get("claim_valid_from") if about_from is None: @@ -1282,7 +1290,8 @@ def _render_about(timing: _ClaimTiming) -> str: if timing.about_until is None: span = f"from {start} onward (no end given)" else: - end = _date_text(timing.about_until) + # the stored end is exclusive; show the last instant inside the window + end = _date_text(_last_inside(timing.about_until)) span = start if start == end else f"{start} to {end}" if timing.is_event: return ( @@ -1295,6 +1304,14 @@ def _render_about(timing: _ClaimTiming) -> str: return f"the day {span} (a state or figure tied to that day, not a dated event)" +def _last_inside(end_exclusive: object) -> object: + """The last instant inside a half-open window, for display only.""" + try: + return end_exclusive - timedelta(microseconds=1) # type: ignore[operator] + except TypeError: + return end_exclusive + + def _date_text(value: object) -> str: """Render a timestamp as its calendar date; anything else verbatim.""" date = getattr(value, "date", None) @@ -1410,14 +1427,16 @@ def _latest_or_open(left: object, right: object, *, existing_defined: bool) -> o def _windows_disjoint( left_from: object, left_until: object, right_from: object, right_until: object ) -> bool: - """Closed-interval disjointness with ``None`` ends unbounded. + """Half-open disjointness (D107 §5) with ``None`` ends unbounded. - Any incomparable value counts as overlap (the fail-safe direction). + Ends are exclusive, so two adjacent calendar days do not overlap while a + day and an instant inside it do. Any incomparable value counts as overlap + (the fail-safe direction). """ try: - if left_until is not None and bool(left_until < right_from): # type: ignore[operator] + if left_until is not None and bool(left_until <= right_from): # type: ignore[operator] return True - if right_until is not None and bool(right_until < left_from): # type: ignore[operator] + if right_until is not None and bool(right_until <= left_from): # type: ignore[operator] return True return False except TypeError: @@ -1495,27 +1514,35 @@ def _is_later_in_total_order( -- event claims only) drives the rung; the wider about-window is shown -- to the model. LEFT JOIN LATERAL ( - -- A NULL claim_valid_until beside a non-NULL claim_valid_from is a - -- D41 open interval (unbounded end); one open claim makes the - -- aggregate open, so the end stays NULL rather than a false maximum. - SELECT min(c.claim_valid_from) - FILTER (WHERE c.claim_valid_kind = 'event_time') AS event_from, - CASE WHEN bool_or(c.claim_valid_until IS NULL) - FILTER (WHERE c.claim_valid_kind = 'event_time') + -- Windows are compared as D107 §5 canonical half-open intervals: a + -- day covers the whole calendar day, an instant is a non-empty + -- point, an open window (NULL canonical end) makes the aggregate + -- open, so the end stays NULL rather than a false maximum. + SELECT min(w.canon_start) + FILTER (WHERE w.valid_kind = 'event_time') AS event_from, + CASE WHEN bool_or(w.canon_end IS NULL) + FILTER (WHERE w.valid_kind = 'event_time') THEN NULL - ELSE max(c.claim_valid_until) - FILTER (WHERE c.claim_valid_kind = 'event_time') + ELSE max(w.canon_end) + FILTER (WHERE w.valid_kind = 'event_time') END AS event_until, - min(c.claim_valid_from) AS about_from, - CASE WHEN bool_or(c.claim_valid_until IS NULL) THEN NULL - ELSE max(c.claim_valid_until) + min(w.canon_start) AS about_from, + CASE WHEN bool_or(w.canon_end IS NULL) THEN NULL + ELSE max(w.canon_end) END AS about_until FROM observation_evidence e - JOIN claims c ON c.claim_id = e.claim_id + JOIN LATERAL ( + SELECT c.claim_valid_kind AS valid_kind, + claim_canonical_start(c.claim_valid_from, + c.claim_valid_precision) AS canon_start, + claim_canonical_end(c.claim_valid_from, c.claim_valid_until, + c.claim_valid_precision) AS canon_end + FROM claims c + WHERE c.claim_id = e.claim_id AND c.is_current_testimony + ) w ON true WHERE e.observation_id = o.observation_id AND e.stance = 'supports' - AND c.is_current_testimony - AND c.claim_valid_from IS NOT NULL + AND w.canon_start IS NOT NULL ) timing ON true WHERE o.deployment_id = :deployment_id AND o.subject_entity_id = :subject_entity_id @@ -1619,7 +1646,10 @@ def _is_later_in_total_order( """ SELECT e.claim_id, e.doc_id, c.asserted_at, c.claim_valid_kind::text AS valid_kind, - c.claim_valid_from, c.claim_valid_until + claim_canonical_start(c.claim_valid_from, c.claim_valid_precision) + AS claim_valid_from, + claim_canonical_end(c.claim_valid_from, c.claim_valid_until, + c.claim_valid_precision) AS claim_valid_until FROM observation_evidence e JOIN claims c ON c.claim_id = e.claim_id WHERE e.deployment_id = :deployment_id @@ -1691,7 +1721,10 @@ def _is_later_in_total_order( _CLAIMS_ASSERTED = text( """ SELECT claim_id, asserted_at, claim_valid_kind::text AS valid_kind, - claim_valid_from, claim_valid_until + claim_canonical_start(claim_valid_from, claim_valid_precision) + AS claim_valid_from, + claim_canonical_end(claim_valid_from, claim_valid_until, + claim_valid_precision) AS claim_valid_until FROM claims WHERE claim_id = ANY(:claim_ids) """ ) diff --git a/src/rememberstack/surfaces/query_engine.py b/src/rememberstack/surfaces/query_engine.py index 9af26946b..581a0c6f3 100644 --- a/src/rememberstack/surfaces/query_engine.py +++ b/src/rememberstack/surfaces/query_engine.py @@ -44,6 +44,7 @@ from rememberstack.core.ranking import reciprocal_rank_fusion from rememberstack.core.ranking import rerank_by_signal from rememberstack.core.ranking import rerank_by_weighted_signals +from rememberstack.core.temporal import inclusive_request from rememberstack.model import AggregateBucket from rememberstack.model import AggregateReport from rememberstack.model import AtTemporalScope @@ -535,6 +536,7 @@ def claims_as_of( "claims_as_of 'to' must be greater than or equal to 'from'" ) candidate_limit = BOUNDED_SEMANTIC_CANDIDATES if query is not None else k + window = inclusive_request(from_=from_, to=to) with self._engine.connect().execution_options( isolation_level="REPEATABLE READ" ) as connection: @@ -543,8 +545,8 @@ def claims_as_of( _CLAIMS_AS_OF_CANDIDATES, { "deployment_id": deployment_id, - "from": from_, - "to": to, + "from": window.start, + "to_exclusive": window.end, "candidate_limit": candidate_limit, }, ) @@ -3591,8 +3593,14 @@ def _co_member(row: dict[str, object]) -> CoMember: AND d.doc_id = c.doc_id WHERE c.deployment_id = :deployment_id AND c.claim_valid_precision <> 'unknown' - AND c.claim_valid_from <= :to - AND (c.claim_valid_until IS NULL OR c.claim_valid_until >= :from) + -- D107 §5: half-open canonical overlap with the caller's window, whose + -- inclusive `to` arrives as an exclusive `to_exclusive` (to + 1 µs). + AND claim_canonical_start(c.claim_valid_from, c.claim_valid_precision) + < :to_exclusive + AND (claim_canonical_end(c.claim_valid_from, c.claim_valid_until, + c.claim_valid_precision) IS NULL + OR claim_canonical_end(c.claim_valid_from, c.claim_valid_until, + c.claim_valid_precision) > :from) AND (d.doc_id IS NULL OR d.deleted_at IS NULL) ORDER BY c.claim_valid_from DESC, c.claim_id LIMIT :candidate_limit diff --git a/src/rememberstack/workers/e3.py b/src/rememberstack/workers/e3.py index acfcbde81..71699cd13 100644 --- a/src/rememberstack/workers/e3.py +++ b/src/rememberstack/workers/e3.py @@ -85,9 +85,7 @@ def _run_profile_refresh(*, action: Callable[[], object], call_key: str) -> None Temperature=0.0 is part of provenance. """ -OBS_FLUSH_VERSION: Final = ( - "e3-obs-flush-2026.09a:temporal-gate-1:claim-fanout-1:entity-fanout-1" -) +OBS_FLUSH_VERSION: Final = "e3-obs-flush-2026.09b:canonical-bounds-1:temporal-gate-1:claim-fanout-1:entity-fanout-1" """Post-barrier observation flush generation (D88 §5.6; D90 entity fan-out). 09a/temporal-gate-1: D106 — dated events with disjoint resolved windows never collapse, and a dated event never becomes evidence for an undated statement.""" diff --git a/src/tests/benchmarks/test_locomo_protocol.py b/src/tests/benchmarks/test_locomo_protocol.py index cda74dffc..96f995fa9 100644 --- a/src/tests/benchmarks/test_locomo_protocol.py +++ b/src/tests/benchmarks/test_locomo_protocol.py @@ -239,10 +239,10 @@ def test_current_protocol_pins_manifest_and_complete_read_plane() -> None: assert len(tool_catalog_sha256()) == 64 -def test_protocol_is_v21_and_answer_prompt_has_reasoning_and_loop_guards() -> None: +def test_protocol_is_v22_and_answer_prompt_has_reasoning_and_loop_guards() -> None: """The current identity, bounded inference, and loop discipline are locked.""" - assert PROTOCOL_NAME == "RS-LoCoMo-Full-v21" - assert DEFAULT_PROTOCOL_KEY == "full-v21" + assert PROTOCOL_NAME == "RS-LoCoMo-Full-v22" + assert DEFAULT_PROTOCOL_KEY == "full-v22" prompt = ANSWER_AGENT_PROMPT_TEMPLATE normalized_prompt = " ".join(prompt.split()) assert ( @@ -274,10 +274,10 @@ def test_protocol_is_v21_and_answer_prompt_has_reasoning_and_loop_guards() -> No def test_typed_protocol_registry_pins_answer_agent_identity_and_effort() -> None: - assert tuple(PROTOCOL_REGISTRY) == ("full-v21",) - protocol = PROTOCOL_REGISTRY["full-v21"] + assert tuple(PROTOCOL_REGISTRY) == ("full-v22",) + protocol = PROTOCOL_REGISTRY["full-v22"] - assert protocol.name == "RS-LoCoMo-Full-v21" + assert protocol.name == "RS-LoCoMo-Full-v22" assert protocol.answer_agent_model == "openai/gpt-5.6-luna" assert protocol.answer_agent_reasoning_effort == "none" assert protocol.judge_reasoning_effort == "none" @@ -315,7 +315,7 @@ def fake_prepare_run(**values: object) -> _Prepared: ) assert exit_code == 0 - assert selected == ["full-v21"] + assert selected == ["full-v22"] def test_summarize_cli_accepts_multiple_run_flags( diff --git a/src/tests/benchmarks/test_locomo_runner.py b/src/tests/benchmarks/test_locomo_runner.py index e4b162cc3..73b580fc1 100644 --- a/src/tests/benchmarks/test_locomo_runner.py +++ b/src/tests/benchmarks/test_locomo_runner.py @@ -1047,7 +1047,7 @@ def test_answer_persists_usage_when_provider_drifts_after_tool_call() -> None: "invalid_first_step_completions", "invalid_reader_completions", ), - (("full-v21", "openai/gpt-5.6-luna", "none", 0, 2),), + (("full-v22", "openai/gpt-5.6-luna", "none", 0, 2),), ) def test_staged_mock_run_uses_prepared_protocol_and_resumes( protocol: ProtocolKey, @@ -1680,7 +1680,7 @@ def drifted_binding(request: httpx.Request) -> httpx.Response: def test_ingest_refuses_document_binding_generation_drift_before_upload( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Full-v21 cannot silently process with document-local T0 disabled.""" + """Full-v22 cannot silently process with document-local T0 disabled.""" _patch_prepared_inputs(monkeypatch=monkeypatch) run_dir = tmp_path / "run" prepare_run(dataset_path=tmp_path / "synthetic.json", tier="smoke", output=run_dir) @@ -1906,8 +1906,8 @@ def test_single_run_summary_json_is_unchanged( serialized = summarize_run(run_dir=run_dir).model_dump_json() assert serialized == ( - '{"protocol_name":"RS-LoCoMo-Full-v21","protocol_fingerprint":' - '"0391b709fc76eb2cfc5748f62c6e3a294885bf2b329f1537715f6148ef455fef",' + '{"protocol_name":"RS-LoCoMo-Full-v22","protocol_fingerprint":' + '"a41cf907b69919b432726b66cb78edebe79e8ea825d955315a0d98f942488db5",' '"tier":"smoke","questions":1,"judge_correct":0,"judge_percent":0.0,' '"official_f1":0.0,"categories":[{"category":1,"questions":0,' '"judge_correct":0,"judge_percent":0.0,"official_f1":0.0},{"category":2,' @@ -2126,7 +2126,7 @@ def test_prepared_protocol_pins_current_surface_and_luna( dataset_path=tmp_path / "synthetic.json", tier="smoke", output=run_dir ) - assert prepared.protocol_name == "RS-LoCoMo-Full-v21" + assert prepared.protocol_name == "RS-LoCoMo-Full-v22" assert prepared.answer_agent_model == "openai/gpt-5.6-luna" assert prepared.answer_agent_reasoning_effort == "none" assert prepared.answer_reader_retry_budget == 2 diff --git a/src/tests/benchmarks/test_locomo_store_backup.py b/src/tests/benchmarks/test_locomo_store_backup.py index 06be91ac5..0aca49775 100644 --- a/src/tests/benchmarks/test_locomo_store_backup.py +++ b/src/tests/benchmarks/test_locomo_store_backup.py @@ -24,7 +24,7 @@ def _run_json(path: Path) -> None: (path / "run.json").write_text( json.dumps( { - "protocol_name": "RS-LoCoMo-Full-v21", + "protocol_name": "RS-LoCoMo-Full-v22", "protocol_fingerprint": "p" * 64, "repository_revision": "r" * 40, "prepared_at": "2026-08-11T00:00:00Z", @@ -660,7 +660,7 @@ def test_restore_validates_every_archive_before_running_docker( deployment_id="57000000-0000-0000-0000-000000000001", compose_project="rememberstack", run=store_backup.RunIdentity( - protocol_name="RS-LoCoMo-Full-v21", + protocol_name="RS-LoCoMo-Full-v22", protocol_fingerprint="p" * 64, repository_revision="r" * 40, prepared_at="2026-08-11T00:00:00Z", @@ -922,7 +922,7 @@ def test_runtime_validation_uses_the_image_revision_stamp( sample_id="conv-1", deployment_id=deployment_id, run=store_backup.RunIdentity( - protocol_name="RS-LoCoMo-Full-v21", + protocol_name="RS-LoCoMo-Full-v22", protocol_fingerprint="p" * 64, repository_revision=revision, prepared_at="2026-08-11T00:00:00Z", diff --git a/src/tests/core/test_temporal.py b/src/tests/core/test_temporal.py new file mode 100644 index 000000000..9cb499072 --- /dev/null +++ b/src/tests/core/test_temporal.py @@ -0,0 +1,135 @@ +"""D107 §5 canonical bounds: the pure function and its SQL twin agree.""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import pytest + +from rememberstack.core.temporal import canonical_bounds +from rememberstack.core.temporal import CanonicalBounds +from rememberstack.core.temporal import inclusive_request +from rememberstack.core.temporal import point_request + +_UTC = timezone.utc +_US = timedelta(microseconds=1) + + +def _ts(text: str) -> datetime: + return datetime.fromisoformat(text).replace(tzinfo=_UTC) + + +@pytest.mark.parametrize( + ("precision", "valid_from", "valid_until", "start", "end"), + [ + # a year stored as its first and last day covers the whole year + ( + "year", + "2022-01-01T00:00", + "2022-12-31T00:00", + "2022-01-01T00:00", + "2023-01-01T00:00", + ), + # a day whose stored start is noon is still the whole calendar day + ( + "day", + "2023-05-07T12:00", + "2023-05-07T12:00", + "2023-05-07T00:00", + "2023-05-08T00:00", + ), + # month and quarter units, including a year rollover + ( + "month", + "2022-12-03T00:00", + "2022-12-03T00:00", + "2022-12-01T00:00", + "2023-01-01T00:00", + ), + ( + "quarter", + "2022-11-15T00:00", + "2022-11-15T00:00", + "2022-10-01T00:00", + "2023-01-01T00:00", + ), + # a bounded span keeps its own start and end units + ( + "year", + "2015-01-01T00:00", + "2018-12-31T00:00", + "2015-01-01T00:00", + "2019-01-01T00:00", + ), + ], +) +def test_bounded_precisions_align_both_ends( + precision: str, valid_from: str, valid_until: str, start: str, end: str +) -> None: + bounds = canonical_bounds( + valid_from=_ts(valid_from), valid_until=_ts(valid_until), precision=precision + ) + assert bounds == CanonicalBounds(start=_ts(start), end=_ts(end)) + + +def test_instant_is_a_non_empty_point_that_overlaps_itself() -> None: + t = _ts("2022-08-21T16:30") + bounds = canonical_bounds(valid_from=t, valid_until=t, precision="instant") + assert bounds == CanonicalBounds(start=t, end=t + _US) + assert bounds.overlaps(bounds) + + +def test_open_is_unbounded_and_unknown_is_no_interval() -> None: + start = _ts("2019-01-01T00:00") + assert canonical_bounds(valid_from=start, valid_until=None, precision="open") == ( + CanonicalBounds(start=start, end=None) + ) + unknown = canonical_bounds(valid_from=None, valid_until=None, precision="unknown") + assert unknown == CanonicalBounds(start=None, end=None) + assert not unknown.is_known + assert not unknown.overlaps( + canonical_bounds(valid_from=start, valid_until=None, precision="open") + ) + + +def test_adjacent_days_do_not_overlap_but_an_intraday_request_finds_the_day() -> None: + day = canonical_bounds( + valid_from=_ts("2023-05-07T00:00"), + valid_until=_ts("2023-05-07T00:00"), + precision="day", + ) + next_day = canonical_bounds( + valid_from=_ts("2023-05-08T00:00"), + valid_until=_ts("2023-05-08T00:00"), + precision="day", + ) + assert not day.overlaps(next_day) + intraday = inclusive_request( + from_=_ts("2023-05-07T09:00"), to=_ts("2023-05-07T23:00") + ) + assert day.overlaps(intraday) + assert day.overlaps(point_request(at=_ts("2023-05-07T12:00"))) + # a day-precision claim and an instant inside that day overlap + instant = canonical_bounds( + valid_from=_ts("2023-05-07T15:00"), + valid_until=_ts("2023-05-07T15:00"), + precision="instant", + ) + assert day.overlaps(instant) + + +def test_a_point_request_is_never_empty() -> None: + t = _ts("2024-06-15T12:00") + request = inclusive_request(from_=t, to=t) + assert request == point_request(at=t) + assert request.end is not None and request.end > request.start # type: ignore[operator] + + +def test_naive_inputs_are_read_as_utc() -> None: + bounds = canonical_bounds( + valid_from=datetime(2022, 1, 1, 12, 0), + valid_until=datetime(2022, 1, 1, 12, 0), + precision="day", + ) + assert bounds.start == _ts("2022-01-01T00:00") + assert bounds.end == _ts("2022-01-02T00:00") diff --git a/src/tests/spine/test_canonical_bounds_sql.py b/src/tests/spine/test_canonical_bounds_sql.py new file mode 100644 index 000000000..4c30a91af --- /dev/null +++ b/src/tests/spine/test_canonical_bounds_sql.py @@ -0,0 +1,86 @@ +"""D107 §5: the SQL canonical-bounds functions agree with their Python twin.""" + +from collections.abc import Iterator +from datetime import datetime +from datetime import timezone +from pathlib import Path + +from alembic import command +from alembic.config import Config +from pydantic import ValidationError +import pytest +from sqlalchemy import create_engine +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from rememberstack.core.temporal import canonical_bounds +from rememberstack.spine.settings import load_database_settings + +_ROOT = Path(__file__).resolve().parents[3] + + +def _ts(value: str) -> datetime: + return datetime.fromisoformat(value).replace(tzinfo=timezone.utc) + + +@pytest.fixture(scope="module") +def database_engine() -> Iterator[Engine]: + """Apply the structural head so the migration's functions exist.""" + try: + database_url = load_database_settings().sqlalchemy_url() + except ValidationError: + pytest.skip("REMEMBERSTACK_DATABASE_URL is required for the SQL twin proof") + config = Config(str(_ROOT / "alembic.ini")) + config.set_main_option("sqlalchemy.url", database_url) + command.downgrade(config=config, revision="base") + command.upgrade(config=config, revision="head") + engine = create_engine(database_url) + try: + yield engine + finally: + engine.dispose() + + +@pytest.mark.parametrize( + ("precision", "valid_from", "valid_until"), + [ + ("year", "2022-01-01T00:00", "2022-12-31T00:00"), + ("year", "2015-01-01T00:00", "2018-12-31T00:00"), + ("day", "2023-05-07T12:00", "2023-05-07T12:00"), + ("month", "2022-12-03T00:00", "2022-12-03T00:00"), + ("quarter", "2022-11-15T00:00", "2022-11-15T00:00"), + ("instant", "2022-08-21T16:30", "2022-08-21T16:30"), + ("open", "2019-01-01T00:00", None), + ], +) +def test_sql_twin_matches_python( + database_engine: Engine, precision: str, valid_from: str, valid_until: str | None +) -> None: + with database_engine.connect() as connection: + row = connection.execute( + text( + "SELECT claim_canonical_start(CAST(:f AS timestamptz)," + " CAST(:p AS claim_valid_precision)) AS s," + " claim_canonical_end(CAST(:f AS timestamptz)," + " CAST(:u AS timestamptz), CAST(:p AS claim_valid_precision)) AS e" + ), + {"f": valid_from, "u": valid_until, "p": precision}, + ).one() + expected = canonical_bounds( + valid_from=_ts(valid_from), + valid_until=_ts(valid_until) if valid_until else None, + precision=precision, + ) + assert row.s == expected.start + assert row.e == expected.end + + +def test_sql_twin_unknown_precision_is_no_interval(database_engine: Engine) -> None: + with database_engine.connect() as connection: + row = connection.execute( + text( + "SELECT claim_canonical_start(NULL, 'unknown') AS s," + " claim_canonical_end(NULL, NULL, 'unknown') AS e" + ) + ).one() + assert row.s is None and row.e is None diff --git a/src/tests/spine/test_observation_adjudication.py b/src/tests/spine/test_observation_adjudication.py index a14ffd553..63be85748 100644 --- a/src/tests/spine/test_observation_adjudication.py +++ b/src/tests/spine/test_observation_adjudication.py @@ -1254,3 +1254,61 @@ def router(prompt: str, type_name: str) -> dict[str, object]: assert rows[0]["statement"] == "Alice is the CEO of Acme." assert rows[0]["valid_until"] is not None # capped by the dated event assert rows[1]["valid_until"] is None + + +def test_d107_a_day_and_an_instant_inside_it_overlap(database_engine: Engine) -> None: + """Canonical bounds (D107 §5): a day-precision win and an instant-precision + mention later that same day overlap, so the model's `evidence` verdict is + honoured and one fact remains; the same instant on the next day is a + different occurrence.""" + adjudicator, _provider = _adjudicator( + engine=database_engine, router=_collapse_happy_router + ) + nate = _entity(engine=database_engine) + _add( + adjudicator=adjudicator, + entity=nate, + statement="Nate won the regional final on Saturday.", + engine=database_engine, + asserted_at="2022-11-07T20:10:00Z", + event_window=("2022-11-05", "2022-11-05"), + ) + claim_id = uuid4() + with database_engine.begin() as connection: + connection.execute( + text( + "INSERT INTO claims (claim_id, deployment_id, doc_id, chunk_id," + " claim_text, source_span, char_start, char_end, anchor_ok," + " window_membership_ok, extractor_version, asserted_at," + " claim_valid_kind, claim_valid_precision, claim_valid_from," + " claim_valid_until) VALUES (:c, :d, :doc, :ch, :s, :s, 0, 1," + " true, true, 'test', CAST(:a AS timestamptz), 'event_time'," + " 'instant', CAST(:t AS timestamptz), CAST(:t AS timestamptz))" + ), + { + "c": claim_id, + "d": _DEPLOYMENT_ID, + "doc": uuid4(), + "ch": uuid4(), + "s": "Nate said the final wrapped up at seven in the evening.", + "a": "2022-11-08T09:00:00Z", + "t": "2022-11-05T19:00:00Z", + }, + ) + adjudicator.add_observation( + deployment_id=_DEPLOYMENT_ID, + subject_entity_id=nate, + statement="Nate said the final wrapped up at seven in the evening.", + claim_id=claim_id, + doc_id=uuid4(), + ) + assert len(_observations(engine=database_engine, entity=nate)) == 1 + _add( + adjudicator=adjudicator, + entity=nate, + statement="Nate won the regional final on Saturday.", + engine=database_engine, + asserted_at="2022-11-14T20:10:00Z", + event_window=("2022-11-06", "2022-11-06"), + ) + assert len(_observations(engine=database_engine, entity=nate)) == 2 diff --git a/src/tests/surfaces/test_retrieval_batch_b.py b/src/tests/surfaces/test_retrieval_batch_b.py index 1851046a1..93fc96f54 100644 --- a/src/tests/surfaces/test_retrieval_batch_b.py +++ b/src/tests/surfaces/test_retrieval_batch_b.py @@ -725,3 +725,75 @@ def test_entity_resolution_failure_is_unknown_entity(corpus: _Corpus) -> None: assert not answer.entities assert answer.negative is not None assert answer.negative.kind is NegativeKind.UNKNOWN_ENTITY + + +def _insert_day_claim( + *, corpus: _Corpus, claim_id: UUID, day: datetime, body: str +) -> None: + """One current day-precision claim stored the way E2 stores it: both ends + on the day's midnight (D41), which the D107 §5 canonical bounds widen to + the whole calendar day at comparison time.""" + with corpus.engine.begin() as connection: + connection.execute( + text( + "INSERT INTO claims (claim_id, deployment_id, doc_id, chunk_id," + " claim_text, source_span, char_start, char_end, anchor_ok," + " window_membership_ok, claim_valid_from, claim_valid_until," + " claim_valid_precision, claim_valid_kind, extractor_version," + " ingested_at) VALUES (:claim, :deployment, :doc, :chunk, :body," + " :body, 0, 30, true, true, :day, :day, 'day', 'event_time'," + " 'batch-b', :ingested_at)" + ), + { + "claim": claim_id, + "deployment": _DEPLOYMENT_ID, + "doc": corpus.doc_id, + "chunk": uuid4(), + "body": body, + "day": day, + "ingested_at": _MENTIONED_AT, + }, + ) + + +def test_claims_as_of_finds_a_day_precision_claim_from_an_intraday_window( + corpus: _Corpus, +) -> None: + """D107 §5: a day is the whole calendar day, so a window strictly inside + it — and a point-in-time request — both find the claim; a window on the + next day does not.""" + claim_id = uuid4() + _insert_day_claim( + corpus=corpus, + claim_id=claim_id, + day=datetime(2024, 6, 15, tzinfo=UTC), + body="Nate won a regional tournament on the fifteenth.", + ) + try: + engine = corpus.query_engine() + intraday = engine.claims_as_of( + deployment_id=_DEPLOYMENT_ID, + from_=datetime(2024, 6, 15, 9, tzinfo=UTC), + to=datetime(2024, 6, 15, 23, tzinfo=UTC), + k=20, + ) + assert claim_id in {claim.claim_id for claim in intraday.evidence} + point = engine.claims_as_of( + deployment_id=_DEPLOYMENT_ID, + from_=datetime(2024, 6, 15, 12, tzinfo=UTC), + to=datetime(2024, 6, 15, 12, tzinfo=UTC), + k=20, + ) + assert claim_id in {claim.claim_id for claim in point.evidence} + next_day = engine.claims_as_of( + deployment_id=_DEPLOYMENT_ID, + from_=datetime(2024, 6, 16, 0, tzinfo=UTC), + to=datetime(2024, 6, 16, 23, tzinfo=UTC), + k=20, + ) + assert claim_id not in {claim.claim_id for claim in next_day.evidence} + finally: + with corpus.engine.begin() as connection: + connection.execute( + text("DELETE FROM claims WHERE claim_id = :claim"), {"claim": claim_id} + ) diff --git a/website/src/app/docs/project-status/page.mdx b/website/src/app/docs/project-status/page.mdx index 73a97746c..8caf1e318 100644 --- a/website/src/app/docs/project-status/page.mdx +++ b/website/src/app/docs/project-status/page.mdx @@ -252,7 +252,11 @@ examined, and the failure looks like the deployment being down. retrieval, retry, reasoning-effort, or call-budget work. - Historical `RS-LoCoMo-Full-v20` also requires every distinct retrieved value that directly satisfies the question. It does not add a retrieval or retry. -- The current `RS-LoCoMo-Full-v21` pins the D106 observation adjudicator: +- The current `RS-LoCoMo-Full-v22` pins canonical half-open claim bounds + (D107 WP-T.0a): a day-precision claim is found from an intraday `claims_as_of` + window and the observation adjudicator no longer treats a day as a + zero-width point. Ingestion provenance only. +- Historical `RS-LoCoMo-Full-v21` pins the D106 observation adjudicator: dated events with disjoint resolved windows never collapse onto or supersede each other (a same-occurrence date dispute may contradict), and a dated event is never `evidence` for an undated statement. Recurring From 47e7ec77e1c4a73e65e063aae2eb6b50dc21bc9c Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:02:46 +0200 Subject: [PATCH 2/4] fix(temporal): make the SQL canonical bounds session-timezone independent Codex review of #372: `timestamptz + interval` uses the SESSION time zone's calendar fields, so `claim_canonical_end` disagreed with its Python twin in a non-UTC session (a March-1 UTC month boundary read as February 28 in America/New_York, so "+1 month" gave March 28) and an IMMUTABLE function that feeds an expression index must not depend on the session. Both functions now truncate and advance on a UTC-naive timestamp (`AT TIME ZONE 'UTC'` in and out). The verdict prompt's `_date_text` likewise takes the UTC calendar day of an aware driver value instead of the session-zone day. Tests: the SQL twin equals Python under America/New_York, Asia/Kolkata and Pacific/Auckland across month/day(DST)/year/quarter-with-null-end/instant; `_date_text` prints the UTC day for a New York-rendered midnight. Benchmark banner and status page now say the library's `claims_as_of` results changed while the benchmark's read-tool set did not. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016p9xB9T7VYREuVmohDPKjR --- plan/designs/locomo_benchmark_design.md | 7 +- .../versions/p9_26_0047_canonical_bounds.py | 21 +++--- .../spine/observation_adjudication.py | 17 ++++- src/tests/spine/test_canonical_bounds_sql.py | 69 +++++++++++++++++++ website/src/app/docs/project-status/page.mdx | 3 +- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/plan/designs/locomo_benchmark_design.md b/plan/designs/locomo_benchmark_design.md index d24dc97f0..e2adb24c5 100644 --- a/plan/designs/locomo_benchmark_design.md +++ b/plan/designs/locomo_benchmark_design.md @@ -13,9 +13,10 @@ > component version now carries canonical half-open bounds (D107 §5): the > observation adjudicator compares a day as the whole calendar day and an > instant as a non-empty point, and `claims_as_of` finds a day-precision -> claim from an intraday window. Ingestion provenance, protocol identity, and -> fingerprint roll; no retrieval, retry, model-effort, or call-budget behavior -> changes. +> claim from an intraday window. Ingestion provenance, the library's +> `claims_as_of` result set, protocol identity, and fingerprint roll; the +> benchmark's exposed read-tool set, retry, model-effort, and call-budget +> behavior are unchanged. > **Historical D106 amendment (2026-09-03; superseded by D107 WP-T.0a).** The > D106 protocol was `RS-LoCoMo-Full-v21`. It retained v20's dataset, rendered documents, models, diff --git a/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py b/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py index 10ad78f92..aaccef33f 100644 --- a/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py +++ b/src/rememberstack/spine/migrations/versions/p9_26_0047_canonical_bounds.py @@ -25,12 +25,17 @@ valid_precision claim_valid_precision ) RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + -- All calendar arithmetic runs on a UTC-naive timestamp + -- (`AT TIME ZONE 'UTC'` in, `AT TIME ZONE 'UTC'` out), because + -- `timestamptz + interval` uses the SESSION time zone's calendar fields; + -- the result must not depend on the session that computed it (IMMUTABLE, + -- and it feeds an expression index). SELECT CASE WHEN valid_from IS NULL OR valid_precision = 'unknown' THEN NULL - WHEN valid_precision = 'day' THEN date_trunc('day', valid_from, 'UTC') - WHEN valid_precision = 'month' THEN date_trunc('month', valid_from, 'UTC') - WHEN valid_precision = 'quarter' THEN date_trunc('quarter', valid_from, 'UTC') - WHEN valid_precision = 'year' THEN date_trunc('year', valid_from, 'UTC') + WHEN valid_precision = 'day' THEN date_trunc('day', valid_from AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + WHEN valid_precision = 'month' THEN date_trunc('month', valid_from AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + WHEN valid_precision = 'quarter' THEN date_trunc('quarter', valid_from AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + WHEN valid_precision = 'year' THEN date_trunc('year', valid_from AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' ELSE valid_from END $$; @@ -47,10 +52,10 @@ WHEN valid_from IS NULL OR valid_precision = 'unknown' THEN NULL WHEN valid_precision = 'open' THEN NULL WHEN valid_precision = 'instant' THEN valid_from + interval '1 microsecond' - WHEN valid_precision = 'day' THEN date_trunc('day', coalesce(valid_until, valid_from), 'UTC') + interval '1 day' - WHEN valid_precision = 'month' THEN date_trunc('month', coalesce(valid_until, valid_from), 'UTC') + interval '1 month' - WHEN valid_precision = 'quarter' THEN date_trunc('quarter', coalesce(valid_until, valid_from), 'UTC') + interval '3 months' - WHEN valid_precision = 'year' THEN date_trunc('year', coalesce(valid_until, valid_from), 'UTC') + interval '1 year' + WHEN valid_precision = 'day' THEN (date_trunc('day', coalesce(valid_until, valid_from) AT TIME ZONE 'UTC') + interval '1 day') AT TIME ZONE 'UTC' + WHEN valid_precision = 'month' THEN (date_trunc('month', coalesce(valid_until, valid_from) AT TIME ZONE 'UTC') + interval '1 month') AT TIME ZONE 'UTC' + WHEN valid_precision = 'quarter' THEN (date_trunc('quarter', coalesce(valid_until, valid_from) AT TIME ZONE 'UTC') + interval '3 months') AT TIME ZONE 'UTC' + WHEN valid_precision = 'year' THEN (date_trunc('year', coalesce(valid_until, valid_from) AT TIME ZONE 'UTC') + interval '1 year') AT TIME ZONE 'UTC' END $$; COMMENT ON FUNCTION claim_canonical_end(timestamptz, timestamptz, claim_valid_precision) IS diff --git a/src/rememberstack/spine/observation_adjudication.py b/src/rememberstack/spine/observation_adjudication.py index d3f6ac1a2..8120110fc 100644 --- a/src/rememberstack/spine/observation_adjudication.py +++ b/src/rememberstack/spine/observation_adjudication.py @@ -18,7 +18,9 @@ from collections.abc import Mapping from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime from datetime import timedelta +from datetime import timezone from typing import Any from typing import Final from uuid import UUID @@ -1313,9 +1315,18 @@ def _last_inside(end_exclusive: object) -> object: def _date_text(value: object) -> str: - """Render a timestamp as its calendar date; anything else verbatim.""" - date = getattr(value, "date", None) - return str(date()) if callable(date) else str(value) + """Render a timestamp as its UTC calendar date; anything else verbatim. + + Driver rows arrive in the connection's session zone, and canonical bounds + (D107 §5) are UTC-aligned, so the date is taken after converting to UTC — + otherwise a canonical day could print as the evening before. + """ + if isinstance(value, datetime): + aware = ( + value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) + ) + return str(aware.astimezone(timezone.utc).date()) + return str(value) def _temporal_relation(*, timing: _ClaimTiming, candidate: Mapping[str, object]) -> str: diff --git a/src/tests/spine/test_canonical_bounds_sql.py b/src/tests/spine/test_canonical_bounds_sql.py index 4c30a91af..d7b47a8e4 100644 --- a/src/tests/spine/test_canonical_bounds_sql.py +++ b/src/tests/spine/test_canonical_bounds_sql.py @@ -84,3 +84,72 @@ def test_sql_twin_unknown_precision_is_no_interval(database_engine: Engine) -> N ) ).one() assert row.s is None and row.e is None + + +@pytest.mark.parametrize( + "session_zone", ["America/New_York", "Asia/Kolkata", "Pacific/Auckland"] +) +@pytest.mark.parametrize( + ("precision", "valid_from", "valid_until"), + [ + ( + "month", + "2023-03-01T00:00", + "2023-03-01T00:00", + ), # a month boundary that local calendars shift + ("day", "2023-03-12T00:00", "2023-03-12T00:00"), # a US DST transition day + ("year", "2022-01-01T00:00", "2022-12-31T00:00"), + ( + "quarter", + "2022-11-15T00:00", + None, + ), # bounded precision with a null stored end + ("instant", "2022-08-21T16:30", "2022-08-21T16:30"), + ], +) +def test_sql_twin_is_session_timezone_independent( + database_engine: Engine, + session_zone: str, + precision: str, + valid_from: str, + valid_until: str | None, +) -> None: + """IMMUTABLE must mean it: the same inputs give the same UTC-aligned bounds + whatever the session TimeZone, or the expression index would be unsafe.""" + with database_engine.connect() as connection: + connection.execute(text(f"SET LOCAL TIME ZONE '{session_zone}'")) + row = connection.execute( + text( + "SELECT claim_canonical_start(CAST(:f AS timestamptz)," + " CAST(:p AS claim_valid_precision)) AS s," + " claim_canonical_end(CAST(:f AS timestamptz)," + " CAST(:u AS timestamptz), CAST(:p AS claim_valid_precision)) AS e" + ), + { + "f": valid_from + "+00:00", + "u": (valid_until + "+00:00") if valid_until else None, + "p": precision, + }, + ).one() + expected = canonical_bounds( + valid_from=_ts(valid_from), + valid_until=_ts(valid_until) if valid_until else None, + precision=precision, + ) + assert row.s == expected.start, (session_zone, precision, "start") + assert row.e == expected.end, (session_zone, precision, "end") + + +def test_prompt_dates_render_the_utc_calendar_day() -> None: + """A driver row in a non-UTC session zone must still print the UTC day.""" + from zoneinfo import ZoneInfo + + from rememberstack.spine.observation_adjudication import _date_text + + canonical_midnight_utc = datetime(2023, 5, 7, tzinfo=timezone.utc) + as_new_york = canonical_midnight_utc.astimezone(ZoneInfo("America/New_York")) + assert as_new_york.date().isoformat() == "2023-05-06" # the trap + assert _date_text(as_new_york) == "2023-05-07" + assert ( + _date_text(datetime(2023, 5, 7, 12, 0)) == "2023-05-07" + ) # naive is read as UTC diff --git a/website/src/app/docs/project-status/page.mdx b/website/src/app/docs/project-status/page.mdx index 8caf1e318..3c623ab2b 100644 --- a/website/src/app/docs/project-status/page.mdx +++ b/website/src/app/docs/project-status/page.mdx @@ -255,7 +255,8 @@ examined, and the failure looks like the deployment being down. - The current `RS-LoCoMo-Full-v22` pins canonical half-open claim bounds (D107 WP-T.0a): a day-precision claim is found from an intraday `claims_as_of` window and the observation adjudicator no longer treats a day as a - zero-width point. Ingestion provenance only. + zero-width point. Ingestion provenance and `claims_as_of` results change; the + benchmark's tool set does not. - Historical `RS-LoCoMo-Full-v21` pins the D106 observation adjudicator: dated events with disjoint resolved windows never collapse onto or supersede each other (a same-occurrence date dispute may contradict), and a dated From 870b0d1ea03dadb1020d82588a02035780d4223d Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:28:28 +0200 Subject: [PATCH 3/4] test: register the canonical-bounds suites and roll the migration head literals CI on #372: the two new test files were absent from the CI test inventory; the migration chain and head literals still ended at p9_25_0046; and the batch-B intraday test inserted its claim against a random chunk id, so the history hydration view (claims_visible_history joins chunks) dropped it after the candidate scan had found it. The test claim now hangs off a live chunk of the live document, which is what the proof is about. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016p9xB9T7VYREuVmohDPKjR --- .github/ci/integration-paths.txt | 1 + .github/ci/unit-paths.txt | 1 + src/tests/spine/test_migrations.py | 3 ++- src/tests/surfaces/test_retrieval_batch_b.py | 5 +++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/ci/integration-paths.txt b/.github/ci/integration-paths.txt index 2f2709b4c..b58d4747b 100644 --- a/.github/ci/integration-paths.txt +++ b/.github/ci/integration-paths.txt @@ -5,6 +5,7 @@ src/tests/eval/test_s58_consumption.py src/tests/profiles/test_selfhost_profile.py src/tests/spikes/test_operational_scale.py src/tests/spine/test_backfill.py +src/tests/spine/test_canonical_bounds_sql.py src/tests/spine/test_clustering.py src/tests/spine/test_component_versions.py src/tests/spine/test_deployment_bootstrap.py diff --git a/.github/ci/unit-paths.txt b/.github/ci/unit-paths.txt index c20dd154b..d7c457782 100644 --- a/.github/ci/unit-paths.txt +++ b/.github/ci/unit-paths.txt @@ -39,6 +39,7 @@ src/tests/core/test_knowledge_planner.py src/tests/core/test_knowledge_writer.py src/tests/core/test_section_snap.py src/tests/core/test_structure_skeleton.py +src/tests/core/test_temporal.py src/tests/core/test_text_metering.py src/tests/eval/test_s55_hard_forget.py src/tests/eval/test_s55_selfhost_restore.py diff --git a/src/tests/spine/test_migrations.py b/src/tests/spine/test_migrations.py index a6cd57826..cf486fa93 100644 --- a/src/tests/spine/test_migrations.py +++ b/src/tests/spine/test_migrations.py @@ -125,6 +125,7 @@ def test_revision_graph_is_one_linear_structural_chain() -> None: "p9_23_0044", "p9_24_0045", "p9_25_0046", + "p9_26_0047", ) assert len(script.get_heads()) == 1 @@ -663,7 +664,7 @@ def test_postgresql_fresh_downgrade_reupgrade_mutation_and_noop_lifecycle() -> N head_before_noop = _head_revision(database_url=database_url) command.upgrade(config=config, revision="head") head_after_noop = _head_revision(database_url=database_url) - assert head_before_noop == head_after_noop == "p9_25_0046" + assert head_before_noop == head_after_noop == "p9_26_0047" assert _inventory(database_url=database_url) == restored_inventory diff --git a/src/tests/surfaces/test_retrieval_batch_b.py b/src/tests/surfaces/test_retrieval_batch_b.py index 93fc96f54..934c3ceaf 100644 --- a/src/tests/surfaces/test_retrieval_batch_b.py +++ b/src/tests/surfaces/test_retrieval_batch_b.py @@ -732,7 +732,8 @@ def _insert_day_claim( ) -> None: """One current day-precision claim stored the way E2 stores it: both ends on the day's midnight (D41), which the D107 §5 canonical bounds widen to - the whole calendar day at comparison time.""" + the whole calendar day at comparison time. It hangs off a live chunk of + the live document so hydration (``claims_visible_history``) keeps it.""" with corpus.engine.begin() as connection: connection.execute( text( @@ -748,7 +749,7 @@ def _insert_day_claim( "claim": claim_id, "deployment": _DEPLOYMENT_ID, "doc": corpus.doc_id, - "chunk": uuid4(), + "chunk": corpus.chunk_ids[0], "body": body, "day": day, "ingested_at": _MENTIONED_AT, From d0c5f045807ced6c0469a9dc02c49fd89a6ff257 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 19:37:53 +0200 Subject: [PATCH 4/4] ci: roll the compose upgrade-gate head assertion to p9_26_0047 The Compose quickstart lane asserts the post-upgrade alembic head literal; migration p9_26_0047 (canonical bounds) moves it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016p9xB9T7VYREuVmohDPKjR --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e5a99844..0aecc2a48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -371,7 +371,7 @@ jobs: trap - EXIT test "$(docker compose --env-file .env.example exec -T postgres \ psql -U rememberstack -d rememberstack -Atc \ - 'SELECT version_num FROM alembic_version')" = 'p9_25_0046' + 'SELECT version_num FROM alembic_version')" = 'p9_26_0047' test "$(docker compose --env-file .env.example exec -T postgres \ psql -U rememberstack -d rememberstack -Atc \ 'SELECT count(*) FROM deployments')" = '1'