diff --git a/.env.example b/.env.example index d5786ae4..adca29af 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,12 @@ REMEMBERSTACK_OPENROUTER_API_KEY=replace-before-real-use # "none" reduces extraction latency but can reduce adjudication quality, and # models that require reasoning may reject it. # REMEMBERSTACK_OPENROUTER_REASONING_EFFORT=none +# Optional per-model overrides as a JSON object of model-id → effort. A model's +# entry wins over the global pin above; models absent from the map fall back to +# the global pin (or the model default when that is also unset). Same allowed +# effort literals. Example: pin flash models to none while keeping high for a +# reasoning model. +# REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP={"z-ai/glm-4.7-flash":"none","openai/gpt-5.6-luna":"high"} # Optional benchmark/deployment model overrides. Keep explicit model IDs for a # reproducible run; do not use a rotating router such as openrouter/free. diff --git a/benchmarks/locomo/README.md b/benchmarks/locomo/README.md index edb20710..24e24daa 100644 --- a/benchmarks/locomo/README.md +++ b/benchmarks/locomo/README.md @@ -1,4 +1,4 @@ -# RS-LoCoMo-Full-v4 setup +# RS-LoCoMo-Full-v5 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 diff --git a/benchmarks/locomo/cli.py b/benchmarks/locomo/cli.py index 25ac9fce..e5b3c0c9 100644 --- a/benchmarks/locomo/cli.py +++ b/benchmarks/locomo/cli.py @@ -104,7 +104,7 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m benchmarks.locomo", description=( - "RS-LoCoMo-Full-v4: prepare is local; ingest/answer/judge require " + "RS-LoCoMo-Full-v5: prepare is local; ingest/answer/judge require " "explicit execution acknowledgements" ), ) diff --git a/benchmarks/locomo/model.py b/benchmarks/locomo/model.py index 13daec0d..f736ca20 100644 --- a/benchmarks/locomo/model.py +++ b/benchmarks/locomo/model.py @@ -1,4 +1,4 @@ -"""Typed values for the full-system RS-LoCoMo-Full-v4 protocol.""" +"""Typed values for the full-system RS-LoCoMo-Full-v5 protocol.""" from __future__ import annotations @@ -108,7 +108,7 @@ class QuestionManifest(FrozenModel): class RunConfiguration(FrozenModel): """Immutable identity of one prepared benchmark run.""" - protocol_name: Literal["RS-LoCoMo-Full-v4"] = "RS-LoCoMo-Full-v4" + protocol_name: Literal["RS-LoCoMo-Full-v5"] = "RS-LoCoMo-Full-v5" adapter_version: NonEmpty prepared_at: datetime repository_revision: NonEmpty @@ -350,7 +350,7 @@ class SessionDiagnosticSummary(FrozenModel): class RunSummary(FrozenModel): """Publication-ready local aggregate with no hidden denominator.""" - protocol_name: Literal["RS-LoCoMo-Full-v4"] = "RS-LoCoMo-Full-v4" + protocol_name: Literal["RS-LoCoMo-Full-v5"] = "RS-LoCoMo-Full-v5" protocol_fingerprint: NonEmpty tier: Tier questions: int = Field(ge=1) diff --git a/benchmarks/locomo/protocol.py b/benchmarks/locomo/protocol.py index 6900487e..ea0458fe 100644 --- a/benchmarks/locomo/protocol.py +++ b/benchmarks/locomo/protocol.py @@ -20,12 +20,12 @@ from benchmarks.locomo.model import ToolCallRecord from rememberstack.model import ToolDescriptor -PROTOCOL_NAME: Final = "RS-LoCoMo-Full-v4" +PROTOCOL_NAME: Final = "RS-LoCoMo-Full-v5" ADAPTER_VERSION: Final = "locomo-full-adapter-2026.07" MAX_TOOL_CALLS: Final = 8 MAX_AGENT_CALLS: Final = 9 EXPECTED_TOOL_CATALOG_SHA256: Final = ( - "f0492cedb5a648816511d99ff35c96862bd9299be186e08bd27a68de76012953" + "34d2069ae37fabf033d2b1f0fae2ed9e7c1ad5c3f6e1fd14b2971344cd89c3a6" ) EXPECTED_PIPELINE_STAGES: Final = ( "convert", diff --git a/benchmarks/locomo/runner.py b/benchmarks/locomo/runner.py index df7e236c..560f5b87 100644 --- a/benchmarks/locomo/runner.py +++ b/benchmarks/locomo/runner.py @@ -354,7 +354,7 @@ def answer_sample( ): raise ExecutionGuardError( "the deployment did not report the exact completed" - " RS-LoCoMo-Full-v4 pipeline and fresh P2/P3 projections" + " RS-LoCoMo-Full-v5 pipeline and fresh P2/P3 projections" ) _require_serving_revision(context=context, readiness=readiness) prior_readiness = context.state.readiness.get(sample_id) @@ -674,7 +674,7 @@ def _validate_run( ) -> None: """Recompute immutable run identity before any local or remote stage.""" if configuration.dataset_sha256 != DATASET_SHA256: - raise BenchmarkRunError("run dataset hash is not RS-LoCoMo-Full-v4") + raise BenchmarkRunError("run dataset hash is not RS-LoCoMo-Full-v5") 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: @@ -684,14 +684,14 @@ 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-v4") + raise BenchmarkRunError("run dataset commit is not RS-LoCoMo-Full-v5") if configuration.adapter_version != ADAPTER_VERSION: raise BenchmarkRunError("run adapter version differs from current code") if ( configuration.answer_agent_temperature != TEMPERATURE or configuration.judge_temperature != TEMPERATURE ): - raise BenchmarkRunError("run temperature differs from RS-LoCoMo-Full-v4") + raise BenchmarkRunError("run temperature differs from RS-LoCoMo-Full-v5") if _models_hash(values=documents) != configuration.documents_sha256: raise BenchmarkRunError("prepared document manifest changed") if len(questions) != configuration.item_count: diff --git a/compose.yaml b/compose.yaml index 50ee74aa..65f07fcb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,6 +18,7 @@ x-app: &app REMEMBERSTACK_OPENROUTER_API_KEY: ${REMEMBERSTACK_OPENROUTER_API_KEY} REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER: ${REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER:-} REMEMBERSTACK_OPENROUTER_REASONING_EFFORT: ${REMEMBERSTACK_OPENROUTER_REASONING_EFFORT:-} + REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP: ${REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP:-} REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID: ${REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID} REMEMBERSTACK_SELFHOST_DEPLOYMENT_SLUG: ${REMEMBERSTACK_SELFHOST_DEPLOYMENT_SLUG} REMEMBERSTACK_SELFHOST_DEPLOYMENT_NAME: ${REMEMBERSTACK_SELFHOST_DEPLOYMENT_NAME} diff --git a/plan/designs/locomo_benchmark_design.md b/plan/designs/locomo_benchmark_design.md index 3c927d70..2a382266 100644 --- a/plan/designs/locomo_benchmark_design.md +++ b/plan/designs/locomo_benchmark_design.md @@ -21,7 +21,7 @@ WP-8.2 remains in progress until an owner-authorized eight-question smoke finish ## 2. Fixed protocol ```text -protocol RS-LoCoMo-Full-v4 +protocol RS-LoCoMo-Full-v5 dataset commit 3eb6f2c585f5e1699204e3c3bdf7adc5c28cb376 dataset SHA-256 79fa87e90f04081343b8c8debecb80a9a6842b76a7aa537dc9fdf651ea698ff4 categories 1, 2, 3, 4 @@ -53,6 +53,14 @@ tool+arguments retries; switch tools after a useless result; try a claims search before "Unknown"). Prompt, tool-catalog hash, and descriptors all changed, so the protocol version bumps. No v3 score is comparable. +**v4 → v5 (2026-07-27, issue #156):** the `identity_as_of` recipe descriptor +became honest about its recent-first transcript bound (default 40 rows, +truncation signalled in the envelope) and gained an optional `limit` +parameter so a truncated history is recoverable through the public surface. +The tool-catalog hash changed, so the protocol version bumps — the v4 smoke +scores (glm-4.7-flash arm) were taken against the pre-truncation catalog and +are not directly comparable. + ### 2.1 Why v2+ uses a stronger judge `RS-LoCoMo-Full-v1` used `openai/gpt-4o-mini` for both the answer agent and the judge. The diff --git a/plan/designs/retrieval_design.md b/plan/designs/retrieval_design.md index cee6c620..478c784c 100644 --- a/plan/designs/retrieval_design.md +++ b/plan/designs/retrieval_design.md @@ -117,12 +117,24 @@ never trigger anything** — all K/E triggering originates from writes). | `fuse` | result_sets → RRF-merged set | reciprocal-rank fusion of parallel channels (D9), exposed as an operator so *agent-composed* channel sets fuse the same way recipes do | S46 | | `rerank` | candidates × signal — graph_distance(focal), evidence_count, cross_encoder (flagged) | the D9 rerankers as explicit, inspectable stages | S46, S48 | | `hydrate` | ids, depth: record \| evidence \| sources \| bytes, locator? | the §2 confirmation hop + progressive deepening: record → evidence rows + claims → documents → GCS handles. At `depth=bytes` an optional **source locator** (D65) scopes the fetch to a time interval / region, returning a seekable, codec-aware segment (§7 — unmounted parity for media) | S5, S59, all | -| `transcript` | relation \| observation \| entity \| k_page → its decision history | adjudications, resolution decisions, compile provenance — the audit trail as a first-class query ("why do we believe…") | S8, S32, S35 | +| `transcript` | relation \| observation \| entity \| k_page → its decision history (recent-first bound; see amendment below) | adjudications, resolution decisions, compile provenance — the audit trail as a first-class query ("why do we believe…") | S8, S32, S35 | | `delta` | since T, scope?, kinds? → changed evidence / pages | the change feed as a query (new / capped / invalidated / recompiled) | S13, S14, S30 | | `pages_about` | entity \| key → K pages (+ freshness/flags) | **the K routing index read backwards**: the rule-key inverted index built for write-side routing doubles as the reader's discovery index — which pages exist about X, mechanically | S31, S45 | | `aggregate` | enumerated forms: count / group-by-predicate / group-by-object / timeline(entity) / delta-top-entities(since T) / typed-absence(type, predicate) | see §9 — enumerated, not general | S12, S26–S28, S30, S40 | | `scan` | filter → stream | the batch surface (§9): full exports for compilers, auditors, external analytics — same zero-LLM reads, streaming contract, separate resource pool from interactive | S53 | +**Amendment (2026-07-27, issue #156).** `transcript` (and the `identity_as_of` +recipe that is one fixed chain over it) is **recent-first bounded** by a +measurable default (`DEFAULT_TRANSCRIPT_LIMIT`, starting point 40 rows), with +the envelope's existing truncation marker set when the cap applies. Long +entity resolution logs were returning hundreds of rows and blowing agent +reader contexts; S18 already forbids silent caps, so the bound is disclosed +rather than unbounded-by-default. Callers that need a larger window pass an +explicit `limit` — on the Python surface and as the recipe's optional `limit` +parameter (recipe v3), so a truncated history is recoverable through the +public surface too. This is the same cap-with-signal pattern as `delta`, not +a new mechanism. + **Temporal parameters — composed, not special-cased (S15/S16).** Every primitive that touches validity accepts `valid_at` (world time: "what held at T") and `believed_at` (system time: "what did we believe at T"; caps `ingested_at`/`invalidated_at` across *all* channels at once — diff --git a/src/rememberstack/adapters/openrouter.py b/src/rememberstack/adapters/openrouter.py index 9d7254ba..d5aadc7b 100644 --- a/src/rememberstack/adapters/openrouter.py +++ b/src/rememberstack/adapters/openrouter.py @@ -6,7 +6,9 @@ import json import time from typing import Any +from typing import Final from typing import Literal +from typing import TypeAlias from typing import TypeVar import httpx @@ -27,6 +29,15 @@ ResponseT = TypeVar("ResponseT", bound=StructuredResponseModel) +ReasoningEffort: TypeAlias = Literal[ + "none", "minimal", "low", "medium", "high", "xhigh", "max" +] +"""Allowed OpenRouter reasoning-effort values (global pin or per-model map).""" + +_ALLOWED_REASONING_EFFORTS: Final[frozenset[str]] = frozenset( + ("none", "minimal", "low", "medium", "high", "xhigh", "max") +) + class StrictSchemaError(ValueError): """A response model cannot be expressed under strict structured output. @@ -46,9 +57,15 @@ class OpenRouterSettings(BaseSettings): base_url: str = Field(default="https://openrouter.ai/api/v1") timeout_s: float = Field(default=120.0, gt=0) embedding_provider: str | None = None - reasoning_effort: ( - Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"] | None - ) = None + reasoning_effort: ReasoningEffort | None = None + reasoning_effort_map: dict[str, ReasoningEffort] | None = None + """Optional per-model effort overrides as a JSON object env var + (`REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP`, e.g. + `{"z-ai/glm-4.7-flash":"none","openai/gpt-5.6-luna":"high"}`). A model's + entry wins over the global `reasoning_effort` for requests to that model; + absent entries fall back to the global pin (or the model default when the + global pin is also unset). Values must be one of the allowed effort + literals.""" @field_validator("embedding_provider", "reasoning_effort", mode="before") @classmethod @@ -58,6 +75,46 @@ def normalize_optional_string(cls, value: object) -> object: return value return value.strip() or None + @field_validator("reasoning_effort_map", mode="before") + @classmethod + def parse_reasoning_effort_map(cls, value: object) -> object: + """Parse the JSON object env form and reject unknown effort literals. + + Compose often supplies empty strings for unset optionals; treat those + as unset. A JSON string is accepted because pydantic-settings may hand + the raw env value through before typed decoding on some paths. + """ + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None + try: + value = json.loads(stripped) + except json.JSONDecodeError as error: + raise ValueError( + "reasoning_effort_map must be a JSON object of model-id → effort" + ) from error + if not isinstance(value, dict): + raise ValueError( + "reasoning_effort_map must be a JSON object of model-id → effort" + ) + parsed: dict[str, str] = {} + for model_id, effort in value.items(): + if not isinstance(model_id, str) or not model_id.strip(): + raise ValueError( + "reasoning_effort_map keys must be non-empty model id strings" + ) + if not isinstance(effort, str) or effort not in _ALLOWED_REASONING_EFFORTS: + raise ValueError( + f"reasoning_effort_map[{model_id!r}]={effort!r} is not an" + f" allowed effort" + f" ({', '.join(sorted(_ALLOWED_REASONING_EFFORTS))})" + ) + parsed[model_id.strip()] = effort + return parsed or None + class OpenRouterProviderError(ProviderCallError): """OpenRouter returned an error or an unusable response body.""" @@ -94,8 +151,9 @@ def generate( } if request.temperature is not None: payload["temperature"] = request.temperature - if self._settings.reasoning_effort is not None: - payload["reasoning"] = {"effort": self._settings.reasoning_effort} + effort = self._reasoning_effort_for(model=request.model) + if effort is not None: + payload["reasoning"] = {"effort": effort} content, usage = self._completion_text( payload=payload, response_type=response_type, started_ns=started_ns @@ -118,6 +176,13 @@ def generate( ) from error return GeneratedResponse(output=output, usage=usage) + def _reasoning_effort_for(self, *, model: str) -> ReasoningEffort | None: + """Resolve effort for one model: per-model map entry, else global pin.""" + mapped = self._settings.reasoning_effort_map + if mapped is not None and model in mapped: + return mapped[model] + return self._settings.reasoning_effort + def _completion_text( self, *, diff --git a/src/rememberstack/adapters/testing/model_provider.py b/src/rememberstack/adapters/testing/model_provider.py index e2106543..4d43cb8a 100644 --- a/src/rememberstack/adapters/testing/model_provider.py +++ b/src/rememberstack/adapters/testing/model_provider.py @@ -30,11 +30,13 @@ def __init__( self._generate_router = generate_router self.embedded_texts: list[str] = [] self.generated_prompts: list[str] = [] + self.generated_requests: list[ModelRequest] = [] def generate[ResponseT: StructuredResponseModel]( self, *, request: ModelRequest, response_type: type[ResponseT] ) -> GeneratedResponse[ResponseT]: """Return the canned payload validated as the caller's declared type.""" + self.generated_requests.append(request) self.generated_prompts.append(request.prompt) if callable(self._generate_router): output = response_type.model_validate( diff --git a/src/rememberstack/profiles/selfhost.py b/src/rememberstack/profiles/selfhost.py index 2cf505af..21553459 100644 --- a/src/rememberstack/profiles/selfhost.py +++ b/src/rememberstack/profiles/selfhost.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json from pathlib import Path import sys from typing import Self @@ -615,6 +616,13 @@ def _model_bindings() -> dict[str, str]: "fact_label": p1.label_model, "openrouter_embedding_provider": openrouter.embedding_provider or "auto", "openrouter_reasoning_effort": openrouter.reasoning_effort or "auto", + # Canonical (sorted-key) form so the effective per-model effort policy + # is part of measurement provenance, not hidden behind the global pin. + "openrouter_reasoning_effort_map": json.dumps( + openrouter.reasoning_effort_map, sort_keys=True + ) + if openrouter.reasoning_effort_map + else "unset", } diff --git a/src/rememberstack/spine/observation_adjudication.py b/src/rememberstack/spine/observation_adjudication.py index a07b58db..433aa5a1 100644 --- a/src/rememberstack/spine/observation_adjudication.py +++ b/src/rememberstack/spine/observation_adjudication.py @@ -35,8 +35,9 @@ from rememberstack.ports.cost_meter import CostMeterPort from rememberstack.ports.model_provider import ModelProviderPort -OBSERVATION_ADJUDICATOR_VERSION: Final = "obs-adjudicator-2026.07" -"""The observation adjudicator generation (D12; replayed on rebuild, D7).""" +OBSERVATION_ADJUDICATOR_VERSION: Final = "obs-adjudicator-2026.07b:temp0-1" +"""The observation adjudicator generation (D12; replayed on rebuild, D7). +07b pins temperature=0.0 — generation parameters are part of provenance.""" _VERDICT_PROMPT: Final = """You adjudicate observations for a memory system. Both statements are believed facts about the SAME entity: @@ -503,7 +504,9 @@ def _ladder( """Small-model verdict, escalating to frontier below the floor.""" prompt = _VERDICT_PROMPT.format(existing=existing, new=new) verdict_call = self._model_provider.generate( - request=ModelRequest(model=self._settings.small_model, prompt=prompt), + request=ModelRequest( + model=self._settings.small_model, prompt=prompt, temperature=0.0 + ), response_type=ObservationVerdict, ) if meter is not None: @@ -516,7 +519,9 @@ def _ladder( if verdict.confidence >= self._settings.confidence_floor: return verdict, "small_model" frontier_call = self._model_provider.generate( - request=ModelRequest(model=self._settings.frontier_model, prompt=prompt), + request=ModelRequest( + model=self._settings.frontier_model, prompt=prompt, temperature=0.0 + ), response_type=ObservationVerdict, ) if meter is not None: diff --git a/src/rememberstack/spine/recipes.py b/src/rememberstack/spine/recipes.py index 69d8705f..6fa66fae 100644 --- a/src/rememberstack/spine/recipes.py +++ b/src/rememberstack/spine/recipes.py @@ -271,20 +271,26 @@ def _recipe_from_row(row: RowMapping) -> Recipe: ), Recipe( name="identity_as_of", - description="An entity's identity history — how its mentions resolved" - " and every merge it took part in (S61). Composite grain, audit." - " As-of regime resolution, not a biography or fact timeline.", - parameters={"entity_id": {"type": "uuid", "required": True}}, + description="An entity's identity history — recent resolution" + " decisions and merges (S61). Composite grain, audit. As-of regime" + " resolution, not a biography or fact timeline. Keeps the newest" + " `limit` rows (default 40), returned oldest-to-newest; the envelope" + " signals truncation when older history was cut. Pass a larger" + " `limit` to widen the window.", + parameters={ + "entity_id": {"type": "uuid", "required": True}, + "limit": {"type": "integer", "required": False, "minimum": 1}, + }, chain=( RecipeStep( op="transcript", settings={"subject_kind": "entity"}, - bind={"subject_id": "entity_id"}, + bind={"subject_id": "entity_id", "limit": "limit"}, ), ), output_grain=Grain.COMPOSITE, answer_intent=RecipeAnswerIntent.AUDIT, - version=2, + version=3, ), Recipe( name="changed_since", diff --git a/src/rememberstack/spine/resolver.py b/src/rememberstack/spine/resolver.py index 197d7591..2d94180e 100644 --- a/src/rememberstack/spine/resolver.py +++ b/src/rememberstack/spine/resolver.py @@ -38,8 +38,9 @@ class ResolverVersionConflictError(Exception): """A resolver version re-registered with a different definition (D22).""" -RESOLVER_VERSION: Final = "resolver-2026.07a" -"""The cascade generation whose thresholds stamp every decision (D17/D22).""" +RESOLVER_VERSION: Final = "resolver-2026.07b" +"""The cascade generation whose thresholds stamp every decision (D17/D22). +07b pins T4 temperature=0.0 — generation parameters are part of provenance.""" _T4_PROMPT: Final = """You adjudicate entity identity for a memory system. Are these the same real-world entity? Answer strictly from the evidence given. @@ -224,13 +225,17 @@ def judge_pair( if context_a: prompt += f"\nCANDIDATE CONTEXT: {context_a}" verdict = self._model_provider.generate( - request=ModelRequest(model=self._small_model, prompt=prompt), + request=ModelRequest( + model=self._small_model, prompt=prompt, temperature=0.0 + ), response_type=AdjudicationVerdict, ) if verdict.output.confidence >= thresholds.t4_small_confidence_floor: return verdict.output.match, "T4_small" frontier = self._model_provider.generate( - request=ModelRequest(model=self._frontier_model, prompt=prompt), + request=ModelRequest( + model=self._frontier_model, prompt=prompt, temperature=0.0 + ), response_type=AdjudicationVerdict, ) return frontier.output.match, "T4_frontier" @@ -384,7 +389,9 @@ def _t4( candidate_type=candidate.type, ) verdict_call = self._model_provider.generate( - request=ModelRequest(model=self._small_model, prompt=prompt), + request=ModelRequest( + model=self._small_model, prompt=prompt, temperature=0.0 + ), response_type=AdjudicationVerdict, ) if meter is not None: @@ -396,7 +403,9 @@ def _t4( if verdict.confidence >= thresholds.t4_small_confidence_floor: return verdict, "T4_small", self._small_model frontier_call = self._model_provider.generate( - request=ModelRequest(model=self._frontier_model, prompt=prompt), + request=ModelRequest( + model=self._frontier_model, prompt=prompt, temperature=0.0 + ), response_type=AdjudicationVerdict, ) if meter is not None: diff --git a/src/rememberstack/spine/supersession.py b/src/rememberstack/spine/supersession.py index 24c360e0..bba9864e 100644 --- a/src/rememberstack/spine/supersession.py +++ b/src/rememberstack/spine/supersession.py @@ -30,8 +30,9 @@ from rememberstack.ports.cost_meter import CostMeterPort from rememberstack.ports.model_provider import ModelProviderPort -ADJUDICATOR_VERSION: Final = "adjudicator-2026.07" -"""The supersession adjudicator generation (D12; replayed on rebuild, D7).""" +ADJUDICATOR_VERSION: Final = "adjudicator-2026.07b:temp0-1" +"""The supersession adjudicator generation (D12; replayed on rebuild, D7). +07b pins temperature=0.0 — generation parameters are part of provenance.""" _ADJUDICATION_PROMPT: Final = """You adjudicate fact supersession for a memory system. Two believed facts share a subject and a change-prone predicate: @@ -212,7 +213,9 @@ def _adjudicate_pair( new_asserted=new["asserted_at"] or "unknown", ) verdict_call = self._model_provider.generate( - request=ModelRequest(model=self._settings.small_model, prompt=prompt), + request=ModelRequest( + model=self._settings.small_model, prompt=prompt, temperature=0.0 + ), response_type=SupersessionVerdict, ) if meter is not None: @@ -227,7 +230,7 @@ def _adjudicate_pair( if verdict.confidence < self._settings.confidence_floor: verdict_call = self._model_provider.generate( request=ModelRequest( - model=self._settings.frontier_model, prompt=prompt + model=self._settings.frontier_model, prompt=prompt, temperature=0.0 ), response_type=SupersessionVerdict, ) diff --git a/src/rememberstack/surfaces/query_engine.py b/src/rememberstack/surfaces/query_engine.py index 28ef9b61..fd74a8bb 100644 --- a/src/rememberstack/surfaces/query_engine.py +++ b/src/rememberstack/surfaces/query_engine.py @@ -57,6 +57,13 @@ """How many change-feed rows one `delta` page returns before truncating — a starting point to measure, not a committed constant (retrieval §13).""" +DEFAULT_TRANSCRIPT_LIMIT = 40 +"""How many decision-history rows one `transcript` returns before truncating +— recent-first. A starting point to measure, not a committed constant: long +entity resolution logs (hundreds of mention rows) must not blow a reader +context, and S18 forbids silent caps, so the envelope always signals when +this bound applies.""" + DEFAULT_SCAN_BATCH = 1_000 """How many rows the batch `scan` cursor fetches per round-trip.""" @@ -423,7 +430,12 @@ def hydrate_relation(self, *, deployment_id: UUID, relation_id: UUID) -> Envelop ) def transcript( - self, *, deployment_id: UUID, subject_kind: str, subject_id: UUID + self, + *, + deployment_id: UUID, + subject_kind: str, + subject_id: UUID, + limit: int = DEFAULT_TRANSCRIPT_LIMIT, ) -> Envelope: """The S8/S32/S35 audit query: any subject's decision history. @@ -431,10 +443,17 @@ def transcript( four subjects a decision is about: a supersession-adjudicated `relation` or `observation`, a resolved/merged `entity` (its resolution decisions braided with its merges), or a compiled - `k_page` (its compile provenance). Returned newest-last; reads never - trigger anything. An empty history is `known_empty`, not a guess; an - unknown kind is a `boundary` naming the four that exist. + `k_page` (its compile provenance). Returned newest-last among the + kept window; reads never trigger anything. The result is + recent-first bounded by `limit` (default + `DEFAULT_TRANSCRIPT_LIMIT`): when more history exists, the oldest + rows drop and the envelope's truncation marker is set so callers + see the cap rather than receiving everything (S18). An empty + history is `known_empty`, not a guess; an unknown kind is a + `boundary` naming the four that exist. """ + if limit < 1: + raise ValueError("limit must be at least 1") statement = _TRANSCRIPT_BY_KIND.get(subject_kind) if statement is None: return Envelope( @@ -447,7 +466,7 @@ def transcript( ), ) with self._engine.connect() as connection: - rows = ( + rows = list( connection.execute( statement, {"deployment_id": deployment_id, "subject_id": subject_id}, @@ -455,12 +474,24 @@ def transcript( .mappings() .all() ) + total = len(rows) + truncated = total > limit + # SQL orders oldest→newest (newest-last). Keep the most recent + # `limit` rows so a long entity log still answers "what happened + # lately" without flooding the reader context. + kept = rows[-limit:] if truncated else rows return Envelope( grain=Grain.COMPOSITE, - transcript=tuple(TranscriptEntry.model_validate(dict(row)) for row in rows), + transcript=tuple(TranscriptEntry.model_validate(dict(row)) for row in kept), freshness=_freshness(), + truncation=Truncation( + truncated=truncated, + returned=len(kept), + estimated_total=total, + total_is_exact=True, + ), negative=None - if rows + if kept else Negative( kind=NegativeKind.KNOWN_EMPTY, explanation=f"no decision history for this {subject_kind}", @@ -1264,26 +1295,33 @@ def _co_member(row: dict[str, object]) -> CoMember: -- its mentions resolved (resolution_decisions) and every merge it took -- part in (merge_events), newest-last across both. related_id is the -- COUNTERPART entity of a merge (never the subject); a reversed merge is - -- an unmerge. - SELECT 'entity' AS subject_kind, - CASE WHEN is_new_entity THEN 'new_entity' ELSE 'linked' END AS outcome, - method::text AS method, confidence, - mention_id AS related_id, decided_by::text AS decided_by, - decided_at, features - FROM resolution_decisions - WHERE deployment_id = :deployment_id AND entity_id = :subject_id - UNION ALL - SELECT 'entity' AS subject_kind, - CASE WHEN reversed_by IS NOT NULL THEN 'unmerge' ELSE 'merge' END - AS outcome, - 'merge_event' AS method, NULL::real AS confidence, - CASE WHEN survivor_id = :subject_id THEN absorbed_id - ELSE survivor_id END AS related_id, - decided_by::text AS decided_by, decided_at, evidence AS features - FROM merge_events - WHERE deployment_id = :deployment_id - AND (survivor_id = :subject_id OR absorbed_id = :subject_id) - ORDER BY decided_at + -- an unmerge. The per-arm primary key breaks decided_at ties so the + -- recent-first truncation boundary is deterministic under batch inserts. + SELECT subject_kind, outcome, method, confidence, related_id, + decided_by, decided_at, features + FROM ( + SELECT 'entity' AS subject_kind, + CASE WHEN is_new_entity THEN 'new_entity' ELSE 'linked' END + AS outcome, + method::text AS method, confidence, + mention_id AS related_id, decided_by::text AS decided_by, + decided_at, features, decision_id AS event_id + FROM resolution_decisions + WHERE deployment_id = :deployment_id AND entity_id = :subject_id + UNION ALL + SELECT 'entity' AS subject_kind, + CASE WHEN reversed_by IS NOT NULL THEN 'unmerge' ELSE 'merge' END + AS outcome, + 'merge_event' AS method, NULL::real AS confidence, + CASE WHEN survivor_id = :subject_id THEN absorbed_id + ELSE survivor_id END AS related_id, + decided_by::text AS decided_by, decided_at, + evidence AS features, merge_id AS event_id + FROM merge_events + WHERE deployment_id = :deployment_id + AND (survivor_id = :subject_id OR absorbed_id = :subject_id) + ) braided + ORDER BY decided_at, event_id """ ) diff --git a/src/rememberstack/workers/e0.py b/src/rememberstack/workers/e0.py index 551e3c95..74653a26 100644 --- a/src/rememberstack/workers/e0.py +++ b/src/rememberstack/workers/e0.py @@ -67,8 +67,9 @@ E0_CONVERT_VERSION: Final = "e0-convert-2026.07" """The convert sub-worker's component version (D12 idempotency key member).""" -E0_STRUCTURE_VERSION: Final = "e0-structure-2026.07b:pageindex-snap-1" -"""The structure stage's component version (D39): LLM route + snap algorithm.""" +E0_STRUCTURE_VERSION: Final = "e0-structure-2026.07c:temp0-1" +"""The structure stage's component version (D39): LLM route + snap algorithm. +07c pins temperature=0.0 — generation parameters are part of provenance.""" UPLOAD_SOURCE_KIND: Final = "upload" """The one-shot upload connector's source kind (D55 lineage identity).""" @@ -529,7 +530,9 @@ def _propose( ) try: generated = self._model_provider.generate( - request=ModelRequest(model=self._settings.model, prompt=prompt), + request=ModelRequest( + model=self._settings.model, prompt=prompt, temperature=0.0 + ), response_type=StructureResponse, ) except ProviderAccountingError: diff --git a/src/rememberstack/workers/e1.py b/src/rememberstack/workers/e1.py index 6a02b362..19473144 100644 --- a/src/rememberstack/workers/e1.py +++ b/src/rememberstack/workers/e1.py @@ -50,12 +50,14 @@ E1_EMBED_VERSION: Final = "e1-embed-2026.07" """The embed stage's component version (model identity rides settings/stamps).""" -E1_PREFIXER_VERSION: Final = "e1-prefix-2026.07" -"""The context-prefix call's prompt generation (D58; conventional mode, D63).""" +E1_PREFIXER_VERSION: Final = "e1-prefix-2026.07b:temp0-1" +"""The context-prefix call's prompt generation (D58; conventional mode, D63). +07b pins temperature=0.0 — generation parameters are part of provenance.""" -E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07c:claim-valid-time-1" +E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07d:temp0-1" """The extractor generation baked into extraction_input_hash (D56); the E2 -stage (WP-1.3) binds its handler to this same constant.""" +stage (WP-1.3) binds its handler to this same constant. 07d pins +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 — " @@ -321,7 +323,9 @@ def _resolve_prefix( head=head, ) response = self._model_provider.generate( - request=ModelRequest(model=self._settings.prefix_model, prompt=prompt), + request=ModelRequest( + model=self._settings.prefix_model, prompt=prompt, temperature=0.0 + ), response_type=ContextPrefix, ) meter.record( diff --git a/src/rememberstack/workers/e2.py b/src/rememberstack/workers/e2.py index c752adfe..29d25f8c 100644 --- a/src/rememberstack/workers/e2.py +++ b/src/rememberstack/workers/e2.py @@ -207,6 +207,7 @@ def _extract_chunk( request=ModelRequest( model=self._settings.extract_model, prompt=_SELECTION_PROMPT.format(outcomes=_OUTCOMES, bundle=bundle), + temperature=0.0, ), response_type=SelectionResponse, ) @@ -241,6 +242,7 @@ def _extract_chunk( keeps="\n".join(f"- {keep.source_span}" for keep in keeps), bundle=bundle, ), + temperature=0.0, ), response_type=ClaimifyResponse, ) diff --git a/src/rememberstack/workers/e3.py b/src/rememberstack/workers/e3.py index 1ba3e061..6aa8ab8d 100644 --- a/src/rememberstack/workers/e3.py +++ b/src/rememberstack/workers/e3.py @@ -45,8 +45,9 @@ _OTHER_PREDICATE: Final = OTHER_PREDICATE_GRAMMAR """The escape-value routing check (the spine re-validates authoritatively).""" -E3_NORMALIZER_VERSION: Final = "e3-normalize-2026.07" -"""The normalize sub-worker's component version (D12 idempotency member).""" +E3_NORMALIZER_VERSION: Final = "e3-normalize-2026.07b:temp0-1" +"""The normalize sub-worker's component version (D12 idempotency member). +07b pins temperature=0.0 — generation parameters are part of provenance.""" _NORMALIZE_PROMPT: Final = """You are the normalizer of a memory system. Turn the CLAIM into zero or more of: @@ -229,6 +230,7 @@ def _normalize_claim( is_attributed=claim.is_attributed, claim_text=claim.claim_text, ), + temperature=0.0, ), response_type=NormalizationResponse, ) diff --git a/src/rememberstack/workers/p1.py b/src/rememberstack/workers/p1.py index b0b77913..7f27db3e 100644 --- a/src/rememberstack/workers/p1.py +++ b/src/rememberstack/workers/p1.py @@ -33,8 +33,9 @@ P1_EMBED_CLAIMS_VERSION: Final = "p1-embed-claims-2026.07" """The claim-embed stage's component version (the model rides settings).""" -FACT_LABEL_VERSION: Final = "p1-fact-label-2026.07" -"""The fact-labeler prompt generation (regenerated only on version bump).""" +FACT_LABEL_VERSION: Final = "p1-fact-label-2026.07b:temp0-1" +"""The fact-labeler prompt generation (regenerated only on version bump). +07b pins temperature=0.0 — generation parameters are part of provenance.""" _FACT_LABEL_PROMPT: Final = ( "Write one short natural sentence stating this fact, nothing else: " @@ -161,6 +162,7 @@ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome: predicate=relation.predicate, object=relation.object_name, ), + temperature=0.0, ), response_type=FactLabelResponse, ) diff --git a/src/rememberstack/workers/p2_analytics.py b/src/rememberstack/workers/p2_analytics.py index 8655bc8f..8de3071a 100644 --- a/src/rememberstack/workers/p2_analytics.py +++ b/src/rememberstack/workers/p2_analytics.py @@ -32,9 +32,11 @@ from rememberstack.ports.model_provider import ModelProviderPort from rememberstack.spine.projection import ProjectionCatalog -COMMUNITY_DETECTOR_VERSION: Final = "p2-communities-2026.07:louvain-native" +COMMUNITY_DETECTOR_VERSION: Final = "p2-communities-2026.07b:louvain-native-temp0" """The analytics pass's component version (D12); the algorithm is part of -the identity because a detector swap changes every assignment.""" +the identity because a detector swap changes every assignment. 07b pins the +label call's temperature=0.0 — labels are navigation-only, but identical +builds should not wander.""" _PROJECTION: Final = "analytics_graph" _LABEL_MEMBERS: Final = 8 @@ -223,6 +225,7 @@ def _labels( request=ModelRequest( model=self._settings.label_model, prompt=_LABEL_PROMPT.format(clusters="\n".join(blocks)), + temperature=0.0, ), response_type=CommunityLabels, ) diff --git a/src/tests/adapters/test_openrouter.py b/src/tests/adapters/test_openrouter.py index 4465352a..7d131c6b 100644 --- a/src/tests/adapters/test_openrouter.py +++ b/src/tests/adapters/test_openrouter.py @@ -5,6 +5,7 @@ from pydantic import BaseModel from pydantic import Field +from pydantic import ValidationError import pytest from rememberstack.adapters import OpenRouterModelProvider @@ -134,6 +135,126 @@ def post(*, path: str, payload: dict[str, object]) -> dict[str, object]: assert observed.get("reasoning") == expected +def test_generation_per_model_reasoning_effort_map_overrides_global( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#155: a model's map entry wins over the global effort pin.""" + provider = OpenRouterModelProvider( + settings=OpenRouterSettings.model_validate( + { + "api_key": "test-key", + "reasoning_effort": "high", + "reasoning_effort_map": { + "z-ai/glm-4.7-flash": "none", + "openai/gpt-5.6-luna": "high", + }, + } + ) + ) + observed: list[dict[str, object]] = [] + + def post(*, path: str, payload: dict[str, object]) -> dict[str, object]: + observed.append(dict(payload)) + assert path == "/chat/completions" + return { + "model": str(payload["model"]), + "usage": {"prompt_tokens": 3, "completion_tokens": 1, "cost": "0"}, + "choices": [{"message": {"content": '{"answer":"Prague"}'}}], + } + + monkeypatch.setattr(provider, "_post", post) + try: + for model in ("z-ai/glm-4.7-flash", "openai/gpt-5.6-luna", "other/model"): + provider.generate( + request=ModelRequest(model=model, prompt="Where?"), + response_type=_Answer, + ) + finally: + provider._client.close() + + assert [item.get("reasoning") for item in observed] == [ + {"effort": "none"}, # map override + {"effort": "high"}, # map entry equals global but still explicit + {"effort": "high"}, # absent from map → global fallback + ] + + +def test_generation_per_model_map_falls_back_when_global_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Absent map entry with no global pin leaves reasoning unset (model default).""" + provider = OpenRouterModelProvider( + settings=OpenRouterSettings.model_validate( + { + "api_key": "test-key", + "reasoning_effort_map": {"z-ai/glm-4.7-flash": "none"}, + } + ) + ) + observed: dict[str, object] = {} + + def post(*, path: str, payload: dict[str, object]) -> dict[str, object]: + observed.update(payload) + return { + "model": "other/model", + "usage": {"prompt_tokens": 3, "completion_tokens": 1, "cost": "0"}, + "choices": [{"message": {"content": '{"answer":"Prague"}'}}], + } + + monkeypatch.setattr(provider, "_post", post) + try: + provider.generate( + request=ModelRequest(model="other/model", prompt="Where?"), + response_type=_Answer, + ) + finally: + provider._client.close() + + assert "reasoning" not in observed + + +def test_reasoning_effort_map_rejects_invalid_effort_values() -> None: + """Map values must be one of the allowed effort literals — and the error + is OUR message, not merely pydantic's extra_forbidden for an unknown field + (which would also raise if the feature were reverted wholesale).""" + with pytest.raises(ValidationError, match="not an allowed effort"): + OpenRouterSettings.model_validate( + { + "api_key": "test-key", + "reasoning_effort_map": {"z-ai/glm-4.7-flash": "ludicrous"}, + } + ) + + +def test_reasoning_effort_map_rejects_malformed_env_values() -> None: + """Invalid JSON, non-object JSON, and empty keys each fail loudly.""" + for bad in ("{not json", '["none"]', '{"": "none"}'): + with pytest.raises(ValidationError, match="reasoning_effort_map"): + OpenRouterSettings.model_validate( + {"api_key": "test-key", "reasoning_effort_map": bad} + ) + + +def test_reasoning_effort_map_empty_string_env_means_unset() -> None: + """Compose passes empty strings for unset optionals; that is None, not an + error and not an empty mapping.""" + settings = OpenRouterSettings.model_validate( + {"api_key": "test-key", "reasoning_effort_map": " "} + ) + assert settings.reasoning_effort_map is None + + +def test_reasoning_effort_map_parses_json_env_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The env form is a JSON object (Compose-friendly), not a Python dict.""" + monkeypatch.setenv( + "REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP", '{"z-ai/glm-4.7-flash":"none"}' + ) + settings = OpenRouterSettings.model_validate({"api_key": "test-key"}) + assert settings.reasoning_effort_map == {"z-ai/glm-4.7-flash": "none"} + + def test_generation_uses_strict_schema_for_defaulted_response_fields( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/tests/benchmarks/test_locomo_protocol.py b/src/tests/benchmarks/test_locomo_protocol.py index 8130681e..779b5105 100644 --- a/src/tests/benchmarks/test_locomo_protocol.py +++ b/src/tests/benchmarks/test_locomo_protocol.py @@ -131,9 +131,9 @@ def test_frozen_tool_catalog_hash_matches_stock_full_system_recipes() -> None: ) -def test_protocol_is_v4_and_answer_prompt_has_loop_guards() -> None: +def test_protocol_is_v5_and_answer_prompt_has_loop_guards() -> None: """v4 fingerprint: protocol name plus the answer-loop discipline lines.""" - assert PROTOCOL_NAME == "RS-LoCoMo-Full-v4" + assert PROTOCOL_NAME == "RS-LoCoMo-Full-v5" prompt = ANSWER_AGENT_PROMPT_TEMPLATE assert "never repeat a tool call with the same tool AND the same" in prompt assert "switch tools rather than retrying" in prompt diff --git a/src/tests/conftest.py b/src/tests/conftest.py index a7e134f1..b651b7c1 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -25,6 +25,7 @@ _LEAKY_OPTIONAL_PINS = ( "REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER", "REMEMBERSTACK_OPENROUTER_REASONING_EFFORT", + "REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP", ) diff --git a/src/tests/surfaces/test_recipe_registry.py b/src/tests/surfaces/test_recipe_registry.py index c95566bf..c26d7e37 100644 --- a/src/tests/surfaces/test_recipe_registry.py +++ b/src/tests/surfaces/test_recipe_registry.py @@ -375,6 +375,40 @@ def test_seeding_upgrades_a_changed_recipe_instead_of_masking_it( } +def test_identity_as_of_v3_supersedes_a_persisted_v2_descriptor( + corpus: _Corpus, +) -> None: + """ON CONFLICT DO NOTHING must not pin deployments to the pre-truncation + descriptor: re-seeding over a persisted v2 row serves v3 (issue #156).""" + registry = RecipeRegistry(engine=corpus.engine) + current = next( + recipe for recipe in CANONICAL_RECIPES if recipe.name == "identity_as_of" + ) + registry.register( + deployment_id=_DEPLOYMENT_ID, + recipe=current.model_copy( + update={ + "version": 2, + "description": "An entity's identity history — how its mentions" + " resolved and every merge it took part in (S61).", + "parameters": {"entity_id": {"type": "uuid", "required": True}}, + } + ), + ) + + seed_canonical_recipes(registry=registry, deployment_id=_DEPLOYMENT_ID) + + active = registry.by_name(deployment_id=_DEPLOYMENT_ID, name="identity_as_of") + assert active is not None + assert active.version == 3 + assert "truncation" in active.description + assert active.parameters["limit"] == { + "type": "integer", + "required": False, + "minimum": 1, + } + + # --- a recipe ≡ its chain -------------------------------------------------- diff --git a/src/tests/surfaces/test_retrieval_primitives.py b/src/tests/surfaces/test_retrieval_primitives.py index d1f1f965..5b968103 100644 --- a/src/tests/surfaces/test_retrieval_primitives.py +++ b/src/tests/surfaces/test_retrieval_primitives.py @@ -675,6 +675,11 @@ def test_transcript_entity_braids_resolution_and_merge(corpus: _Corpus) -> None: outcomes = [entry.outcome for entry in answer.transcript] assert outcomes == ["linked", "merge"] # ordered by decided_at assert answer.transcript[-1].related_id == corpus.ids["A. Nowak"] + assert answer.truncation is not None + assert answer.truncation.truncated is False + assert answer.truncation.returned == 2 + assert answer.truncation.estimated_total == 2 + assert answer.truncation.total_is_exact is True def test_transcript_unknown_kind_is_boundary(corpus: _Corpus) -> None: @@ -697,6 +702,65 @@ def test_transcript_of_a_subject_with_no_history_is_known_empty( assert answer.negative.kind is NegativeKind.KNOWN_EMPTY +def test_transcript_recent_first_bound_signals_truncation(corpus: _Corpus) -> None: + """#156: over-cap entity history keeps the newest rows and discloses the cut. + + An unbounded identity transcript once returned ~310 rows / ~75KB for one + entity and five calls blew a 128k reader context. The default bound is a + measurement starting point; the envelope must never be silent about it + (S18). + """ + from datetime import timedelta + + from rememberstack.surfaces.query_engine import DEFAULT_TRANSCRIPT_LIMIT + + entity_id = corpus.ids["Bob"] + over_cap = DEFAULT_TRANSCRIPT_LIMIT + 7 + origin = datetime(2026, 1, 1, tzinfo=UTC) + with corpus.engine.begin() as connection: + for index in range(over_cap): + connection.execute( + text( + "INSERT INTO resolution_decisions (decision_id, deployment_id," + " mention_id, entity_id, method, confidence, is_new_entity," + " resolver_version, decided_by, decided_at)" + " VALUES (:x, :d, :m, :e, 'T3', 0.8, false, 'toy', 'auto', :at)" + ), + { + "x": uuid4(), + "d": _DEPLOYMENT_ID, + "m": uuid4(), + "e": entity_id, + "at": origin + timedelta(hours=index), + }, + ) + + answer = _engine(corpus).transcript( + deployment_id=_DEPLOYMENT_ID, subject_kind="entity", subject_id=entity_id + ) + assert answer.truncation is not None + assert answer.truncation.truncated is True + assert answer.truncation.returned == DEFAULT_TRANSCRIPT_LIMIT + assert answer.truncation.estimated_total == over_cap + assert answer.truncation.total_is_exact is True + assert len(answer.transcript) == DEFAULT_TRANSCRIPT_LIMIT + # kept window is newest-last among the recent rows, not the oldest tail + times = [entry.decided_at for entry in answer.transcript if entry.decided_at] + assert times == sorted(times) + assert times[0] == origin + timedelta(hours=7) + assert times[-1] == origin + timedelta(hours=over_cap - 1) + + full = _engine(corpus).transcript( + deployment_id=_DEPLOYMENT_ID, + subject_kind="entity", + subject_id=entity_id, + limit=over_cap, + ) + assert full.truncation is not None + assert full.truncation.truncated is False + assert len(full.transcript) == over_cap + + # --- delta: the change feed ------------------------------------------------ diff --git a/src/tests/workers/test_e2_chain.py b/src/tests/workers/test_e2_chain.py index 1154771e..445e8e98 100644 --- a/src/tests/workers/test_e2_chain.py +++ b/src/tests/workers/test_e2_chain.py @@ -368,6 +368,13 @@ def test_claims_land_grounded_with_drops_ledgered_and_stance_kept(rig: _E2Rig) - ] assert all(call["model_name"] and call["tokens_in"] > 0 for call in metered_calls) assert {call["tier"] for call in metered_calls} == {"decontextualize", "selection"} + # Extraction is a measurement surface (#154): selection + claimify must pin + # temperature=0.0 so identical bindings do not wander (gold-span coverage + # previously varied 7/8 → 4/8 across runs at the provider default). + assert rig.provider.generated_requests, "expected Claimify model calls" + assert all( + request.temperature == 0.0 for request in rig.provider.generated_requests + ) assert all(call["cost_usd"] == 0 for call in metered_calls) diff --git a/src/tests/workers/test_extraction_temperature.py b/src/tests/workers/test_extraction_temperature.py new file mode 100644 index 00000000..ff6d6499 --- /dev/null +++ b/src/tests/workers/test_extraction_temperature.py @@ -0,0 +1,82 @@ +"""#154: extraction-class model calls pin temperature=0.0 (measurement surface).""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[3] +_SCANNED_TREES = ( + _ROOT / "src/rememberstack/workers", + _ROOT / "src/rememberstack/spine", +) +"""Every module under workers/ and spine/ is scanned — a new extraction-class +module is covered the day it appears, without editing this list. Benchmarks' +answer-agent / judge calls pin temperature separately; eval/ is a consumption +harness, not extraction.""" + + +def _extraction_modules() -> list[Path]: + """Every python module under the scanned trees, discovered, not listed.""" + return sorted(path for tree in _SCANNED_TREES for path in tree.rglob("*.py")) + + +def _model_request_temperatures(*, path: Path) -> list[float | None]: + """Return the temperature keyword value for each ModelRequest(...) call. + + Matches both the bare name and any attribute-qualified spelling + (`model.ModelRequest(...)`) so an import alias cannot dodge the scan. + """ + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + temperatures: list[float | None] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + callee = node.func + name = ( + callee.id + if isinstance(callee, ast.Name) + else callee.attr + if isinstance(callee, ast.Attribute) + else None + ) + if name != "ModelRequest": + continue + temperature: float | None = None + for keyword in node.keywords: + if keyword.arg != "temperature": + continue + if not isinstance(keyword.value, ast.Constant): + raise AssertionError( + f"{path}: ModelRequest temperature must be a constant, got" + f" {ast.dump(keyword.value)}" + ) + value = keyword.value.value + if not isinstance(value, (int, float)): + raise AssertionError( + f"{path}: ModelRequest temperature must be numeric, got {value!r}" + ) + temperature = float(value) + temperatures.append(temperature) + return temperatures + + +def test_extraction_model_request_sites_pin_temperature_zero() -> None: + """Structurer, prefix, Claimify, normalize, labels, adjudicators: temp=0.0. + + Extraction is measurement, not creativity. Leaving temperature unset left + gold-span coverage wandering 7/8 → 4/8 across identical-binding runs + (issue #154). This AST check fails closed if a new extraction-class + ModelRequest is added without the pin. + """ + modules = _extraction_modules() + assert len(modules) >= 8, "workers/ and spine/ trees moved — fix the scan roots" + seen = 0 + for path in modules: + for temperature in _model_request_temperatures(path=path): + assert temperature == 0.0, ( + f"{path}: ModelRequest temperature={temperature!r}, expected 0.0" + ) + seen += 1 + # Guard against vacuous green: the known extraction surface has many calls. + assert seen >= 15