diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70acfe9..749e2f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: uv run ruff format --check . - name: Mypy - run: uv run mypy . + run: uv run mypy src - name: Install Hypothesis for tests run: uv pip install --python .venv/bin/python hypothesis diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd38bb1..6f11d85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,6 +10,7 @@ repos: rev: v1.19.1 hooks: - id: mypy + exclude: ^tests/ args: [--pretty] additional_dependencies: - hypothesis diff --git a/README.md b/README.md index c32a6d5..21bfe10 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Later in the conversation: User: how should I deploy my service? ``` -The host supplies the compiled state to the model so the constraint persists across turns. +The host supplies the authoritative state to the model so the constraint persists across turns. --- @@ -140,19 +140,40 @@ The compiler maintains an authoritative state: } ``` -## State Export / Import +## State Access and Persistence -The compiler exposes its authoritative state so host applications can -persist or restore it across sessions. +The compiler exposes authoritative state through in-memory replacement APIs +and JSON persistence/transport APIs. -Example lifecycle: +Supported host interaction model: ```python -state = engine.export_state() -save_to_storage(state) +engine = create_engine() +engine = create_engine(state=initial_state_obj) +decision = engine.step(user_input) +state = engine.state +engine.state = replacement_state_obj +payload = engine.export_json() +engine.import_json(payload) +``` + +API boundaries: + +- `step()` performs directive-driven mutation. +- `engine.state` / `engine.state = ...` are in-memory inspection/replacement APIs. +- `export_json()` / `import_json()` are persistence/transport APIs. +- No imperative convenience mutation methods are provided; operations such as + `reset policies` and `clear state` are handled through directive input to `step()`. + +Example persistence lifecycle: -restored = load_from_storage() -engine = create_engine(state=restored) +```python +payload = engine.export_json() +save_to_storage(payload) + +restored_payload = load_from_storage() +engine = create_engine() +engine.import_json(restored_payload) ``` The compiler does not manage storage or snapshots. Persistence policies @@ -323,6 +344,7 @@ The compiler enforces several invariants: - The same input sequence always produces the same state - LLM output never affects state - No mutation occurs during clarification +- Administrative state replacement clears pending clarification state - Facts are exclusive - Policies are additive - Pending clarification blocks mutation @@ -344,8 +366,6 @@ Higher-level systems such as session scope management, memory layers, agent tool or context routing belong in host applications or separate projects built on top of the compiler. -One possible future extension is cross-session persistence using exported state. - --- ## Design Philosophy diff --git a/docs/DescriptionAndMilestones.md b/docs/DescriptionAndMilestones.md index 701d2f1..5bfc6a7 100644 --- a/docs/DescriptionAndMilestones.md +++ b/docs/DescriptionAndMilestones.md @@ -56,7 +56,7 @@ Richer fact schemas may be introduced in future milestones. - State data model (facts + policies) - Deterministic update rules (exclusive vs additive slots) - Clarification mechanism for ambiguous mutations -- Context serialization interface (state → host application) +- Context serialization interface (`export_json` / `import_json`, state → host application) - Reference integration harness (example host) - Tests: persistence and non-regression of corrections @@ -67,7 +67,7 @@ After correcting or constraining the assistant once, the behavior remains consis ### M3 — Cross-Session Recall **Goal** -Allow host applications to restore previously exported state safely and intentionally. +Extend host-level workflows around persisted exported state safely and intentionally. **Core capability:** @@ -77,7 +77,7 @@ Allow host applications to restore previously exported state safely and intentio **Deliverables:** -- Import/Export API +- Host-side storage/recovery patterns built on the existing import/export API **User-visible outcome:** diff --git a/docs/M1Design.md b/docs/M1Design.md index c270188..dd485ce 100644 --- a/docs/M1Design.md +++ b/docs/M1Design.md @@ -33,6 +33,8 @@ The compiler: 4. Returns a deterministic `Decision` The compiler never calls the LLM. +Mutations are expressed through directive input to `step()`, including reset/clear operations. +Imperative convenience methods such as `reset_policies()` and `clear_state()` are not part of the public API. ### 3. Host Responsibilities @@ -241,6 +243,12 @@ While pending exists, no other mutation may occur. Adding duplicate policy is a no-op. Policies stored in sorted lexical order. +Administrative state replacement is also supported through public host APIs: +- `engine.state = ...` (object replacement) +- `engine.import_json(payload)` (JSON replacement) + +Both replacement paths clear pending clarification state and must behave like live state for subsequent `step()` calls. + ### 10. Context Serialization Contract The compiler outputs structured state only. The host formats prompts. @@ -254,6 +262,10 @@ Example: } ` +JSON persistence boundary: +- `engine.export_json()` serializes authoritative state for transport/storage. +- `engine.import_json(payload)` validates/canonicalizes payload and fully replaces active state. + ### 11. Reset Commands Explicit only: diff --git a/pyproject.toml b/pyproject.toml index 68cf46c..462c4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"] [tool.mypy] python_version = "3.11" strict = true +exclude = ["^tests/"] warn_unused_configs = true disallow_untyped_defs = true disallow_incomplete_defs = true diff --git a/src/context_compiler/const.py b/src/context_compiler/const.py index cd8fd98..80d4a5a 100644 --- a/src/context_compiler/const.py +++ b/src/context_compiler/const.py @@ -12,3 +12,7 @@ # Policy keys POLICY_PROHIBIT: Final = "prohibit" + +# Event kinds +EVENT_RESET_POLICIES: Final = "reset_policies" +EVENT_CLEAR_STATE: Final = "clear_state" diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index 028d1b2..c6565f9 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -14,6 +14,8 @@ from unicodedata import normalize as unicode_normalize from .const import ( + EVENT_CLEAR_STATE, + EVENT_RESET_POLICIES, FOCUS_PRIMARY, POLICY_PROHIBIT, STATE_FACTS, @@ -90,38 +92,64 @@ class NegativeDirectiveRule: ) -def create_engine() -> "Engine": - """Create a new deterministic Engine instance with initial M1 state.""" - return Engine() +def create_engine(state: State | None = None) -> "Engine": + """Create an Engine with initial state or validated replacement state. + When ``state`` is provided, it is validated and canonicalized before use. + """ + return Engine(state=state) -class Engine: - """Deterministic state engine implementing M1 directive semantics.""" - def __init__(self) -> None: - self._state: State = _initial_state() +class Engine: + """Deterministic state engine implementing M1 directive semantics. + + Design note: + - ``step()`` is the mutation interface for directive-driven updates. + - Operations like "reset policies" and "clear state" are expressed as + directive input to ``step()``. + - Host code should not rely on imperative helpers such as + ``reset_policies()`` or ``clear_state()``. + - State may be administratively replaced via ``engine.state = ...`` and + ``engine.import_json(...)``. + """ + + def __init__(self, state: State | None = None) -> None: + """Initialize engine state from defaults or a supplied state object. + + Supplied state is validated and canonicalized. Initialization performs + full state replacement and clears pending clarification state. + """ + self._state: State self._pending: PendingEvent | None = None self._pending_prompt: str | None = None self._last_exclusive_fact_key: str | None = None + self._replace_state(_initial_state() if state is None else _load_state_obj(state)) @property def state(self) -> State: - """Return a defensive copy of the current authoritative state snapshot.""" + """Return a defensive copy of the current authoritative in-memory state.""" return deepcopy(self._state) + @state.setter + def state(self, value: State) -> None: + """Replace authoritative in-memory state from a supplied object. + + The supplied value is validated and canonicalized. Replacement is full, + and pending clarification state is cleared. + """ + self._replace_state(_load_state_obj(value)) + def export_json(self) -> str: - """Serialize authoritative state to canonical JSON.""" + """Serialize authoritative state for persistence or transport.""" return json.dumps(self._state, sort_keys=True, separators=(",", ":")) def import_json(self, payload: str) -> None: - """Replace authoritative state from a JSON payload.""" - state = _load_state_json(payload) - self._state = state - self._pending = None - self._pending_prompt = None - self._last_exclusive_fact_key = ( - FOCUS_PRIMARY if state[STATE_FACTS][FOCUS_PRIMARY] is not None else None - ) + """Load and replace authoritative state from a JSON payload. + + Payload state is validated and canonicalized. Replacement is full, and + pending clarification state is cleared. + """ + self._replace_state(_load_state_json(payload)) def step(self, user_input: str) -> Decision: """Process one user input and return a deterministic Decision.""" @@ -144,10 +172,10 @@ def _classify(self, user_input: str) -> PendingEvent | Decision: normalized_message = _normalize_message(user_input) if normalized_message in _RESET_POLICY: - return PendingEvent(kind="reset_policies") + return PendingEvent(kind=EVENT_RESET_POLICIES) if normalized_message in _CLEAR_STATE: - return PendingEvent(kind="clear_state") + return PendingEvent(kind=EVENT_CLEAR_STATE) correction = _parse_correction(user_input) if correction is not None: @@ -226,7 +254,7 @@ def _apply_event(self, event: PendingEvent) -> Decision: if event.kind == "fact_set": assert event.fact_value is not None return self._set_focus_primary(event.fact_value) - if event.kind == "reset_policies": + if event.kind == EVENT_RESET_POLICIES: self._state[STATE_POLICIES][POLICY_PROHIBIT] = [] return _update_decision(self._state) @@ -234,6 +262,14 @@ def _apply_event(self, event: PendingEvent) -> Decision: self._last_exclusive_fact_key = None return _update_decision(self._state) + def _replace_state(self, state: State) -> None: + self._state = state + self._pending = None + self._pending_prompt = None + self._last_exclusive_fact_key = ( + FOCUS_PRIMARY if state[STATE_FACTS][FOCUS_PRIMARY] is not None else None + ) + def _set_focus_primary(self, value: str) -> Decision: self._state[STATE_FACTS][FOCUS_PRIMARY] = _clean_fact_value(value) self._last_exclusive_fact_key = FOCUS_PRIMARY @@ -268,8 +304,13 @@ def _load_state_json(payload: str) -> State: except json.JSONDecodeError as exc: raise ValueError("Invalid JSON payload.") from exc + return _load_state_obj(raw) + + +def _load_state_obj(raw: object) -> State: if not isinstance(raw, dict): raise ValueError("Invalid state payload.") + if set(raw.keys()) != {STATE_FACTS, STATE_POLICIES, STATE_VERSION}: raise ValueError("Invalid state payload.") diff --git a/tests/test_engine.py b/tests/test_engine.py index f485556..afa825c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,7 +1,9 @@ import json +import pytest + from context_compiler import create_engine -from context_compiler.engine import DecisionKind +from context_compiler.engine import DecisionKind, Engine def test_decision_kind_strenum_behavior() -> None: @@ -11,6 +13,13 @@ def test_decision_kind_strenum_behavior() -> None: assert DecisionKind(kind.value) is kind +def test_state_getter_returns_defensive_copy() -> None: + engine = create_engine() + snapshot = engine.state + snapshot["facts"]["focus.primary"] = "mutated" + assert engine.state["facts"]["focus.primary"] is None + + def test_export_json_returns_complete_representation_of_state() -> None: engine = create_engine() engine.step("use Nord Stage 4") @@ -94,6 +103,20 @@ def test_import_json_invalid_json_and_unsupported_version_are_rejected() -> None assert "Unsupported state version" in str(exc) +@pytest.mark.parametrize( + "payload", + [ + {"facts": {}, "policies": {"prohibit": []}, "version": 1}, + {"facts": {"focus.primary": None}, "policies": {"prohibit": "docker"}, "version": 1}, + {"facts": {"focus.primary": 123}, "policies": {"prohibit": []}, "version": 1}, + ], +) +def test_import_json_rejects_structurally_invalid_payload(payload: dict[str, object]) -> None: + engine = create_engine() + with pytest.raises(ValueError): + engine.import_json(json.dumps(payload)) + + def test_import_json_canonicalizes_duplicate_unsorted_prohibit_values() -> None: engine = create_engine() engine.import_json( @@ -128,6 +151,108 @@ def test_import_json_sanitizes_fact_value_like_live_fact_write() -> None: assert engine.state["facts"]["focus.primary"] == "MacBook M3'" +def test_constructor_with_state_initializes_from_normalized_state() -> None: + raw_state = { + "facts": {"focus.primary": " MacBook M3` "}, + "policies": {"prohibit": ["kubernetes", "docker", "docker"]}, + "version": 1, + } + loaded_state = json.loads(json.dumps(raw_state)) + + constructed = Engine(state=loaded_state) + + assert constructed.state == { + "facts": {"focus.primary": "MacBook M3'"}, + "policies": {"prohibit": ["docker", "kubernetes"]}, + "version": 1, + } + + +def test_create_engine_with_state_initializes_from_normalized_state() -> None: + created = create_engine( + state={ + "facts": {"focus.primary": " MacBook M3` "}, + "policies": {"prohibit": ["kubernetes", "docker", "docker"]}, + "version": 1, + } + ) + + assert created.state == { + "facts": {"focus.primary": "MacBook M3'"}, + "policies": {"prohibit": ["docker", "kubernetes"]}, + "version": 1, + } + + +@pytest.mark.parametrize( + ("path", "bad_state"), + [ + ( + "constructor", + { + "facts": {"focus.primary": None}, + "policies": {"prohibit": []}, + }, + ), + ( + "setter", + { + "facts": {"focus.primary": None}, + "policies": {"prohibit": "docker"}, + "version": 1, + }, + ), + ], +) +def test_object_state_replacement_paths_reject_invalid_state(path: str, bad_state: object) -> None: + if path == "constructor": + with pytest.raises(ValueError): + Engine(state=bad_state) + return + + engine = create_engine() + with pytest.raises(ValueError): + engine.state = bad_state + + +def test_state_setter_replaces_state_and_clears_pending_clarification() -> None: + engine = create_engine() + decision = engine.step("no use docker") + assert decision["kind"] == "clarify" + + engine.state = { + "facts": {"focus.primary": "Nord Stage 4"}, + "policies": {"prohibit": ["kubernetes", "docker", "docker"]}, + "version": 1, + } + + decision_after_setter = engine.step("yes") + assert decision_after_setter["kind"] == "passthrough" + assert engine.state == { + "facts": {"focus.primary": "Nord Stage 4"}, + "policies": {"prohibit": ["docker", "kubernetes"]}, + "version": 1, + } + + +def test_constructor_setter_and_import_json_share_normalization_behavior() -> None: + raw_state = { + "facts": {"focus.primary": " MacBook M3` "}, + "policies": {"prohibit": ["kubernetes", "docker", "docker"]}, + "version": 1, + } + + from_ctor = Engine(state=json.loads(json.dumps(raw_state))) + + from_setter = create_engine() + from_setter.state = json.loads(json.dumps(raw_state)) + + from_import = create_engine() + from_import.import_json(json.dumps(raw_state)) + + assert from_ctor.state == from_setter.state == from_import.state + + def test_import_json_clears_pending_clarification_state() -> None: engine = create_engine() decision = engine.step("no use docker")