feat(W-008): L10 Shape B hybrid engine-state persistence (#271)
* ci: re-trigger full-pytest * docs: ADR-0146 — L10 Shape B hybrid engine-state persistence * feat(W-008): Shape B engine-state persistence spike (ADR-0146) * fix(W-008): eval isolation + env-var path + empty-manifest guard - evals/run_cognition_eval.py: all ChatRuntime() calls pass no_load_state=True so parallel eval workers never touch engine_state/ checkpoints - engine_state/__init__.py: honour CORE_ENGINE_STATE_DIR env var (ADR-0146 spec) - engine_state/__init__.py: load_manifest() skips empty file instead of crashing (defensive against partial writes in concurrent contexts) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8f60e838d1
commit
9bbdcc96aa
8 changed files with 580 additions and 60 deletions
156
chat/runtime.py
156
chat/runtime.py
|
|
@ -45,10 +45,13 @@ from chat.telemetry import (
|
||||||
from chat.verdicts import TurnVerdicts
|
from chat.verdicts import TurnVerdicts
|
||||||
from chat.dispatch_trace import DispatchAttempt, DispatchTrace
|
from chat.dispatch_trace import DispatchAttempt, DispatchTrace
|
||||||
from teaching.discovery import (
|
from teaching.discovery import (
|
||||||
|
DiscoveryCandidate,
|
||||||
extract_discovery_candidates,
|
extract_discovery_candidates,
|
||||||
format_candidate_jsonl,
|
format_candidate_jsonl,
|
||||||
)
|
)
|
||||||
from teaching.discovery_sink import DiscoveryCandidateSink
|
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.config import DEFAULT_CONFIG, DEFAULT_IDENTITY_PACK, RuntimeConfig
|
||||||
from core.physics.drive import DriveGradientMap, GradientField
|
from core.physics.drive import DriveGradientMap, GradientField
|
||||||
from core.physics.energy import EnergyClass, EnergyProfile
|
from core.physics.energy import EnergyClass, EnergyProfile
|
||||||
|
|
@ -466,6 +469,8 @@ class ChatRuntime:
|
||||||
*,
|
*,
|
||||||
frame_pack: str | None = None,
|
frame_pack: str | None = None,
|
||||||
config: RuntimeConfig = DEFAULT_CONFIG,
|
config: RuntimeConfig = DEFAULT_CONFIG,
|
||||||
|
no_load_state: bool = False,
|
||||||
|
engine_state_path: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if pack_id is not None or frame_pack is not 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)
|
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.
|
# W-013 — last classified intent, updated each turn for /explain REPL use.
|
||||||
self._last_intent: Any | None = None
|
self._last_intent: Any | None = None
|
||||||
self._last_input_text: str = ""
|
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
|
@property
|
||||||
def session(self) -> SessionContext:
|
def session(self) -> SessionContext:
|
||||||
|
|
@ -797,9 +834,6 @@ class ChatRuntime:
|
||||||
intent_subject: str | None,
|
intent_subject: str | None,
|
||||||
grounding_source: str | None,
|
grounding_source: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
sink = self._discovery_sink
|
|
||||||
if sink is None:
|
|
||||||
return
|
|
||||||
candidates = extract_discovery_candidates(
|
candidates = extract_discovery_candidates(
|
||||||
turn_event,
|
turn_event,
|
||||||
intent_tag,
|
intent_tag,
|
||||||
|
|
@ -816,6 +850,10 @@ class ChatRuntime:
|
||||||
candidates = tuple(
|
candidates = tuple(
|
||||||
contemplate(c, vault_probe=vault_probe) for c in candidates
|
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:
|
for candidate in candidates:
|
||||||
sink.emit(format_candidate_jsonl(candidate))
|
sink.emit(format_candidate_jsonl(candidate))
|
||||||
|
|
||||||
|
|
@ -1793,17 +1831,19 @@ class ChatRuntime:
|
||||||
text,
|
text,
|
||||||
stub_articulation,
|
stub_articulation,
|
||||||
)
|
)
|
||||||
return self._stub_response(
|
return self._checkpointed_response(
|
||||||
committed,
|
self._stub_response(
|
||||||
tokens=tuple(filtered),
|
committed,
|
||||||
pack_grounded_surface=pack_surface,
|
tokens=tuple(filtered),
|
||||||
grounded_source_tag=pack_source_tag,
|
pack_grounded_surface=pack_surface,
|
||||||
pack_semantic_domains=pack_semantic_domains,
|
grounded_source_tag=pack_source_tag,
|
||||||
graph_atoms=stub_graph_atoms,
|
pack_semantic_domains=pack_semantic_domains,
|
||||||
graph_unconstrained=stub_graph_unconstrained,
|
graph_atoms=stub_graph_atoms,
|
||||||
discovery_intent_tag=discovery_intent_tag,
|
graph_unconstrained=stub_graph_unconstrained,
|
||||||
discovery_intent_subject=discovery_intent_subject,
|
discovery_intent_tag=discovery_intent_tag,
|
||||||
dispatch_trace=dispatch_trace,
|
discovery_intent_subject=discovery_intent_subject,
|
||||||
|
dispatch_trace=dispatch_trace,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.config.unified_ingest:
|
if self.config.unified_ingest:
|
||||||
|
|
@ -1896,7 +1936,9 @@ class ChatRuntime:
|
||||||
field_state,
|
field_state,
|
||||||
tokens=tuple(filtered),
|
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 ---
|
# --- Articulation fidelity: replace bare S-P-O join with intent-aware surface ---
|
||||||
# Phase 2: pass proposition so the bridge grounds <pending> obj slots
|
# Phase 2: pass proposition so the bridge grounds <pending> obj slots
|
||||||
|
|
@ -2215,47 +2257,49 @@ class ChatRuntime:
|
||||||
grounding_source=main_grounding_source,
|
grounding_source=main_grounding_source,
|
||||||
surface=response_surface,
|
surface=response_surface,
|
||||||
)
|
)
|
||||||
return ChatResponse(
|
return self._checkpointed_response(
|
||||||
surface=response_surface,
|
ChatResponse(
|
||||||
proposition=proposition,
|
surface=response_surface,
|
||||||
articulation=articulation,
|
proposition=proposition,
|
||||||
articulation_surface=articulation.surface,
|
articulation=articulation,
|
||||||
dialogue_role=dialogue_role,
|
articulation_surface=articulation.surface,
|
||||||
versor_condition=versor_condition(result.final_state.F),
|
dialogue_role=dialogue_role,
|
||||||
output_language=self.config.output_language,
|
versor_condition=versor_condition(result.final_state.F),
|
||||||
frame_pack=self.config.frame_pack,
|
output_language=self.config.output_language,
|
||||||
walk_surface=walk_surface,
|
frame_pack=self.config.frame_pack,
|
||||||
salience_top_k=result.salience_top_k,
|
walk_surface=walk_surface,
|
||||||
candidates_used=result.candidates_used,
|
salience_top_k=result.salience_top_k,
|
||||||
vault_hits=vault_hits,
|
candidates_used=result.candidates_used,
|
||||||
identity_score=identity_score,
|
vault_hits=vault_hits,
|
||||||
character_profile=self.character_profile,
|
identity_score=identity_score,
|
||||||
flagged=flagged,
|
character_profile=self.character_profile,
|
||||||
recall_energy_class=recall_energy_class_main,
|
flagged=flagged,
|
||||||
admissibility_trace=result.admissibility_trace,
|
recall_energy_class=recall_energy_class_main,
|
||||||
region_was_unconstrained=result.region_was_unconstrained,
|
admissibility_trace=result.admissibility_trace,
|
||||||
safety_verdict=safety_verdict,
|
region_was_unconstrained=result.region_was_unconstrained,
|
||||||
ethics_verdict=ethics_verdict,
|
safety_verdict=safety_verdict,
|
||||||
verdicts=verdicts_bundle,
|
ethics_verdict=ethics_verdict,
|
||||||
grounding_source=main_grounding_source,
|
verdicts=verdicts_bundle,
|
||||||
pre_decoration_surface=pre_decoration_surface_main,
|
grounding_source=main_grounding_source,
|
||||||
register_id=register_id_main,
|
pre_decoration_surface=pre_decoration_surface_main,
|
||||||
register_variant_id=decoration_main.variant_id,
|
register_id=register_id_main,
|
||||||
anchor_lens_id=anchor_lens_id_main,
|
register_variant_id=decoration_main.variant_id,
|
||||||
anchor_lens_mode_label=anchor_lens_mode_label_main,
|
anchor_lens_id=anchor_lens_id_main,
|
||||||
realizer_guard_status=realizer_guard_status_main,
|
anchor_lens_mode_label=anchor_lens_mode_label_main,
|
||||||
realizer_guard_rule=realizer_guard_rule_main,
|
realizer_guard_status=realizer_guard_status_main,
|
||||||
register_canonical_surface=register_canonical_surface_main,
|
realizer_guard_rule=realizer_guard_rule_main,
|
||||||
composer_graph_atom_status=atom_equivalence_main.status,
|
register_canonical_surface=register_canonical_surface_main,
|
||||||
composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash,
|
composer_graph_atom_status=atom_equivalence_main.status,
|
||||||
graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash,
|
composer_atom_set_hash=atom_equivalence_main.composer_atom_set_hash,
|
||||||
composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count,
|
graph_atom_set_hash=atom_equivalence_main.graph_atom_set_hash,
|
||||||
recalled_words=walk_tokens,
|
composer_graph_atom_overlap_count=atom_equivalence_main.overlap_count,
|
||||||
epistemic_state=main_epistemic_state,
|
recalled_words=walk_tokens,
|
||||||
normative_clearance=main_normative_clearance,
|
epistemic_state=main_epistemic_state,
|
||||||
normative_detail=main_normative_detail,
|
normative_clearance=main_normative_clearance,
|
||||||
refusal_reason=refusal_surface if refusal_emitted else "",
|
normative_detail=main_normative_detail,
|
||||||
dispatch_trace=dispatch_trace,
|
refusal_reason=refusal_surface if refusal_emitted else "",
|
||||||
|
dispatch_trace=dispatch_trace,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:
|
def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse:
|
||||||
|
|
|
||||||
11
core/cli.py
11
core/cli.py
|
|
@ -223,7 +223,10 @@ def cmd_chat(args: argparse.Namespace) -> int:
|
||||||
_print_runtime_import_hint(exc)
|
_print_runtime_import_hint(exc)
|
||||||
|
|
||||||
try:
|
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
|
except Exception as exc: # noqa: BLE001 — surface pack-load errors
|
||||||
from packs.anchor_lens.loader import AnchorLensError
|
from packs.anchor_lens.loader import AnchorLensError
|
||||||
from packs.register.loader import RegisterPackError
|
from packs.register.loader import RegisterPackError
|
||||||
|
|
@ -3186,6 +3189,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
"stderr (ADR-0041 operator-facing audit readout)"
|
"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(
|
chat.add_argument(
|
||||||
"--register",
|
"--register",
|
||||||
metavar="REGISTER_ID",
|
metavar="REGISTER_ID",
|
||||||
|
|
|
||||||
127
docs/decisions/ADR-0146-l10-hybrid-engine-state-persistence.md
Normal file
127
docs/decisions/ADR-0146-l10-hybrid-engine-state-persistence.md
Normal file
|
|
@ -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": "<git-sha>",
|
||||||
|
"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).
|
||||||
118
engine_state/__init__.py
Normal file
118
engine_state/__init__.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -73,12 +73,12 @@ def _build_case_runner(
|
||||||
) -> Callable[[dict], CaseResult]:
|
) -> Callable[[dict], CaseResult]:
|
||||||
"""Warm worker-local caches once, then return a per-case scorer."""
|
"""Warm worker-local caches once, then return a per-case scorer."""
|
||||||
if config is None:
|
if config is None:
|
||||||
ChatRuntime()
|
ChatRuntime(no_load_state=True)
|
||||||
else:
|
else:
|
||||||
ChatRuntime(config=config)
|
ChatRuntime(config=config, no_load_state=True)
|
||||||
|
|
||||||
def _run(case: dict) -> CaseResult:
|
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)
|
pipeline = CognitiveTurnPipeline(runtime)
|
||||||
return _run_case(case, pipeline)
|
return _run_case(case, pipeline)
|
||||||
|
|
||||||
|
|
|
||||||
40
recognition/registry.py
Normal file
40
recognition/registry.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -95,6 +95,15 @@ class EvidencePointer:
|
||||||
"epistemic_status": self.epistemic_status,
|
"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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SubQuestion:
|
class SubQuestion:
|
||||||
|
|
@ -120,6 +129,18 @@ class SubQuestion:
|
||||||
"evidence": [e.as_dict() for e in self.evidence],
|
"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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DiscoveryCandidate:
|
class DiscoveryCandidate:
|
||||||
|
|
@ -178,6 +199,28 @@ class DiscoveryCandidate:
|
||||||
out["recursion_overflow"] = self.recursion_overflow
|
out["recursion_overflow"] = self.recursion_overflow
|
||||||
return out
|
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] = {
|
_TEACHING_INTENT_NAME: dict[IntentTag, str] = {
|
||||||
IntentTag.CAUSE: "cause",
|
IntentTag.CAUSE: "cause",
|
||||||
|
|
|
||||||
139
tests/test_adr_0146_engine_state.py
Normal file
139
tests/test_adr_0146_engine_state.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue