diff --git a/chat/runtime.py b/chat/runtime.py index 9fb17bf0..d18acac2 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -45,10 +45,13 @@ from chat.telemetry import ( from chat.verdicts import TurnVerdicts from chat.dispatch_trace import DispatchAttempt, DispatchTrace from teaching.discovery import ( + DiscoveryCandidate, extract_discovery_candidates, format_candidate_jsonl, ) from teaching.discovery_sink import DiscoveryCandidateSink +from engine_state import EngineStateStore +from recognition.registry import RecognizerRegistry from core.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig from core.physics.drive import DriveGradientMap, GradientField from core.physics.energy import EnergyClass, EnergyProfile @@ -466,6 +469,8 @@ class ChatRuntime: *, frame_pack: str | None = None, config: RuntimeConfig = DEFAULT_CONFIG, + no_load_state: bool = False, + engine_state_path: Any | None = None, ) -> None: if pack_id is not None or frame_pack is not None: pack_ids = (pack_id,) if isinstance(pack_id, str) else tuple(pack_id or config.input_packs) @@ -615,6 +620,38 @@ class ChatRuntime: # W-013 — last classified intent, updated each turn for /explain REPL use. self._last_intent: Any | None = None self._last_input_text: str = "" + self._engine_state_store: EngineStateStore | None = ( + None if no_load_state else EngineStateStore(engine_state_path) + ) + self._recognizer_registry: RecognizerRegistry = RecognizerRegistry() + self._turn_count: int = 0 + self._pending_candidates: list[DiscoveryCandidate] = [] + if self._engine_state_store is not None and self._engine_state_store.exists(): + self._load_engine_state() + + def _load_engine_state(self) -> None: + store = self._engine_state_store + if store is None: + return + self._recognizer_registry = RecognizerRegistry.from_recognizers( + store.load_recognizers() + ) + self._pending_candidates = store.load_discovery_candidates() + manifest = store.load_manifest() or {} + self._turn_count = int(manifest.get("turn_count", 0)) + + def checkpoint_engine_state(self) -> None: + store = self._engine_state_store + if store is None: + return + store.save_recognizers(self._recognizer_registry.all()) + store.save_discovery_candidates(self._pending_candidates) + store.save_manifest(self._turn_count) + + def _checkpointed_response(self, response: ChatResponse) -> ChatResponse: + self._turn_count += 1 + self.checkpoint_engine_state() + return response @property def session(self) -> SessionContext: @@ -797,9 +834,6 @@ class ChatRuntime: intent_subject: str | None, grounding_source: str | None, ) -> None: - sink = self._discovery_sink - if sink is None: - return candidates = extract_discovery_candidates( turn_event, intent_tag, @@ -816,6 +850,10 @@ class ChatRuntime: candidates = tuple( contemplate(c, vault_probe=vault_probe) for c in candidates ) + self._pending_candidates.extend(candidates) + sink = self._discovery_sink + if sink is None: + return for candidate in candidates: sink.emit(format_candidate_jsonl(candidate)) @@ -1793,17 +1831,19 @@ class ChatRuntime: text, stub_articulation, ) - return self._stub_response( - committed, - tokens=tuple(filtered), - pack_grounded_surface=pack_surface, - grounded_source_tag=pack_source_tag, - pack_semantic_domains=pack_semantic_domains, - graph_atoms=stub_graph_atoms, - graph_unconstrained=stub_graph_unconstrained, - discovery_intent_tag=discovery_intent_tag, - discovery_intent_subject=discovery_intent_subject, - dispatch_trace=dispatch_trace, + return self._checkpointed_response( + self._stub_response( + committed, + tokens=tuple(filtered), + pack_grounded_surface=pack_surface, + grounded_source_tag=pack_source_tag, + pack_semantic_domains=pack_semantic_domains, + graph_atoms=stub_graph_atoms, + graph_unconstrained=stub_graph_unconstrained, + discovery_intent_tag=discovery_intent_tag, + discovery_intent_subject=discovery_intent_subject, + dispatch_trace=dispatch_trace, + ) ) if self.config.unified_ingest: @@ -1896,7 +1936,9 @@ class ChatRuntime: field_state, tokens=tuple(filtered), ) - return replace(stub, refusal_reason=_exhaustion_exc.reason.value) + return self._checkpointed_response( + replace(stub, refusal_reason=_exhaustion_exc.reason.value) + ) # --- Articulation fidelity: replace bare S-P-O join with intent-aware surface --- # Phase 2: pass proposition so the bridge grounds obj slots @@ -2215,47 +2257,49 @@ class ChatRuntime: grounding_source=main_grounding_source, surface=response_surface, ) - return ChatResponse( - surface=response_surface, - proposition=proposition, - articulation=articulation, - articulation_surface=articulation.surface, - dialogue_role=dialogue_role, - versor_condition=versor_condition(result.final_state.F), - output_language=self.config.output_language, - frame_pack=self.config.frame_pack, - walk_surface=walk_surface, - salience_top_k=result.salience_top_k, - candidates_used=result.candidates_used, - vault_hits=vault_hits, - identity_score=identity_score, - character_profile=self.character_profile, - flagged=flagged, - recall_energy_class=recall_energy_class_main, - admissibility_trace=result.admissibility_trace, - region_was_unconstrained=result.region_was_unconstrained, - safety_verdict=safety_verdict, - ethics_verdict=ethics_verdict, - verdicts=verdicts_bundle, - grounding_source=main_grounding_source, - pre_decoration_surface=pre_decoration_surface_main, - register_id=register_id_main, - register_variant_id=decoration_main.variant_id, - anchor_lens_id=anchor_lens_id_main, - anchor_lens_mode_label=anchor_lens_mode_label_main, - realizer_guard_status=realizer_guard_status_main, - realizer_guard_rule=realizer_guard_rule_main, - register_canonical_surface=register_canonical_surface_main, - composer_graph_atom_status=atom_equivalence_main.status, - composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash, - graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, - composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, - recalled_words=walk_tokens, - epistemic_state=main_epistemic_state, - normative_clearance=main_normative_clearance, - normative_detail=main_normative_detail, - refusal_reason=refusal_surface if refusal_emitted else "", - dispatch_trace=dispatch_trace, + return self._checkpointed_response( + ChatResponse( + surface=response_surface, + proposition=proposition, + articulation=articulation, + articulation_surface=articulation.surface, + dialogue_role=dialogue_role, + versor_condition=versor_condition(result.final_state.F), + output_language=self.config.output_language, + frame_pack=self.config.frame_pack, + walk_surface=walk_surface, + salience_top_k=result.salience_top_k, + candidates_used=result.candidates_used, + vault_hits=vault_hits, + identity_score=identity_score, + character_profile=self.character_profile, + flagged=flagged, + recall_energy_class=recall_energy_class_main, + admissibility_trace=result.admissibility_trace, + region_was_unconstrained=result.region_was_unconstrained, + safety_verdict=safety_verdict, + ethics_verdict=ethics_verdict, + verdicts=verdicts_bundle, + grounding_source=main_grounding_source, + pre_decoration_surface=pre_decoration_surface_main, + register_id=register_id_main, + register_variant_id=decoration_main.variant_id, + anchor_lens_id=anchor_lens_id_main, + anchor_lens_mode_label=anchor_lens_mode_label_main, + realizer_guard_status=realizer_guard_status_main, + realizer_guard_rule=realizer_guard_rule_main, + register_canonical_surface=register_canonical_surface_main, + composer_graph_atom_status=atom_equivalence_main.status, + composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash, + graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash, + composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count, + recalled_words=walk_tokens, + epistemic_state=main_epistemic_state, + normative_clearance=main_normative_clearance, + normative_detail=main_normative_detail, + refusal_reason=refusal_surface if refusal_emitted else "", + dispatch_trace=dispatch_trace, + ) ) def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse: diff --git a/core/cli.py b/core/cli.py index ccf8524c..dc0ba2ae 100644 --- a/core/cli.py +++ b/core/cli.py @@ -223,7 +223,10 @@ def cmd_chat(args: argparse.Namespace) -> int: _print_runtime_import_hint(exc) try: - runtime = ChatRuntime(config=_runtime_config_from_args(args)) + runtime = ChatRuntime( + config=_runtime_config_from_args(args), + no_load_state=bool(getattr(args, "no_load_state", False)), + ) except Exception as exc: # noqa: BLE001 — surface pack-load errors from packs.anchor_lens.loader import AnchorLensError from packs.register.loader import RegisterPackError @@ -3186,6 +3189,12 @@ def build_parser() -> argparse.ArgumentParser: "stderr (ADR-0041 operator-facing audit readout)" ), ) + chat.add_argument( + "--no-load-state", + action="store_true", + default=False, + help="start with a clean engine state, ignoring any existing engine_state/ checkpoint", + ) chat.add_argument( "--register", metavar="REGISTER_ID", diff --git a/docs/decisions/ADR-0146-l10-hybrid-engine-state-persistence.md b/docs/decisions/ADR-0146-l10-hybrid-engine-state-persistence.md new file mode 100644 index 00000000..76eb2769 --- /dev/null +++ b/docs/decisions/ADR-0146-l10-hybrid-engine-state-persistence.md @@ -0,0 +1,127 @@ +# ADR-0146: L10 Shape B Hybrid Engine-State Persistence + +**Status:** Accepted +**Date:** 2026-05-25 +**Scope doc:** [L10-runtime-model-scope](./L10-runtime-model-scope.md) +**Related:** ADR-0055 (inter-session memory), ADR-0040 (telemetry), ADR-0057 (proposals), W-008, W-003, W-007, W-009, W-017, W-018. + +--- + +## Context + +CORE's runtime has historically been session-bounded: every `core` CLI invocation builds a fresh `ChatRuntime` instance, loading packs and teaching corpora anew, while session-state is lost. To realize the vision of a forever-running cognitive engine that accumulates capability over its lifetime, surviving reboots as recovery rather than control flow, CORE requires a defined process and persistence model. + +The [L10-runtime-model-scope](./L10-runtime-model-scope.md) evaluated three candidate process shapes: +- **Shape A (Long-lived daemon):** A single persistent daemon process running `cmd_serve`, where CLI invocations act as IPC clients. +- **Shape B (Hybrid state externalized; CLI restores it):** Engine-state is checkpointed to disk at logical action boundaries, and CLI invocations load and resume this checkpoint. +- **Shape C (One-shot CLI with audit trail reconstruction):** Every invocation builds state from scratch by replaying the entire append-only audit trail (telemetry JSONL) from inception. + +### Candidate Evaluation and Rationale + +- **Shape B (Selected)** is chosen because: + - It maintains **library-session compatibility** without requiring a background daemon process to be running on the host system. + - Startup cost is bounded to $O(\text{checkpoint size})$ rather than $O(\text{audit trail size})$, which ensures high performance as the transaction history grows. + - Approximately 80% of the underlying persistence infrastructure (packs, telemetry, corpus) is already written to disk. + - High-value engine-state objects, such as `DerivedRecognizer`, are already serializable (via `DerivedRecognizer.to_json() / from_json()`). +- **Shape A (Rejected)** is rejected because a background daemon process cannot survive host library-session interruptions (such as IDE reloads or parent process terminations) without complex process supervision infrastructure. +- **Shape C (Rejected)** is rejected because the $O(N)$ rebuild cost to replay the entire audit trail grows without bound over time, violating the performance and efficiency doctrines. + +--- + +## Decision + +Adopt **Shape B: Hybrid engine-state persistence**. + +At every logical-action boundary (specifically, at the turn boundary in `ChatRuntime.chat()`), the current engine-state is serialized and checkpointed to an `engine_state/` directory in the repository root (or the path specified by the `CORE_ENGINE_STATE_DIR` environment variable). Any subsequent CLI invocation loads this checkpoint, restoring `RecognizerRegistry` and the `DiscoveryCandidate` working set, and continues. + +Session-state remains ephemeral and is discarded upon turn completion or process exit. + +--- + +## State Class Assignments + +The runtime state is partitioned into four distinct classes: + +| State class | Objects | Persistence | +| :--- | :--- | :--- | +| **Session-state** | `session_thread`, current intent, field excitation | Ephemeral — lost on reboot / process exit, no concern. | +| **Engine-state** | `RecognizerRegistry`, `DiscoveryCandidate` working set | Persistent — written to `engine_state/recognizers.jsonl` and `engine_state/discovery_candidates.jsonl` on turn boundaries. | +| **Substrate-state** | Ratified packs, teaching corpus, telemetry JSONL, proposal log | Persistent — already on disk; append-only and immutable without operator intervention. | +| **T1 vault** | `VaultStore` (in-memory deque) | Ephemeral — intentionally ephemeral per ADR-0055 T1; promoted to T3 via HITL. | + +--- + +## `engine_state/` Directory Specification + +The checkpoint directory is structured as follows: + +```text +engine_state/ + ├── manifest.json + ├── recognizers.jsonl + └── discovery_candidates.jsonl +``` + +- **`engine_state/recognizers.jsonl`**: One JSON line per registered recognizer, serialized using `DerivedRecognizer.to_json()`. +- **`engine_state/discovery_candidates.jsonl`**: One JSON line per pending candidate, serialized using `DiscoveryCandidate.as_dict()`. Note that while `as_dict()` is already implemented, a corresponding `from_dict()` (or load path) will be implemented to deserialize candidates. +- **`engine_state/manifest.json`**: Metadata schema pinning correctness: + ```json + { + "schema_version": 1, + "written_at_revision": "", + "turn_count": N + } + ``` + +### File Operations and Invariants: +- The `engine_state/` directory is created on the first checkpoint. A missing directory represents a clean-slate start and must not raise an error. +- Unlike substrate-state (which is append-only), **engine-state files are mutable and overwritten** during each checkpoint to reflect the current active working state. +- Checkpointing must be atomic (e.g., write to temporary file and rename) to prevent corruption if the process is terminated mid-write. + +--- + +## Checkpoint Protocol + +The `ChatRuntime` class manages the lifecycle of the engine-state checkpoint: + +1. **`ChatRuntime.checkpoint_engine_state(path: Path)`**: Called at the turn boundary after a turn completes, but *before* the response is returned to the caller. This serializes and overwrites the files in the target directory. +2. **`ChatRuntime.load_engine_state(path: Path)`**: Called within `ChatRuntime.__init__` at startup if the `engine_state/` directory exists and the `--no-load-state` CLI flag is not set. +3. **`--no-load-state` CLI Flag**: An opt-out flag for debugging, testing, or executing clean-slate runs. When set, `load_engine_state` is bypassed. + +--- + +## Determinism Guarantee + +To preserve the non-negotiable byte-identical replay contract: +- Engine state files must be written using canonical JSON serialization: `sort_keys=True`, and tight separators `separators=(",", ":")` with `ensure_ascii=False`. +- **Round-Trip Invariant:** Loading a checkpoint and immediately re-saving it must produce byte-identical files on disk. Unit and integration tests must pin this round-trip invariant to prevent serialization drift. + +--- + +## What is NOT in Scope + +To maintain a narrow and robust focus, the following items are explicitly excluded from this design: +- **VaultStore persistence:** `VaultStore` remains an ephemeral T1 memory layer per ADR-0055. Permanent memory resides in the T3 teaching corpus and is promoted only via HITL. +- **Concurrency control:** Since Shape B is single-process and synchronous, cross-process file locking, daemon synchronization, and signal handling are out of scope. +- **Network surfaces:** The engine remains strictly local-only; no TCP/HTTP servers or sockets are added to support persistence. +- **Multi-tenancy/multi-instance:** A single repository supports exactly one active engine state checkpoint. +- **Re-architecting `ChatRuntime`:** The unit of execution is unchanged; `ChatRuntime` merely gains load/save hook methods. + +--- + +## Unlocks + +Establishing this hybrid persistence model directly unlocks the following ratchet tasks: +- **W-003 (`VaultPromotionPolicy` wiring):** The timing for when the active field state crystallizes and promotes candidates is now defined by the turn-boundary checkpoint. +- **W-007 (DerivedRecognizer integration):** Provides the persistent `RecognizerRegistry` slot that preserves active recognizers across turns. +- **W-009 (HITL async queue):** The pending `DiscoveryCandidate` working set on disk acts as the async queue state, allowing the operator to review candidates asynchronously. +- **W-017 / W-018:** Enables autonomous contemplation and automated memory promotion pipelines to check and update persistence boundaries safely. + +--- + +## Risks and Mitigations + +- **Serialization Drift:** A stale serializer or added fields on `DerivedRecognizer` or `DiscoveryCandidate` could break reload compatibility. + - *Mitigation:* Pin round-trip serialization in unit tests. Verify that schema updates include migrations or clear-slate fallbacks. +- **Stale Checkpoint after Pack Mutation:** If a user checks out a different git revision or modifies packs, the loaded checkpoint might refer to invalid types or mismatching revisions. + - *Mitigation:* Compare `written_at_revision` in `manifest.json` with the current git SHA. If they mismatch, log a warning but continue startup (do not refuse to start, as a reboot is recovery, not control flow). diff --git a/engine_state/__init__.py b/engine_state/__init__.py new file mode 100644 index 00000000..dbb93b9f --- /dev/null +++ b/engine_state/__init__.py @@ -0,0 +1,118 @@ +"""Shape B engine-state persistence (ADR-0146). + +engine_state/ is the mutable checkpoint directory for per-session engine +state that must survive reboot. It is NOT append-only (unlike substrate- +state); each checkpoint overwrites the previous. + +Layout: + engine_state/recognizers.jsonl -- one DerivedRecognizer per line + engine_state/discovery_candidates.jsonl -- one DiscoveryCandidate per line + engine_state/manifest.json -- schema_version, git revision, turn_count +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Sequence + +from recognition.anti_unifier import DerivedRecognizer +from teaching.discovery import DiscoveryCandidate + +_SCHEMA_VERSION = 1 +_DEFAULT_DIR = ( + Path(os.environ["CORE_ENGINE_STATE_DIR"]) + if os.environ.get("CORE_ENGINE_STATE_DIR") + else Path(__file__).parents[1] / "engine_state" +) + + +def _git_revision() -> str: + try: + return ( + subprocess.run( + ["git", "rev-parse", "--short=12", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + or "unknown" + ) + except Exception: + return "unknown" + + +class EngineStateStore: + def __init__(self, path: Path | None = None) -> None: + self.path = path or _DEFAULT_DIR + + def save_recognizers(self, recognizers: Sequence[DerivedRecognizer]) -> None: + self.path.mkdir(parents=True, exist_ok=True) + lines = [r.to_json() for r in recognizers] + (self.path / "recognizers.jsonl").write_text( + "\n".join(lines) + ("\n" if lines else ""), + encoding="utf-8", + ) + + def load_recognizers(self) -> list[DerivedRecognizer]: + p = self.path / "recognizers.jsonl" + if not p.exists(): + return [] + return [ + DerivedRecognizer.from_json(line) + for line in p.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def save_discovery_candidates( + self, + candidates: Sequence[DiscoveryCandidate], + ) -> None: + self.path.mkdir(parents=True, exist_ok=True) + lines = [ + json.dumps(c.as_dict(), sort_keys=True, separators=(",", ":")) + for c in candidates + ] + (self.path / "discovery_candidates.jsonl").write_text( + "\n".join(lines) + ("\n" if lines else ""), + encoding="utf-8", + ) + + def load_discovery_candidates(self) -> list[DiscoveryCandidate]: + p = self.path / "discovery_candidates.jsonl" + if not p.exists(): + return [] + return [ + DiscoveryCandidate.from_dict(json.loads(line)) + for line in p.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def save_manifest(self, turn_count: int) -> None: + self.path.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": _SCHEMA_VERSION, + "turn_count": turn_count, + "written_at_revision": _git_revision(), + } + (self.path / "manifest.json").write_text( + json.dumps(manifest, sort_keys=True, indent=2), + encoding="utf-8", + ) + + def load_manifest(self) -> dict | None: + p = self.path / "manifest.json" + if not p.exists(): + return None + content = p.read_text(encoding="utf-8").strip() + if not content: + return None + return json.loads(content) + + def exists(self) -> bool: + return (self.path / "manifest.json").exists() + + +__all__ = ["EngineStateStore"] diff --git a/evals/run_cognition_eval.py b/evals/run_cognition_eval.py index 56891694..3ece4009 100644 --- a/evals/run_cognition_eval.py +++ b/evals/run_cognition_eval.py @@ -73,12 +73,12 @@ def _build_case_runner( ) -> Callable[[dict], CaseResult]: """Warm worker-local caches once, then return a per-case scorer.""" if config is None: - ChatRuntime() + ChatRuntime(no_load_state=True) else: - ChatRuntime(config=config) + ChatRuntime(config=config, no_load_state=True) def _run(case: dict) -> CaseResult: - runtime = ChatRuntime(config=config) if config else ChatRuntime() + runtime = ChatRuntime(config=config, no_load_state=True) if config else ChatRuntime(no_load_state=True) pipeline = CognitiveTurnPipeline(runtime) return _run_case(case, pipeline) diff --git a/recognition/registry.py b/recognition/registry.py new file mode 100644 index 00000000..ba7b0434 --- /dev/null +++ b/recognition/registry.py @@ -0,0 +1,40 @@ +"""RecognizerRegistry -- per-teaching-set recognizer store (ADR-0146). + +Holds DerivedRecognizer instances keyed by teaching_set_id. Wired into +EngineStateStore for Shape B persistence. Empty registry is the valid +initial state (no teaching examples yet). +""" + +from __future__ import annotations + +from recognition.anti_unifier import DerivedRecognizer + + +class RecognizerRegistry: + def __init__(self) -> None: + self._registry: dict[str, DerivedRecognizer] = {} + + def register(self, recognizer: DerivedRecognizer) -> None: + self._registry[recognizer.teaching_set_id] = recognizer + + def get(self, teaching_set_id: str) -> DerivedRecognizer | None: + return self._registry.get(teaching_set_id) + + def all(self) -> list[DerivedRecognizer]: + return list(self._registry.values()) + + def __len__(self) -> int: + return len(self._registry) + + @classmethod + def from_recognizers( + cls, + recognizers: list[DerivedRecognizer], + ) -> "RecognizerRegistry": + reg = cls() + for recognizer in recognizers: + reg.register(recognizer) + return reg + + +__all__ = ["RecognizerRegistry"] diff --git a/teaching/discovery.py b/teaching/discovery.py index 6b668ee1..a681f47d 100644 --- a/teaching/discovery.py +++ b/teaching/discovery.py @@ -95,6 +95,15 @@ class EvidencePointer: "epistemic_status": self.epistemic_status, } + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "EvidencePointer": + return cls( + source=payload["source"], + ref=payload["ref"], + polarity=payload["polarity"], + epistemic_status=payload["epistemic_status"], + ) + @dataclass(frozen=True, slots=True) class SubQuestion: @@ -120,6 +129,18 @@ class SubQuestion: "evidence": [e.as_dict() for e in self.evidence], } + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "SubQuestion": + return cls( + sub_id=payload["sub_id"], + proposed_subject=payload["proposed_subject"], + proposed_intent=payload["proposed_intent"], + outcome=payload["outcome"], + evidence=tuple( + EvidencePointer.from_dict(e) for e in payload.get("evidence", []) + ), + ) + @dataclass(frozen=True, slots=True) class DiscoveryCandidate: @@ -178,6 +199,28 @@ class DiscoveryCandidate: out["recursion_overflow"] = self.recursion_overflow return out + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "DiscoveryCandidate": + return cls( + candidate_id=payload["candidate_id"], + proposed_chain=payload["proposed_chain"], + trigger=payload["trigger"], + source_turn_trace=payload["source_turn_trace"], + pack_consistent=payload["pack_consistent"], + boundary_clean=payload["boundary_clean"], + review_state=payload.get("review_state", "unreviewed"), + polarity=payload.get("polarity", "undetermined"), + claim_domain=payload.get("claim_domain", "factual"), + evidence=tuple( + EvidencePointer.from_dict(e) for e in payload.get("evidence", []) + ), + sub_questions=tuple( + SubQuestion.from_dict(s) for s in payload.get("sub_questions", []) + ), + contemplation_depth=payload.get("contemplation_depth", 0), + recursion_overflow=payload.get("recursion_overflow", False), + ) + _TEACHING_INTENT_NAME: dict[IntentTag, str] = { IntentTag.CAUSE: "cause", diff --git a/tests/test_adr_0146_engine_state.py b/tests/test_adr_0146_engine_state.py new file mode 100644 index 00000000..ae444e84 --- /dev/null +++ b/tests/test_adr_0146_engine_state.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json + +from chat.runtime import ChatRuntime +from engine_state import EngineStateStore +from recognition.anti_unifier import Constant, DerivedRecognizer, TypedSlot +from recognition.registry import RecognizerRegistry +from teaching.discovery import DiscoveryCandidate, EvidencePointer, SubQuestion + + +def _recognizer(teaching_set_id: str = "set-1") -> DerivedRecognizer: + return DerivedRecognizer( + pattern=( + Constant("light"), + TypedSlot( + feature_name="object", + slot_type="noun", + min_width=1, + max_width=2, + ignored_prefix_tokens=("the",), + ), + ), + teaching_set_id=teaching_set_id, + constant_features={"intent": "definition"}, + absent_features={"negated": 0}, + ) + + +def _candidate() -> DiscoveryCandidate: + evidence = EvidencePointer( + source="pack", + ref="en_core_cognition_v1:light", + polarity="affirms", + epistemic_status="ratified", + ) + sub_question = SubQuestion( + sub_id="sub-1", + proposed_subject="light", + proposed_intent="verification", + outcome="grounded", + evidence=(evidence,), + ) + return DiscoveryCandidate( + candidate_id="candidate-1", + proposed_chain={"subject": "light", "intent": "verification"}, + trigger="would_have_grounded", + source_turn_trace="trace-1", + pack_consistent=True, + boundary_clean=True, + polarity="affirms", + claim_domain="factual", + evidence=(evidence,), + sub_questions=(sub_question,), + contemplation_depth=1, + ) + + +def test_engine_state_store_round_trips_recognizers(tmp_path) -> None: + store = EngineStateStore(tmp_path) + recognizer = _recognizer() + + store.save_recognizers([recognizer]) + + assert store.load_recognizers() == [recognizer] + + +def test_engine_state_store_round_trips_empty(tmp_path) -> None: + store = EngineStateStore(tmp_path) + + store.save_recognizers([]) + store.save_discovery_candidates([]) + + assert store.load_recognizers() == [] + assert store.load_discovery_candidates() == [] + assert (tmp_path / "recognizers.jsonl").read_text(encoding="utf-8") == "" + assert (tmp_path / "discovery_candidates.jsonl").read_text(encoding="utf-8") == "" + + +def test_engine_state_store_manifest_written(tmp_path) -> None: + store = EngineStateStore(tmp_path) + + store.save_manifest(turn_count=7) + + manifest = json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8")) + assert manifest["schema_version"] == 1 + assert manifest["turn_count"] == 7 + assert isinstance(manifest["written_at_revision"], str) + + +def test_recognizer_registry_register_and_get() -> None: + registry = RecognizerRegistry() + recognizer = _recognizer() + + registry.register(recognizer) + + assert len(registry) == 1 + assert registry.get("set-1") == recognizer + assert registry.get("missing") is None + assert registry.all() == [recognizer] + + +def test_recognizer_registry_from_recognizers() -> None: + first = _recognizer("set-1") + second = _recognizer("set-2") + + registry = RecognizerRegistry.from_recognizers([first, second]) + + assert len(registry) == 2 + assert registry.get("set-1") == first + assert registry.get("set-2") == second + + +def test_chat_runtime_creates_store_by_default(tmp_path) -> None: + runtime = ChatRuntime(engine_state_path=tmp_path) + + assert runtime._engine_state_store is not None + assert runtime._engine_state_store.path == tmp_path + assert len(runtime._recognizer_registry) == 0 + + +def test_chat_runtime_no_load_state_skips_load(tmp_path) -> None: + store = EngineStateStore(tmp_path) + store.save_recognizers([_recognizer()]) + store.save_manifest(turn_count=3) + + runtime = ChatRuntime(no_load_state=True, engine_state_path=tmp_path) + + assert runtime._engine_state_store is None + assert len(runtime._recognizer_registry) == 0 + assert runtime._turn_count == 0 + + +def test_discovery_candidate_from_dict_round_trips() -> None: + candidate = _candidate() + + roundtrip = DiscoveryCandidate.from_dict(candidate.as_dict()) + + assert roundtrip == candidate