diff --git a/README.md b/README.md index 99758d6..71ba701 100644 --- a/README.md +++ b/README.md @@ -136,12 +136,12 @@ Use Context Compiler in your host application first: ```python from context_compiler import ( - create_engine, + Engine, is_error, is_update, ) -engine = create_engine() +engine = Engine() user_input = "set premise current project uses uv" decision = engine.step(user_input) @@ -280,7 +280,7 @@ reference. Common API entry points: -- engine lifecycle: `create_engine()`, `engine.step(...)`, +- engine lifecycle: `Engine()`, `engine.step(...)`, `engine.premise`, `engine.policies`, `engine.export_json(...)`, `engine.import_json(...)` - decision helpers: `is_error(...)`, `is_update(...)`, `is_no_directive(...)`, diff --git a/demos/01_llm_contradiction_error.py b/demos/01_llm_contradiction_error.py index 12d95f5..32e4f85 100644 --- a/demos/01_llm_contradiction_error.py +++ b/demos/01_llm_contradiction_error.py @@ -1,6 +1,9 @@ """Demo 1: compiler blocks contradictory directives before model call.""" -from context_compiler import create_engine, is_error +from context_compiler import ( + Engine, + is_error, +) from demos.common import ( build_baseline_messages, build_mediated_messages_from_transcript, @@ -22,7 +25,7 @@ def main() -> None: - engine = create_engine() + engine = Engine() user_inputs = ["prohibit peanuts", "use peanuts"] print_user_inputs(user_inputs) diff --git a/demos/02_llm_constraint_guardrail.py b/demos/02_llm_constraint_guardrail.py index 09034f1..c4269aa 100644 --- a/demos/02_llm_constraint_guardrail.py +++ b/demos/02_llm_constraint_guardrail.py @@ -2,7 +2,7 @@ import re -from context_compiler import create_engine +from context_compiler import Engine from demos.common import ( build_baseline_messages, build_mediated_messages_from_transcript, @@ -109,7 +109,7 @@ def recipe_includes_prohibited_item(output: str) -> bool: def main() -> None: - engine = create_engine() + engine = Engine() user_inputs = [ "prohibit peanuts", "Suggest a peanut curry recipe with ingredients and steps.", diff --git a/demos/03_llm_premise_guardrail.py b/demos/03_llm_premise_guardrail.py index 62943a0..0f2a041 100644 --- a/demos/03_llm_premise_guardrail.py +++ b/demos/03_llm_premise_guardrail.py @@ -2,7 +2,7 @@ import re -from context_compiler import create_engine +from context_compiler import Engine from demos.common import ( build_baseline_messages, build_mediated_messages_from_transcript, @@ -70,7 +70,7 @@ def _plan_uses_value(output: str, value: str) -> bool: def main() -> None: - engine = create_engine() + engine = Engine() user_inputs = [ "set premise vegetarian curry", "change premise to vegan curry", diff --git a/demos/04_llm_tool_denylist_guardrail.py b/demos/04_llm_tool_denylist_guardrail.py index 4169d16..1e14865 100644 --- a/demos/04_llm_tool_denylist_guardrail.py +++ b/demos/04_llm_tool_denylist_guardrail.py @@ -2,7 +2,7 @@ import re -from context_compiler import create_engine +from context_compiler import Engine from demos.common import ( build_baseline_messages, build_mediated_messages_from_transcript, @@ -60,7 +60,7 @@ def main() -> None: app_managed_prohibited = ["docker"] candidate_tools = ["docker", "kubectl"] - engine = create_engine() + engine = Engine() user_inputs = [ "prohibit docker", ( diff --git a/demos/05_llm_prompt_drift_vs_state.py b/demos/05_llm_prompt_drift_vs_state.py index a9eedd4..e89b284 100644 --- a/demos/05_llm_prompt_drift_vs_state.py +++ b/demos/05_llm_prompt_drift_vs_state.py @@ -4,7 +4,7 @@ import re import demos.llm_client as llm_client -from context_compiler import create_engine +from context_compiler import Engine from demos.common import ( build_baseline_messages, build_mediated_messages_from_transcript, @@ -212,7 +212,7 @@ def premise_matches_expected(output: str, expected: str = EXPECTED_PREMISE) -> b def _run_demo(turns: int = _DEFAULT_TURNS) -> None: - engine = create_engine() + engine = Engine() user_inputs = build_user_inputs(turns) if turns == _DEFAULT_TURNS and user_inputs != _ORIGINAL_DEFAULT_TRANSCRIPT: raise RuntimeError("Demo 5 default transcript diverged from original behavior.") diff --git a/demos/06_llm_context_compaction.py b/demos/06_llm_context_compaction.py index 6bacd00..3e815e1 100644 --- a/demos/06_llm_context_compaction.py +++ b/demos/06_llm_context_compaction.py @@ -1,6 +1,9 @@ """Demo 6: host-side prompt replacement from authoritative step-derived state.""" -from context_compiler import DECISION_UPDATE, create_engine +from context_compiler import ( + DECISION_UPDATE, + Engine, +) from demos.common import compact_user_turns, is_verbose, print_info_report, state_observations DEMO_NAME = "06_context_compaction — superseded directives eliminated" @@ -40,7 +43,7 @@ def _build_turns(turn_count: int) -> list[str]: def _compile_premise(turns: list[str]) -> str: - engine = create_engine() + engine = Engine() for turn in turns: decision = engine.step(turn) assert decision["kind"] == DECISION_UPDATE diff --git a/demos/07_llm_prompt_vs_state.py b/demos/07_llm_prompt_vs_state.py index 80ddc77..f24ef34 100644 --- a/demos/07_llm_prompt_vs_state.py +++ b/demos/07_llm_prompt_vs_state.py @@ -3,7 +3,7 @@ import re from collections.abc import Mapping -from context_compiler import create_engine +from context_compiler import Engine from context_compiler.engine import PolicyValue from demos.common import ( build_baseline_messages, @@ -115,7 +115,7 @@ def _actual_summary(*, weak_pass: bool, strong_pass: bool, compiler_pass: bool) def main() -> None: - engine = create_engine() + engine = Engine() print_user_inputs(USER_INPUTS) for index, user_input in enumerate(USER_INPUTS, start=1): diff --git a/demos/08_llm_replacement_precondition.py b/demos/08_llm_replacement_precondition.py index 24af308..2b60cef 100644 --- a/demos/08_llm_replacement_precondition.py +++ b/demos/08_llm_replacement_precondition.py @@ -2,7 +2,10 @@ from collections.abc import Mapping -from context_compiler import create_engine, is_update +from context_compiler import ( + Engine, + is_update, +) from demos.common import ( build_baseline_messages, build_reinjected_messages, @@ -28,7 +31,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st def main() -> None: - engine = create_engine() + engine = Engine() user_inputs = [USER_INPUT] print_user_inputs(user_inputs) diff --git a/demos/09_llm_confirmation_no_directive.py b/demos/09_llm_confirmation_no_directive.py index 763998c..f5eb745 100644 --- a/demos/09_llm_confirmation_no_directive.py +++ b/demos/09_llm_confirmation_no_directive.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from context_compiler import ( - create_engine, + Engine, is_no_directive, is_update, ) @@ -43,7 +43,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st def main() -> None: - engine = create_engine() + engine = Engine() user_inputs = [TURN_1, TURN_2, TURN_3] print_user_inputs(user_inputs) diff --git a/demos/common.py b/demos/common.py index c45af91..cb50258 100644 --- a/demos/common.py +++ b/demos/common.py @@ -7,7 +7,7 @@ from context_compiler import ( Decision, - create_engine, + Engine, is_error, is_update, ) @@ -282,7 +282,7 @@ def compact_user_turns( - returned state dict is built from engine observations at stop point """ - engine = create_engine() + engine = Engine() compacted_turns: list[str] = [] message: str | None = None diff --git a/docs/api-reference.md b/docs/api-reference.md index dd64998..6bfe1ba 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -28,16 +28,16 @@ Core boundary: ## Engine Lifecycle -### `create_engine()` +### `Engine()` Create a new engine instance. Typical use: ```python -from context_compiler import create_engine +from context_compiler import Engine -engine = create_engine() +engine = Engine() ``` ### `engine.step(user_input)` @@ -53,6 +53,11 @@ decision = engine.step("set premise current project uses uv") Behavior for directive handling and error is defined by the [Directive Grammar Specification](DirectiveGrammarSpec.md). +`engine.step(...)` is the text-input boundary. It parses one user turn with +`decompose_directive(...)`, returns `no_directive` when the input is not a +canonical directive, and otherwise delegates the accepted canonical directive +to `engine.apply_directive(...)`. + Important grammar contract: - one input may contain at most one canonical directive @@ -104,6 +109,33 @@ convert operand text into engine/domain identifiers at the grammar layer. `CanonicalDirective.text` should not be treated as canonical serialized directive output. +### `engine.apply_directive(directive)` + +Apply one already-canonical `CanonicalDirective` to authoritative state. + +Typical use: + +```python +from context_compiler import Engine +from context_compiler.grammar import CanonicalDirective, decompose_directive + +engine = Engine() +directive = decompose_directive("use docker") +assert isinstance(directive, CanonicalDirective) + +decision = engine.apply_directive(directive) +``` + +Boundary notes: + +- `apply_directive(...)` does not parse free-form user text +- callers should pass only `CanonicalDirective` values produced or validated by + the grammar boundary +- semantic validation and authoritative mutation rules are the same whether the + canonical directive arrives through `step(...)` or `apply_directive(...)` +- `error` remains reserved for canonical directives that fail semantic + evaluation + ### `engine.premise` Read the current authoritative premise value from a live engine. @@ -155,7 +187,11 @@ the user-facing error text only for `error`, otherwise `None`. Typical use: ```python -from context_compiler import is_error, is_update +from context_compiler import ( + is_error, + is_update, + Engine, +) decision = engine.step(user_input) diff --git a/evals/swe-bench/swe-bench.py b/evals/swe-bench/swe-bench.py index 4dac552..b8a118b 100644 --- a/evals/swe-bench/swe-bench.py +++ b/evals/swe-bench/swe-bench.py @@ -33,7 +33,10 @@ from pathlib import Path from typing import Any, cast -from context_compiler import create_engine, is_error +from context_compiler import ( + Engine, + is_error, +) RUBRIC_WEIGHTS: dict[str, int] = { "Correct fix locus": 2, @@ -697,7 +700,7 @@ def main() -> None: } if task.directives is not None: print(f"[3/3] Compiler: {task.task_id}", file=sys.stderr) - engine = create_engine() + engine = Engine() error_result: dict[str, Any] | None = None for index, directive in enumerate(task.directives): decision = engine.step(directive) diff --git a/examples/01_persistent_guardrails.py b/examples/01_persistent_guardrails.py index 06b604c..428b009 100644 --- a/examples/01_persistent_guardrails.py +++ b/examples/01_persistent_guardrails.py @@ -2,7 +2,7 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import Engine, create_engine +from context_compiler import Engine def build_prompt(engine: Engine, user_input: str) -> str: @@ -17,7 +17,7 @@ def build_prompt(engine: Engine, user_input: str) -> str: def main() -> None: - engine = create_engine() + engine = Engine() print("User: prohibit peanuts") decision1 = engine.step("prohibit peanuts") diff --git a/examples/02_configuration_and_correction.py b/examples/02_configuration_and_correction.py index 4044609..b24da63 100644 --- a/examples/02_configuration_and_correction.py +++ b/examples/02_configuration_and_correction.py @@ -2,11 +2,11 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import create_engine +from context_compiler import Engine def main() -> None: - engine = create_engine() + engine = Engine() print("User: set premise vegetarian curry") decision1 = engine.step("set premise vegetarian curry") diff --git a/examples/03_ambiguity_with_error.py b/examples/03_ambiguity_with_error.py index f132017..c71b57d 100644 --- a/examples/03_ambiguity_with_error.py +++ b/examples/03_ambiguity_with_error.py @@ -2,7 +2,10 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import create_engine, is_error +from context_compiler import ( + Engine, + is_error, +) def fake_llm(user_input: str) -> str: @@ -11,7 +14,7 @@ def fake_llm(user_input: str) -> str: def main() -> None: - engine = create_engine() + engine = Engine() print("User: prohibit peanuts") decision1 = engine.step("prohibit peanuts") diff --git a/examples/04_tool_governance_denylist.py b/examples/04_tool_governance_denylist.py index 214d323..61110d0 100644 --- a/examples/04_tool_governance_denylist.py +++ b/examples/04_tool_governance_denylist.py @@ -4,7 +4,7 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import create_engine +from context_compiler import Engine @dataclass @@ -21,7 +21,7 @@ def allow_tool(tool: Tool) -> None: def main() -> None: - engine = create_engine() + engine = Engine() user_input = "prohibit docker" print(f"User: {user_input}") diff --git a/examples/05_llm_integration_pattern.py b/examples/05_llm_integration_pattern.py index 3b3af6c..bdc2437 100644 --- a/examples/05_llm_integration_pattern.py +++ b/examples/05_llm_integration_pattern.py @@ -6,7 +6,6 @@ from context_compiler import ( Engine, - create_engine, is_error, is_no_directive, is_update, @@ -45,7 +44,7 @@ def handle_turn(engine_input: str, engine: Engine) -> None: def main() -> None: - engine = create_engine() + engine = Engine() handle_turn("hello there", engine) handle_turn("set premise concise replies", engine) diff --git a/examples/06_step_sequence_and_state_restore.py b/examples/06_step_sequence_and_state_restore.py index 6d0cc9f..636c7a4 100644 --- a/examples/06_step_sequence_and_state_restore.py +++ b/examples/06_step_sequence_and_state_restore.py @@ -2,11 +2,11 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import create_engine +from context_compiler import Engine def main() -> None: - engine = create_engine() + engine = Engine() turns = [ "prohibit peanuts", "set premise vegetarian curry", @@ -22,7 +22,7 @@ def main() -> None: # Hosts can persist authoritative state directly instead of replaying prior turns. state_json = engine.export_json() - restored = create_engine() + restored = Engine() restored.import_json(state_json) print("JSON restore keeps authority state:") diff --git a/examples/07_single_policy_correction.py b/examples/07_single_policy_correction.py index 9e4fc85..15746eb 100644 --- a/examples/07_single_policy_correction.py +++ b/examples/07_single_policy_correction.py @@ -2,11 +2,11 @@ from _util import print_decision_summary, print_engine_observations -from context_compiler import create_engine +from context_compiler import Engine def main() -> None: - engine = create_engine() + engine = Engine() print("User: prohibit peanuts") decision1 = engine.step("prohibit peanuts") diff --git a/pyproject.toml b/pyproject.toml index c9d98b2..8e662a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "context-compiler" -version = "0.9.0dev7" +version = "0.9.0dev8" description = "Deterministic conversational state engine for LLM applications." readme = "README.md" requires-python = ">=3.11" diff --git a/src/context_compiler/__init__.py b/src/context_compiler/__init__.py index 6cb8cd3..d9c0e91 100644 --- a/src/context_compiler/__init__.py +++ b/src/context_compiler/__init__.py @@ -18,7 +18,6 @@ DecisionKind, Engine, PolicyValue, - create_engine, ) __version__ = version("context-compiler") @@ -33,7 +32,6 @@ "POLICY_PROHIBIT", "POLICY_USE", "PolicyValue", - "create_engine", "get_error_message", "is_error", "is_no_directive", diff --git a/src/context_compiler/engine.py b/src/context_compiler/engine.py index e07b344..c326dcc 100644 --- a/src/context_compiler/engine.py +++ b/src/context_compiler/engine.py @@ -4,7 +4,6 @@ import re from collections.abc import Mapping from copy import deepcopy -from dataclasses import dataclass from enum import StrEnum from typing import Literal, TypedDict from unicodedata import normalize as unicode_normalize @@ -48,29 +47,7 @@ class Decision(TypedDict): message: str | None -@dataclass(frozen=True) -class _Action: - """Represent one parsed engine action before state validation or mutation.""" - - kind: Literal[ - "set_premise", - "change_premise", - "use_item", - "prohibit_item", - "remove_policy_item", - "replace_use", - "clear_premise", - "reset_policies", - "clear_state", - ] - value: str | None = None - item: str | None = None - new_item: str | None = None - old_item: str | None = None - - -@dataclass(frozen=True) -class _EvaluatedTransition: +class _EvaluatedTransition(TypedDict): decision: Decision next_state: _State @@ -78,12 +55,6 @@ class _EvaluatedTransition: _NO_DIRECTIVE: Decision = {"kind": DecisionKind.NO_DIRECTIVE, "message": None} -def create_engine() -> "Engine": - """Create an engine initialized to the empty authoritative state.""" - - return Engine() - - class Engine: """Own the authoritative state and apply one directive transition at a time.""" @@ -137,32 +108,31 @@ def apply_directive(self, directive: CanonicalDirective) -> Decision: """Evaluate and commit one canonical directive against authoritative state.""" evaluated = self._evaluate_directive_transition(self._state, directive) - self._replace_state(evaluated.next_state) - return evaluated.decision + self._replace_state(evaluated["next_state"]) + return evaluated["decision"] def _evaluate_directive_transition( self, state: _State, directive: CanonicalDirective ) -> _EvaluatedTransition: error_decision = self._pre_mutation_error(directive, state=state) if error_decision is not None: - return _EvaluatedTransition(decision=error_decision, next_state=deepcopy(state)) + return {"decision": error_decision, "next_state": deepcopy(state)} next_state = self._apply_directive(directive, state=state) - return _EvaluatedTransition(decision=_update_decision(next_state), next_state=next_state) + return {"decision": _update_decision(next_state), "next_state": next_state} def _replace_state(self, state: _State) -> None: self._state = state def _pre_mutation_error( - self, directive: _Action | CanonicalDirective, *, state: _State | None = None + self, directive: CanonicalDirective, *, state: _State | None = None ) -> Decision | None: candidate_state = self._state if state is None else state - action = directive if isinstance(directive, _Action) else _directive_to_action(directive) # Single error path: all error outcomes are detected before any mutation. - if action.kind in {"set_premise", "change_premise"}: - assert action.value is not None - if _sanitize_premise_value(action.value) == "": - if action.kind == "set_premise": + if directive.kind in {_DirectiveKind.SET_PREMISE, _DirectiveKind.CHANGE_PREMISE}: + value = directive.operands["value"] + if _sanitize_premise_value(value) == "": + if directive.kind is _DirectiveKind.SET_PREMISE: return _error( "Premise value cannot be empty.\n" "Use 'set premise ' with a non-empty value." @@ -172,56 +142,60 @@ def _pre_mutation_error( "Use 'change premise to ' with a non-empty value." ) - if action.kind == "remove_policy_item": - assert action.item is not None - if _normalize_item(action.item) == "": - return _error( - "Policy item cannot be empty.\n" - "Use 'remove policy ' with a non-empty value." - ) - - if action.kind == "use_item": - assert action.item is not None - if _normalize_item(action.item) == "": - return _error( - "Policy item cannot be empty.\nUse 'use ' with a non-empty value." - ) - - if action.kind == "prohibit_item": - assert action.item is not None - if _normalize_item(action.item) == "": - return _error( - "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." - ) - - if action.kind == "set_premise" and candidate_state[STATE_PREMISE] is not None: + if ( + directive.kind is _DirectiveKind.REMOVE_POLICY + and _normalize_item(directive.operands["item"]) == "" + ): + return _error( + "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." + ) + + if ( + directive.kind is _DirectiveKind.USE_ITEM + and _normalize_item(directive.operands["item"]) == "" + ): + return _error("Policy item cannot be empty.\nUse 'use ' with a non-empty value.") + + if ( + directive.kind is _DirectiveKind.PROHIBIT_ITEM + and _normalize_item(directive.operands["item"]) == "" + ): + return _error( + "Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value." + ) + + if ( + directive.kind is _DirectiveKind.SET_PREMISE + and candidate_state[STATE_PREMISE] is not None + ): return _error("Premise already set.\nUse 'change premise to ' to modify it.") - if action.kind == "change_premise" and candidate_state[STATE_PREMISE] is None: + if ( + directive.kind is _DirectiveKind.CHANGE_PREMISE + and candidate_state[STATE_PREMISE] is None + ): return _error("No premise is set.\nUse 'set premise ' to define one.") - if action.kind == "use_item": - assert action.item is not None - item_key = _normalize_item(action.item) + if directive.kind is _DirectiveKind.USE_ITEM: + item_key = _normalize_item(directive.operands["item"]) if candidate_state[STATE_POLICIES].get(item_key) == POLICY_PROHIBIT: return _error( f'"{item_key}" is currently prohibited.\nRemove or replace it before using it.' ) - if action.kind == "prohibit_item": - assert action.item is not None - item_key = _normalize_item(action.item) + if directive.kind is _DirectiveKind.PROHIBIT_ITEM: + item_key = _normalize_item(directive.operands["item"]) if candidate_state[STATE_POLICIES].get(item_key) == POLICY_USE: return _error( f'"{item_key}" is currently in use.\n' "Remove or replace it before prohibiting it." ) - if action.kind == "replace_use": - assert action.new_item is not None - assert action.old_item is not None - new_key = _normalize_item(action.new_item) - old_key = _normalize_item(action.old_item) + if directive.kind is _DirectiveKind.REPLACE_USE: + new_item = directive.operands["new_item"] + old_item = directive.operands["old_item"] + new_key = _normalize_item(new_item) + old_key = _normalize_item(old_item) if new_key == old_key: return None @@ -229,68 +203,63 @@ def _pre_mutation_error( new_state = candidate_state[STATE_POLICIES].get(new_key) if old_state == POLICY_PROHIBIT: return _error( - f'"{action.old_item}" is currently prohibited.\n' + f'"{old_item}" is currently prohibited.\n' "Submit explicit directive(s) to remove it or use a different item." ) if new_state == POLICY_PROHIBIT: return _error( - f'"{action.new_item}" is currently prohibited.\n' + f'"{new_item}" is currently prohibited.\n' "Submit explicit directive(s) to remove it or use a different item." ) if old_state not in {None, POLICY_USE}: return _error( - f'"{action.old_item}" is not currently in use.\n' + f'"{old_item}" is not currently in use.\n' "Replacement requires an active 'use' policy." ) return None - def _apply_directive(self, directive: _Action | CanonicalDirective, *, state: _State) -> _State: - action = directive if isinstance(directive, _Action) else _directive_to_action(directive) + def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _State: next_state = deepcopy(state) - kind = action.kind - if kind == "set_premise": - assert action.value is not None - next_state[STATE_PREMISE] = _sanitize_premise_value(action.value) + if directive.kind is _DirectiveKind.SET_PREMISE: + next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"]) return next_state - if kind == "change_premise": - assert action.value is not None - next_state[STATE_PREMISE] = _sanitize_premise_value(action.value) + if directive.kind is _DirectiveKind.CHANGE_PREMISE: + next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"]) return next_state - if kind == "use_item": - assert action.item is not None - item_key = _normalize_item(action.item) + if directive.kind is _DirectiveKind.USE_ITEM: + item_key = _normalize_item(directive.operands["item"]) # Idempotent directives are updates even if state does not change. next_state[STATE_POLICIES][item_key] = POLICY_USE return next_state - if kind == "prohibit_item": - assert action.item is not None - item_key = _normalize_item(action.item) + if directive.kind is _DirectiveKind.PROHIBIT_ITEM: + item_key = _normalize_item(directive.operands["item"]) # Idempotent directives are updates even if state does not change. next_state[STATE_POLICIES][item_key] = POLICY_PROHIBIT return next_state - if kind == "replace_use": - assert action.new_item is not None - assert action.old_item is not None - self._apply_replacement_explicit(next_state, action.new_item, action.old_item) + if directive.kind is _DirectiveKind.REPLACE_USE: + self._apply_replacement_explicit( + next_state, + directive.operands["new_item"], + directive.operands["old_item"], + ) return next_state - if kind == "remove_policy_item": - assert action.item is not None - item_key = _normalize_item(action.item) + if directive.kind is _DirectiveKind.REMOVE_POLICY: + item_key = _normalize_item(directive.operands["item"]) next_state[STATE_POLICIES].pop(item_key, None) return next_state - if kind == "clear_premise": + if directive.kind is _DirectiveKind.CLEAR_PREMISE: next_state[STATE_PREMISE] = None return next_state - if kind == "reset_policies": + if directive.kind is _DirectiveKind.RESET_POLICIES: next_state[STATE_POLICIES] = {} return next_state @@ -307,30 +276,6 @@ def _apply_replacement_explicit(self, state: _State, new_item: str, old_item: st state[STATE_POLICIES][new_key] = POLICY_USE -def _directive_to_action(parsed: CanonicalDirective) -> _Action: - if parsed.kind is _DirectiveKind.SET_PREMISE: - return _Action(kind="set_premise", value=parsed.operands["value"]) - if parsed.kind is _DirectiveKind.CHANGE_PREMISE: - return _Action(kind="change_premise", value=parsed.operands["value"]) - if parsed.kind is _DirectiveKind.USE_ITEM: - return _Action(kind="use_item", item=parsed.operands["item"]) - if parsed.kind is _DirectiveKind.PROHIBIT_ITEM: - return _Action(kind="prohibit_item", item=parsed.operands["item"]) - if parsed.kind is _DirectiveKind.REMOVE_POLICY: - return _Action(kind="remove_policy_item", item=parsed.operands["item"]) - if parsed.kind is _DirectiveKind.REPLACE_USE: - return _Action( - kind="replace_use", - new_item=parsed.operands["new_item"], - old_item=parsed.operands["old_item"], - ) - if parsed.kind is _DirectiveKind.CLEAR_PREMISE: - return _Action(kind="clear_premise") - if parsed.kind is _DirectiveKind.RESET_POLICIES: - return _Action(kind="reset_policies") - return _Action(kind="clear_state") - - def _initial_state() -> _State: return { STATE_PREMISE: None, diff --git a/src/context_compiler/repl.py b/src/context_compiler/repl.py index e75eda2..f5140a7 100644 --- a/src/context_compiler/repl.py +++ b/src/context_compiler/repl.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import TextIO -from . import __version__, create_engine +from . import __version__ from .const import STATE_POLICIES, STATE_PREMISE, STATE_VERSION from .decision_helpers import is_error, is_no_directive, is_update from .engine import Decision, DecisionKind, Engine, PolicyValue @@ -233,7 +233,7 @@ def run_repl( emit NDJSON records. """ - active_engine = create_engine() if engine is None else engine + active_engine = Engine() if engine is None else engine if _is_interactive(in_stream, out_stream): print("Context Compiler REPL (0.5). Type help for commands.", file=out_stream) @@ -420,7 +420,7 @@ def main() -> int: # pragma: no cover print("error: --json requires non-interactive stdin/stdout.", file=sys.stderr) return 1 - engine = create_engine() + engine = Engine() try: _apply_preload_from_options(engine, options) except (OSError, ValueError) as exc: diff --git a/tests/_api_contract_harness.py b/tests/_api_contract_harness.py index c9f5c03..7557038 100644 --- a/tests/_api_contract_harness.py +++ b/tests/_api_contract_harness.py @@ -45,7 +45,7 @@ def resolve_probe_value(value: object) -> object: _assert_closed_keys(value, {"fixture"}, "probe fixture") fixture = value["fixture"] if fixture == "empty_engine": - return context_compiler.create_engine() + return context_compiler.Engine() raise AssertionError(f"Unknown probe fixture: {fixture!r}") diff --git a/tests/fixtures/conformance/api/public-api-v2.json b/tests/fixtures/conformance/api/public-api-v2.json index de13350..add06ec 100644 --- a/tests/fixtures/conformance/api/public-api-v2.json +++ b/tests/fixtures/conformance/api/public-api-v2.json @@ -25,7 +25,6 @@ "POLICY_PROHIBIT", "POLICY_USE", "PolicyValue", - "create_engine", "get_error_message", "is_error", "is_no_directive", @@ -64,20 +63,6 @@ "PolicyValue": { "kind": "type_alias" }, - "create_engine": { - "kind": "callable", - "signature": { - "params": [] - }, - "shape_probes": [ - { - "kwargs": {}, - "return_shape": { - "kind": "engine_instance" - } - } - ] - }, "get_error_message": { "kind": "callable", "signature": { diff --git a/tests/test_04_grammar_edge_cases.py b/tests/test_04_grammar_edge_cases.py index c50bcf4..ef63c19 100644 --- a/tests/test_04_grammar_edge_cases.py +++ b/tests/test_04_grammar_edge_cases.py @@ -1,4 +1,8 @@ -from context_compiler import DECISION_NO_DIRECTIVE, DECISION_UPDATE, create_engine +from context_compiler import ( + DECISION_NO_DIRECTIVE, + DECISION_UPDATE, + Engine, +) def _observations(engine: object) -> tuple[object, object]: @@ -6,7 +10,7 @@ def _observations(engine: object) -> tuple[object, object]: def test_parser_trims_leading_space_for_canonical_directive() -> None: - engine = create_engine() + engine = Engine() decision = engine.step(" set premise concise") @@ -18,7 +22,7 @@ def test_parser_trims_leading_space_for_canonical_directive() -> None: def test_parser_does_not_accept_conversational_aliases() -> None: - engine = create_engine() + engine = Engine() for text in [ "actually use docker", @@ -37,7 +41,7 @@ def test_parser_does_not_accept_conversational_aliases() -> None: def test_empty_policy_payloads_and_incomplete_replacement_remain_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in ["use", "use ", "use "]: @@ -63,7 +67,7 @@ def test_empty_policy_payloads_and_incomplete_replacement_remain_no_directive() def test_lexical_normalization_and_non_directive_near_misses() -> None: - engine = create_engine() + engine = Engine() assert engine.step("clear premise ")["kind"] == DECISION_UPDATE assert engine.step("reset policies ")["kind"] == DECISION_UPDATE assert engine.step("clear state ")["kind"] == DECISION_UPDATE @@ -77,7 +81,7 @@ def test_lexical_normalization_and_non_directive_near_misses() -> None: def test_premise_to_variant_near_misses_remain_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) set_variant = engine.step("set premise to concise") @@ -89,7 +93,7 @@ def test_premise_to_variant_near_misses_remain_no_directive() -> None: def test_remove_policy_missing_or_whitespace_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) first = engine.step("remove policy") @@ -101,7 +105,7 @@ def test_remove_policy_missing_or_whitespace_payload_remains_no_directive() -> N def test_invalid_replacement_does_not_block_following_directives() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") assert first["kind"] == DECISION_UPDATE @@ -114,7 +118,7 @@ def test_invalid_replacement_does_not_block_following_directives() -> None: def test_replace_update_independent_followup_is_no_directive() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") second = engine.step("sounds good") diff --git a/tests/test_api_contract_fixture.py b/tests/test_api_contract_fixture.py index cd2690b..82172d9 100644 --- a/tests/test_api_contract_fixture.py +++ b/tests/test_api_contract_fixture.py @@ -53,7 +53,7 @@ def test_api_contract_fixture_matches_python_public_surface() -> None: result = exported(*args, **kwargs) assert_shape(result, probe["return_shape"], contract) - engine = context_compiler.create_engine() + engine = context_compiler.Engine() engine_contract = contract["engine"]["public_members"] expected_members = engine_contract["members"] diff --git a/tests/test_compound_directive_properties.py b/tests/test_compound_directive_properties.py index a68d830..98b8951 100644 --- a/tests/test_compound_directive_properties.py +++ b/tests/test_compound_directive_properties.py @@ -3,7 +3,12 @@ from hypothesis import assume, given, settings from hypothesis import strategies as st -from context_compiler import DECISION_ERROR, DECISION_NO_DIRECTIVE, DECISION_UPDATE, create_engine +from context_compiler import ( + DECISION_ERROR, + DECISION_NO_DIRECTIVE, + DECISION_UPDATE, + Engine, +) CANONICAL_SECOND_DIRECTIVES = [ "set premise concise", @@ -37,7 +42,7 @@ def _observations(engine: object) -> tuple[object, object]: def _assert_compound_no_directive(user_input: str) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step(user_input) @@ -81,7 +86,7 @@ def test_compound_arbitrary_intervening_text(chunks: list[str], second: str) -> def test_embedded_canonical_tokens_do_not_trigger_compound_detection( token: str, prefix: str, suffix: str ) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step(f"use docker {prefix}{token}{suffix}") @@ -103,7 +108,7 @@ def test_embedded_canonical_tokens_do_not_trigger_compound_detection( def test_leading_non_directive_text_disables_compound_detection(prefix: str, second: str) -> None: assume(not any(prefix.startswith(token) for token in CANONICAL_STARTS)) - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step(f"{prefix} use docker {second}") @@ -123,7 +128,7 @@ def _mutate_case(text: str) -> st.SearchStrategy[str]: def test_case_mutated_second_directive_does_not_trigger_compound_detection( second_start: str, ) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step(f"use docker {second_start}") @@ -155,7 +160,7 @@ def test_quotes_do_not_create_protected_region_after_first_directive( second=st.sampled_from(CANONICAL_SECOND_DIRECTIVES), ) def test_fully_quoted_input_remains_no_directive(quote: str, second: str) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step(f"{quote}use docker {second}{quote}") diff --git a/tests/test_demo_01_04_behavior.py b/tests/test_demo_01_04_behavior.py index 300cc47..7cb8662 100644 --- a/tests/test_demo_01_04_behavior.py +++ b/tests/test_demo_01_04_behavior.py @@ -93,7 +93,7 @@ def fake_complete_messages(_messages: object) -> str: return "ACTION:proceed" return "ACTION:error" - monkeypatch.setattr(module, "create_engine", _FakeEngine) + monkeypatch.setattr(module, "Engine", _FakeEngine) monkeypatch.setattr(module, "complete_messages", fake_complete_messages) module.main() @@ -123,7 +123,7 @@ def step(self, _text: str) -> dict[str, str]: return {"kind": DECISION_UPDATE} return {"kind": DECISION_NO_DIRECTIVE, "message": None} - monkeypatch.setattr(module, "create_engine", _FakeEngine) + monkeypatch.setattr(module, "Engine", _FakeEngine) monkeypatch.setattr( module, "complete_messages", diff --git a/tests/test_demo_08_09_behavior.py b/tests/test_demo_08_09_behavior.py index aae4fc5..2f78e54 100644 --- a/tests/test_demo_08_09_behavior.py +++ b/tests/test_demo_08_09_behavior.py @@ -71,7 +71,7 @@ def test_demo_08_reinjected_path_does_not_instantiate_engine( ) -> None: module = _load_demo_module("08_llm_replacement_precondition.py") - original_create_engine = module.create_engine + original_engine_class = module.Engine class _EngineWrapper: reinjected_seen = False @@ -87,9 +87,9 @@ def step(self, text: str) -> dict[str, object]: _EngineWrapper.reinjected_seen = False return self._inner.step(text) - engine = _EngineWrapper(original_create_engine()) + engine = _EngineWrapper(original_engine_class()) - monkeypatch.setattr(module, "create_engine", lambda: engine) + monkeypatch.setattr(module, "Engine", lambda: engine) original_build_reinjected_messages = module.build_reinjected_messages @@ -138,25 +138,25 @@ def test_demo_09_reports_independent_followup_no_directive_boundary( assert "compiler: PASS" in output -def test_demo_09_reinjected_path_does_not_call_create_engine( +def test_demo_09_reinjected_path_does_not_call_engine_constructor( monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_demo_module("09_llm_confirmation_no_directive.py") - original_create_engine = module.create_engine - create_engine_calls = 0 + original_engine_class = module.Engine + engine_class_calls = 0 - def wrapped_create_engine() -> object: - nonlocal create_engine_calls - create_engine_calls += 1 - return original_create_engine() + def wrapped_engine_class() -> object: + nonlocal engine_class_calls + engine_class_calls += 1 + return original_engine_class() - monkeypatch.setattr(module, "create_engine", wrapped_create_engine) + monkeypatch.setattr(module, "Engine", wrapped_engine_class) monkeypatch.setattr(module, "complete_messages", _sequenced_outputs(["x", "y"])) module.main() report = consume_last_report() - assert create_engine_calls == 1 + assert engine_class_calls == 1 assert report is not None assert report["reinjected_state_pass"] is False diff --git a/tests/test_engine.py b/tests/test_engine.py index 98eec3f..2f1d7f8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -4,10 +4,13 @@ import pytest -from context_compiler import DECISION_ERROR, DECISION_NO_DIRECTIVE, DECISION_UPDATE, create_engine +from context_compiler import ( + DECISION_ERROR, + DECISION_NO_DIRECTIVE, + DECISION_UPDATE, + Engine, +) from context_compiler.engine import ( - _Action, - _directive_to_action, _load_state_obj, ) from context_compiler.grammar import ( @@ -37,61 +40,194 @@ def _import_state(engine: object, payload: dict[str, object]) -> None: engine.import_json(json.dumps(payload, sort_keys=True, separators=(",", ":"))) -def test_directive_to_action_delegates_canonical_kinds_to_existing_actions() -> None: - parsed = decompose_directive("set premise concise replies") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="set_premise", value="concise replies") - parsed = decompose_directive("change premise to concise replies") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="change_premise", value="concise replies") - parsed = decompose_directive("use docker") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="use_item", item="docker") - parsed = decompose_directive("prohibit peanuts") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="prohibit_item", item="peanuts") - parsed = decompose_directive("remove policy docker") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="remove_policy_item", item="docker") - parsed = decompose_directive("use podman instead of docker") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action( - kind="replace_use", new_item="podman", old_item="docker" - ) - parsed = decompose_directive("clear premise") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="clear_premise") - parsed = decompose_directive("reset policies") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="reset_policies") - parsed = decompose_directive("clear state") - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action(kind="clear_state") +def _canonical_directive( + text: str, + kind: _DirectiveKind, + **operands: str, +) -> CanonicalDirective: + return CanonicalDirective(text=text, kind=kind, operands=MappingProxyType(operands)) -def test_engine_directive_to_action_matches_public_decomposition_boundary() -> None: - parsed = decompose_directive("use podman instead of docker") +@pytest.mark.parametrize( + ("directive", "initial_state", "expected_decision", "expected_state"), + [ + ( + _canonical_directive( + "set premise concise replies", + _DirectiveKind.SET_PREMISE, + value="concise replies", + ), + None, + {"kind": DECISION_UPDATE, "message": None}, + (("concise replies"), {}), + ), + ( + _canonical_directive( + "change premise to concise replies", + _DirectiveKind.CHANGE_PREMISE, + value="concise replies", + ), + {"premise": "verbose replies", "policies": {}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + (("concise replies"), {}), + ), + ( + _canonical_directive("use docker", _DirectiveKind.USE_ITEM, item="docker"), + None, + {"kind": DECISION_UPDATE, "message": None}, + (None, {"docker": "use"}), + ), + ( + _canonical_directive("prohibit peanuts", _DirectiveKind.PROHIBIT_ITEM, item="peanuts"), + None, + {"kind": DECISION_UPDATE, "message": None}, + (None, {"peanuts": "prohibit"}), + ), + ( + _canonical_directive( + "remove policy docker", + _DirectiveKind.REMOVE_POLICY, + item="docker", + ), + {"premise": None, "policies": {"docker": "use"}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + (None, {}), + ), + ( + _canonical_directive( + "use podman instead of docker", + _DirectiveKind.REPLACE_USE, + new_item="podman", + old_item="docker", + ), + {"premise": None, "policies": {"docker": "use"}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + (None, {"podman": "use"}), + ), + ( + _canonical_directive("clear premise", _DirectiveKind.CLEAR_PREMISE), + {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + (None, {"docker": "use"}), + ), + ( + _canonical_directive("reset policies", _DirectiveKind.RESET_POLICIES), + {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + ("concise replies", {}), + ), + ( + _canonical_directive("clear state", _DirectiveKind.CLEAR_STATE), + {"premise": "concise replies", "policies": {"docker": "use"}, "version": 2}, + {"kind": DECISION_UPDATE, "message": None}, + (None, {}), + ), + ], +) +def test_apply_directive_accepts_all_canonical_directive_kinds( + directive: CanonicalDirective, + initial_state: dict[str, object] | None, + expected_decision: dict[str, str | None], + expected_state: tuple[str | None, dict[str, str]], +) -> None: + engine = Engine() + if initial_state is not None: + _import_state(engine, initial_state) - assert isinstance(parsed, CanonicalDirective) - assert _directive_to_action(parsed) == _Action( - kind="replace_use", - new_item=parsed.operands["new_item"], - old_item=parsed.operands["old_item"], - ) + decision = engine.apply_directive(directive) + assert decision == expected_decision + _assert_observations(engine, premise=expected_state[0], policies=expected_state[1]) -def test_directive_to_action_uses_canonical_operands_from_decompose_directive() -> None: - parsed = CanonicalDirective( - text="use docker", - kind=_DirectiveKind.USE_ITEM, - operands=MappingProxyType({"item": "docker"}), - ) - assert _directive_to_action(parsed) == _Action(kind="use_item", item="docker") +@pytest.mark.parametrize( + ("directive", "initial_state", "expected_decision", "expected_state"), + [ + ( + _canonical_directive( + "set premise concise replies", + _DirectiveKind.SET_PREMISE, + value="concise replies", + ), + {"premise": "existing premise", "policies": {}, "version": 2}, + { + "kind": DECISION_ERROR, + "message": "Premise already set.\nUse 'change premise to ' to modify it.", + }, + ("existing premise", {}), + ), + ( + _canonical_directive( + "change premise to concise replies", + _DirectiveKind.CHANGE_PREMISE, + value="concise replies", + ), + None, + { + "kind": DECISION_ERROR, + "message": "No premise is set.\nUse 'set premise ' to define one.", + }, + (None, {}), + ), + ( + _canonical_directive("use docker", _DirectiveKind.USE_ITEM, item="docker"), + {"premise": None, "policies": {"docker": "prohibit"}, "version": 2}, + { + "kind": DECISION_ERROR, + "message": ( + '"docker" is currently prohibited.\nRemove or replace it before using it.' + ), + }, + (None, {"docker": "prohibit"}), + ), + ( + _canonical_directive("prohibit docker", _DirectiveKind.PROHIBIT_ITEM, item="docker"), + {"premise": None, "policies": {"docker": "use"}, "version": 2}, + { + "kind": DECISION_ERROR, + "message": ( + '"docker" is currently in use.\nRemove or replace it before prohibiting it.' + ), + }, + (None, {"docker": "use"}), + ), + ( + _canonical_directive( + "use docker instead of kubectl", + _DirectiveKind.REPLACE_USE, + new_item="docker", + old_item="kubectl", + ), + {"premise": None, "policies": {"docker": "prohibit"}, "version": 2}, + { + "kind": DECISION_ERROR, + "message": ( + '"docker" is currently prohibited.\n' + "Submit explicit directive(s) to remove it or use a different item." + ), + }, + (None, {"docker": "prohibit"}), + ), + ], +) +def test_apply_directive_preserves_state_for_semantic_errors( + directive: CanonicalDirective, + initial_state: dict[str, object] | None, + expected_decision: dict[str, str | None], + expected_state: tuple[str | None, dict[str, str]], +) -> None: + engine = Engine() + if initial_state is not None: + _import_state(engine, initial_state) + + decision = engine.apply_directive(directive) + + assert decision == expected_decision + _assert_observations(engine, premise=expected_state[0], policies=expected_state[1]) def test_apply_directive_updates_state_from_canonical_directive() -> None: - engine = create_engine() + engine = Engine() parsed = decompose_directive("use docker") assert isinstance(parsed, CanonicalDirective) @@ -104,7 +240,7 @@ def test_apply_directive_updates_state_from_canonical_directive() -> None: def test_step_routes_canonical_directives_through_apply_directive( monkeypatch: pytest.MonkeyPatch, ) -> None: - engine = create_engine() + engine = Engine() parsed = decompose_directive("use docker") assert isinstance(parsed, CanonicalDirective) @@ -137,50 +273,75 @@ def test_decompose_directive_rejects_invalid_and_nondirective_inputs() -> None: def test_pre_mutation_error_empty_operand_branches_remain_stable() -> None: - engine = create_engine() + engine = Engine() + set_premise = CanonicalDirective( + text="set premise ", + kind=_DirectiveKind.SET_PREMISE, + operands=MappingProxyType({"value": ""}), + ) + change_premise = CanonicalDirective( + text="change premise to ", + kind=_DirectiveKind.CHANGE_PREMISE, + operands=MappingProxyType({"value": ""}), + ) + remove_policy = CanonicalDirective( + text="remove policy ", + kind=_DirectiveKind.REMOVE_POLICY, + operands=MappingProxyType({"item": ""}), + ) + use_item = CanonicalDirective( + text="use ", + kind=_DirectiveKind.USE_ITEM, + operands=MappingProxyType({"item": ""}), + ) + prohibit_item = CanonicalDirective( + text="prohibit ", + kind=_DirectiveKind.PROHIBIT_ITEM, + operands=MappingProxyType({"item": ""}), + ) - assert engine._pre_mutation_error(_Action(kind="set_premise", value="")) == { + assert engine._pre_mutation_error(set_premise) == { # noqa: SLF001 "kind": "error", "message": ( "Premise value cannot be empty.\nUse 'set premise ' with a non-empty value." ), } - assert engine._pre_mutation_error(_Action(kind="change_premise", value="")) == { + assert engine._pre_mutation_error(change_premise) == { # noqa: SLF001 "kind": "error", "message": ( "Premise value cannot be empty.\n" "Use 'change premise to ' with a non-empty value." ), } - assert engine._pre_mutation_error(_Action(kind="remove_policy_item", item="")) == { + assert engine._pre_mutation_error(remove_policy) == { # noqa: SLF001 "kind": "error", "message": ( "Policy item cannot be empty.\nUse 'remove policy ' with a non-empty value." ), } - assert engine._pre_mutation_error(_Action(kind="use_item", item="")) == { + assert engine._pre_mutation_error(use_item) == { # noqa: SLF001 "kind": "error", "message": "Policy item cannot be empty.\nUse 'use ' with a non-empty value.", } - assert engine._pre_mutation_error(_Action(kind="prohibit_item", item="")) == { + assert engine._pre_mutation_error(prohibit_item) == { # noqa: SLF001 "kind": "error", "message": ("Policy item cannot be empty.\nUse 'prohibit ' with a non-empty value."), } def test_initial_state_and_engine_properties() -> None: - engine = create_engine() + engine = Engine() _assert_observations(engine, premise=None, policies={}) def test_policies_property_returns_mapping_snapshot() -> None: - engine = create_engine() + engine = Engine() assert isinstance(engine.policies, Mapping) assert engine.policies == {} def test_premise_property_exposes_authoritative_premise_value() -> None: - engine = create_engine() + engine = Engine() assert engine.premise is None engine.step("set premise concise replies") @@ -189,7 +350,7 @@ def test_premise_property_exposes_authoritative_premise_value() -> None: def test_policies_property_returns_defensive_copy() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") policies = engine.policies @@ -200,13 +361,13 @@ def test_policies_property_returns_defensive_copy() -> None: def test_export_json_returns_complete_representation_of_state() -> None: - engine = create_engine() + engine = Engine() payload = engine.export_json() assert json.loads(payload) == {"premise": None, "policies": {}, "version": 2} def test_export_json_is_canonical_sorted_and_compact() -> None: - engine = create_engine() + engine = Engine() engine.step("use zeta") engine.step("use alpha") payload = engine.export_json() @@ -215,7 +376,7 @@ def test_export_json_is_canonical_sorted_and_compact() -> None: def test_import_json_restores_state_exactly() -> None: - engine = create_engine() + engine = Engine() expected = { "premise": "Use concise output", "policies": {"docker": "prohibit", "pytest": "use"}, @@ -232,7 +393,7 @@ def test_import_json_restores_state_exactly() -> None: def test_export_import_round_trip_preserves_state() -> None: - source = create_engine() + source = Engine() _import_state( source, { @@ -242,14 +403,14 @@ def test_export_import_round_trip_preserves_state() -> None: }, ) - target = create_engine() + target = Engine() target.import_json(source.export_json()) assert _observations(target) == _observations(source) def test_import_json_invalid_json_and_unsupported_version_are_rejected() -> None: - engine = create_engine() + engine = Engine() with pytest.raises(ValueError, match="Invalid JSON payload"): engine.import_json("{") @@ -267,7 +428,7 @@ def test_import_json_invalid_json_and_unsupported_version_are_rejected() -> None def test_import_json_rejects_non_object_payload() -> None: - engine = create_engine() + engine = Engine() with pytest.raises(ValueError, match="Invalid state payload"): engine.import_json('["not", "an", "object"]') @@ -294,7 +455,7 @@ def test_internal_state_loader_rejects_non_string_policy_keys() -> None: def test_import_json_rejects_policy_keys_that_normalize_to_empty( policies: dict[str, str], ) -> None: - engine = create_engine() + engine = Engine() with pytest.raises(ValueError, match="Invalid state payload"): engine.import_json( json.dumps( @@ -308,7 +469,7 @@ def test_import_json_rejects_policy_keys_that_normalize_to_empty( def test_import_json_rejects_empty_normalized_key_atomically() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl") before = _observations(engine) @@ -327,7 +488,7 @@ def test_import_json_rejects_empty_normalized_key_atomically() -> None: def test_import_json_accepts_valid_policy_key_and_normalizes_it() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( @@ -343,9 +504,9 @@ def test_import_json_accepts_valid_policy_key_and_normalizes_it() -> None: def test_replace_use_clarifies_when_old_policy_is_not_use_in_invalid_internal_state() -> None: - engine = create_engine() + engine = Engine() # Defensive-path coverage for impossible external state values. - engine._state["policies"]["docker"] = "invalid" # type: ignore[assignment] + engine._state["policies"]["docker"] = "invalid" # type: ignore[assignment] # noqa: SLF001 decision = engine.step("use kubectl instead of docker") @@ -367,13 +528,13 @@ def test_replace_use_clarifies_when_old_policy_is_not_use_in_invalid_internal_st ], ) def test_import_json_rejects_structurally_invalid_payload(payload: dict[str, object]) -> None: - engine = create_engine() + engine = Engine() with pytest.raises(ValueError): engine.import_json(json.dumps(payload)) def test_import_json_normalizes_policy_keys() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( { @@ -395,7 +556,7 @@ def test_import_json_normalizes_policy_keys() -> None: def test_import_json_sanitizes_premise_value() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( { @@ -410,7 +571,7 @@ def test_import_json_sanitizes_premise_value() -> None: def test_import_json_canonicalizes_policies_by_normalized_key() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( { @@ -428,7 +589,7 @@ def test_import_json_canonicalizes_policies_by_normalized_key() -> None: def test_non_matching_input_is_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in [ @@ -447,7 +608,7 @@ def test_non_matching_input_is_no_directive() -> None: def test_lexical_normalization_accepts_canonical_directives() -> None: - engine = create_engine() + engine = Engine() assert engine.step("clear premise ")["kind"] == DECISION_UPDATE assert engine.step(" reset policies")["kind"] == DECISION_UPDATE @@ -458,7 +619,7 @@ def test_lexical_normalization_accepts_canonical_directives() -> None: def test_clear_premise_is_idempotent_update_when_already_null() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step("clear premise") @@ -467,7 +628,7 @@ def test_clear_premise_is_idempotent_update_when_already_null() -> None: def test_clear_state_is_idempotent_update_when_already_empty() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step("clear state") @@ -476,7 +637,7 @@ def test_clear_state_is_idempotent_update_when_already_empty() -> None: def test_set_premise_lifecycle_rules() -> None: - engine = create_engine() + engine = Engine() d1 = engine.step("set premise concise replies") assert d1["kind"] == DECISION_UPDATE @@ -492,7 +653,7 @@ def test_set_premise_lifecycle_rules() -> None: def test_set_premise_empty_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) d1 = engine.step("set premise") assert d1 == {"kind": DECISION_NO_DIRECTIVE, "message": None} @@ -500,7 +661,7 @@ def test_set_premise_empty_payload_remains_no_directive() -> None: def test_set_premise_whitespace_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) d1 = engine.step("set premise ") assert d1 == {"kind": DECISION_NO_DIRECTIVE, "message": None} @@ -508,7 +669,7 @@ def test_set_premise_whitespace_payload_remains_no_directive() -> None: def test_set_premise_to_variant_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() decision = engine.step("set premise to concise replies") assert decision == {"kind": DECISION_NO_DIRECTIVE, "message": None} @@ -516,7 +677,7 @@ def test_set_premise_to_variant_remains_no_directive() -> None: def test_set_premise_to_with_whitespace_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() decision = engine.step("set premise to ") @@ -525,7 +686,7 @@ def test_set_premise_to_with_whitespace_payload_remains_no_directive() -> None: def test_change_premise_requires_existing_premise() -> None: - engine = create_engine() + engine = Engine() d1 = engine.step("change premise to concise") assert d1 == { @@ -541,7 +702,7 @@ def test_change_premise_requires_existing_premise() -> None: def test_change_premise_to_empty_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("set premise baseline") before = _observations(engine) @@ -551,7 +712,7 @@ def test_change_premise_to_empty_payload_remains_no_directive() -> None: def test_change_premise_to_without_space_payload_and_empty_variant_remain_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("set premise baseline") before = _observations(engine) @@ -564,7 +725,7 @@ def test_change_premise_to_without_space_payload_and_empty_variant_remain_no_dir def test_change_premise_to_whitespace_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("set premise baseline") before = _observations(engine) @@ -574,7 +735,7 @@ def test_change_premise_to_whitespace_payload_remains_no_directive() -> None: def test_change_premise_missing_to_variant_is_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step("change premise concise replies") @@ -583,7 +744,7 @@ def test_change_premise_missing_to_variant_is_no_directive() -> None: def test_change_premise_with_whitespace_after_prefix_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() _import_state(engine, {"premise": "baseline", "policies": {}, "version": 2}) decision = engine.step("change premise ") @@ -593,7 +754,7 @@ def test_change_premise_with_whitespace_after_prefix_remains_no_directive() -> N def test_canonical_premise_forms_still_update_normally() -> None: - engine = create_engine() + engine = Engine() first = engine.step("set premise concise replies") second = engine.step("change premise to concise bullet points") @@ -604,7 +765,7 @@ def test_canonical_premise_forms_still_update_normally() -> None: def test_clear_premise_and_clear_state() -> None: - engine = create_engine() + engine = Engine() engine.step("set premise use bullets") engine.step("use docker") @@ -618,7 +779,7 @@ def test_clear_premise_and_clear_state() -> None: def test_policy_directives_and_idempotent_update() -> None: - engine = create_engine() + engine = Engine() d1 = engine.step("use The Docker") assert d1["kind"] == DECISION_UPDATE @@ -635,7 +796,7 @@ def test_policy_directives_and_idempotent_update() -> None: ) assert dict(engine.policies) == {"docker": "use", "the docker": "use"} - engine2 = create_engine() + engine2 = Engine() engine2.step("prohibit docker") d4 = engine2.step("prohibit docker") assert d4["kind"] == "update" @@ -650,7 +811,7 @@ def test_policy_directives_and_idempotent_update() -> None: def test_use_empty_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in ["use", "use ", "use "]: @@ -660,7 +821,7 @@ def test_use_empty_payload_remains_no_directive() -> None: def test_prohibit_empty_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in ["prohibit", "prohibit ", "prohibit "]: @@ -670,7 +831,7 @@ def test_prohibit_empty_payload_remains_no_directive() -> None: def test_replace_use_incomplete_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in [ @@ -686,7 +847,7 @@ def test_replace_use_incomplete_payload_remains_no_directive() -> None: def test_reset_policies_is_update_even_when_already_empty() -> None: - engine = create_engine() + engine = Engine() d1 = engine.step("reset policies") assert d1["kind"] == DECISION_UPDATE _assert_observations(engine, premise=None, policies={}) @@ -698,7 +859,7 @@ def test_reset_policies_is_update_even_when_already_empty() -> None: def test_remove_policy_removes_existing_use_policy() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") decision = engine.step("remove policy docker") @@ -708,7 +869,7 @@ def test_remove_policy_removes_existing_use_policy() -> None: def test_remove_policy_removes_existing_prohibit_policy() -> None: - engine = create_engine() + engine = Engine() engine.step("prohibit docker") decision = engine.step("remove policy docker") @@ -718,7 +879,7 @@ def test_remove_policy_removes_existing_prohibit_policy() -> None: def test_remove_policy_missing_item_is_idempotent_update() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") before = _observations(engine) @@ -729,7 +890,7 @@ def test_remove_policy_missing_item_is_idempotent_update() -> None: def test_remove_policy_empty_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step("remove policy") @@ -739,7 +900,7 @@ def test_remove_policy_empty_payload_remains_no_directive() -> None: def test_remove_policy_whitespace_payload_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) decision = engine.step("remove policy ") @@ -749,7 +910,7 @@ def test_remove_policy_whitespace_payload_remains_no_directive() -> None: def test_replace_use_success() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") decision = engine.step("use kubectl instead of docker") @@ -759,7 +920,7 @@ def test_replace_use_success() -> None: def test_replace_use_identity_is_noop_update() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") decision = engine.step("use Docker instead of docker") @@ -769,7 +930,7 @@ def test_replace_use_identity_is_noop_update() -> None: def test_replace_use_identity_case_variant_is_noop_update() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") decision = engine.step("use DOCKER instead of docker") @@ -779,7 +940,7 @@ def test_replace_use_identity_case_variant_is_noop_update() -> None: def test_replace_use_missing_source_applies_as_use_update() -> None: - engine = create_engine() + engine = Engine() d1 = engine.step("use kubectl instead of docker") assert d1 == { @@ -790,7 +951,7 @@ def test_replace_use_missing_source_applies_as_use_update() -> None: def test_replace_use_missing_source_yes_followup_is_no_directive() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") assert first == { @@ -805,7 +966,7 @@ def test_replace_use_missing_source_yes_followup_is_no_directive() -> None: def test_replace_use_missing_source_no_followup_has_no_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) @@ -817,7 +978,7 @@ def test_replace_use_missing_source_no_followup_has_no_mutation() -> None: def test_replace_use_missing_source_still_reports_target_prohibit_when_new_item_prohibited() -> ( None ): - engine = create_engine() + engine = Engine() engine.step("prohibit kubectl") decision = engine.step("use kubectl instead of docker") @@ -831,7 +992,7 @@ def test_replace_use_missing_source_still_reports_target_prohibit_when_new_item_ def test_replace_use_missing_source_ignores_unrelated_existing_policies() -> None: - engine = create_engine() + engine = Engine() engine.step("use python and docker") decision = engine.step("use kubectl instead of python") @@ -840,7 +1001,7 @@ def test_replace_use_missing_source_ignores_unrelated_existing_policies() -> Non def test_replace_use_missing_source_ignores_other_conflicting_entries() -> None: - engine = create_engine() + engine = Engine() engine.step("use python and docker") engine.step("prohibit python tooling") @@ -854,7 +1015,7 @@ def test_replace_use_missing_source_ignores_other_conflicting_entries() -> None: def test_replace_use_missing_source_with_empty_probe_uses_invalid_prompt() -> None: - engine = create_engine() + engine = Engine() engine.step("use python and docker") decision = engine.step("use kubectl instead of the") @@ -867,7 +1028,7 @@ def test_replace_use_missing_source_with_empty_probe_uses_invalid_prompt() -> No def test_replace_use_ky_prohibit_returns_error_without_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("prohibit docker") engine.step("use pytest") @@ -884,7 +1045,7 @@ def test_replace_use_ky_prohibit_returns_error_without_mutation() -> None: def test_replace_use_ky_prohibit_yes_does_not_authorize_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("prohibit docker") engine.step("use pytest") first = engine.step("use kubectl instead of docker") @@ -897,7 +1058,7 @@ def test_replace_use_ky_prohibit_yes_does_not_authorize_mutation() -> None: def test_replace_use_kx_prohibit_returns_error_without_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") engine.step("prohibit kubectl") @@ -914,7 +1075,7 @@ def test_replace_use_kx_prohibit_returns_error_without_mutation() -> None: def test_replace_use_priority_prefers_source_prohibit_error_when_both_prohibit() -> None: - engine = create_engine() + engine = Engine() engine.step("prohibit docker") engine.step("prohibit kubectl") @@ -931,7 +1092,7 @@ def test_replace_use_priority_prefers_source_prohibit_error_when_both_prohibit() def test_replace_use_invalid_source_state_prohibit_clarifies_without_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("prohibit docker") engine.step("use pytest") before = _observations(engine) @@ -948,7 +1109,7 @@ def test_replace_use_invalid_source_state_prohibit_clarifies_without_mutation() def test_replace_use_kx_prohibit_no_followup_has_no_mutation() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") engine.step("prohibit kubectl") first = engine.step("use kubectl instead of docker") @@ -961,7 +1122,7 @@ def test_replace_use_kx_prohibit_no_followup_has_no_mutation() -> None: def test_missing_source_replacement_does_not_block_following_directives() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") assert first["kind"] == "update" @@ -975,7 +1136,7 @@ def test_missing_source_replacement_does_not_block_following_directives() -> Non def test_missing_source_replacement_does_not_suspend_admin_commands() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = (None, {"kubectl": "use"}) @@ -991,7 +1152,7 @@ def test_missing_source_replacement_does_not_suspend_admin_commands() -> None: def test_missing_source_replacement_negative_followup_is_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") decision = engine.step("no") @@ -1001,7 +1162,7 @@ def test_missing_source_replacement_negative_followup_is_no_directive() -> None: def test_missing_source_replacement_affirmative_followup_tokens_are_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") decision = engine.step(" YES!!! ") @@ -1011,7 +1172,7 @@ def test_missing_source_replacement_affirmative_followup_tokens_are_no_directive def test_missing_source_replacement_affirmative_token_variants_are_no_directive() -> None: for token in ["yes please", "Yep", "yeah", "ok", " OKAY... ", "sure!"]: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") decision = engine.step(token) assert decision["kind"] == DECISION_NO_DIRECTIVE @@ -1019,7 +1180,7 @@ def test_missing_source_replacement_affirmative_token_variants_are_no_directive( def test_missing_source_replacement_negative_tokens_are_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) @@ -1029,7 +1190,7 @@ def test_missing_source_replacement_negative_tokens_are_no_directive() -> None: def test_missing_source_replacement_no_thanks_is_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) @@ -1040,7 +1201,7 @@ def test_missing_source_replacement_no_thanks_is_no_directive() -> None: def test_missing_source_replacement_negative_token_variants_are_no_directive() -> None: for token in ["nope", "Nope??", " no ", "NO THANKS!"]: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) decision = engine.step(token) @@ -1049,7 +1210,7 @@ def test_missing_source_replacement_negative_token_variants_are_no_directive() - def test_missing_source_replacement_unmatched_followup_is_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) @@ -1059,7 +1220,7 @@ def test_missing_source_replacement_unmatched_followup_is_no_directive() -> None def test_missing_source_replacement_unmatched_followups_remain_no_directive() -> None: - engine = create_engine() + engine = Engine() engine.step("use kubectl instead of docker") before = _observations(engine) @@ -1069,7 +1230,7 @@ def test_missing_source_replacement_unmatched_followups_remain_no_directive() -> def test_prohibited_replacement_yes_cannot_override_conflicting_target_polarity() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") engine.step("prohibit kubectl") @@ -1083,7 +1244,7 @@ def test_prohibited_replacement_yes_cannot_override_conflicting_target_polarity( def test_import_json_does_not_change_independent_yes_no_followup_behavior() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") assert first["kind"] == DECISION_UPDATE @@ -1099,7 +1260,7 @@ def test_import_json_does_not_change_independent_yes_no_followup_behavior() -> N def test_remove_policy_uses_normalized_item_matching() -> None: - engine = create_engine() + engine = Engine() engine.step("use The Docker") decision = engine.step("remove policy the docker") @@ -1108,7 +1269,7 @@ def test_remove_policy_uses_normalized_item_matching() -> None: def test_use_and_prohibit_article_variants_remain_distinct_policies() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use docker") second = engine.step("prohibit the docker") @@ -1123,7 +1284,7 @@ def test_use_and_prohibit_article_variants_remain_distinct_policies() -> None: def test_remove_policy_the_docker_does_not_remove_docker() -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") decision = engine.step("remove policy the docker") @@ -1133,7 +1294,7 @@ def test_remove_policy_the_docker_does_not_remove_docker() -> None: def test_dont_and_dont_apostrophe_remain_distinct_policy_identities() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use don't") second = engine.step("prohibit dont") @@ -1148,7 +1309,7 @@ def test_dont_and_dont_apostrophe_remain_distinct_policy_identities() -> None: def test_import_json_preserves_distinct_article_variant_policy_keys() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( @@ -1168,7 +1329,7 @@ def test_import_json_preserves_distinct_article_variant_policy_keys() -> None: def test_import_json_preserves_distinct_dont_and_dont_apostrophe_policy_keys() -> None: - engine = create_engine() + engine = Engine() engine.import_json( json.dumps( @@ -1188,7 +1349,7 @@ def test_import_json_preserves_distinct_dont_and_dont_apostrophe_policy_keys() - def test_export_import_round_trip_preserves_distinct_normalized_policy_keys() -> None: - source = create_engine() + source = Engine() source.import_json( json.dumps( { @@ -1206,7 +1367,7 @@ def test_export_import_round_trip_preserves_distinct_normalized_policy_keys() -> payload = source.export_json() - restored = create_engine() + restored = Engine() restored.import_json(payload) assert dict(restored.policies) == { @@ -1271,7 +1432,7 @@ def test_export_import_round_trip_preserves_distinct_normalized_policy_keys() -> def test_compound_directives_remain_no_directive_without_mutation( user_input: str, initial_state: dict[str, object] ) -> None: - engine = create_engine() + engine = Engine() _import_state(engine, initial_state) before = _observations(engine) @@ -1282,7 +1443,7 @@ def test_compound_directives_remain_no_directive_without_mutation( def test_quoted_non_directive_leading_input_remains_no_directive() -> None: - engine = create_engine() + engine = Engine() decision = engine.step('"use docker and prohibit peanuts"') @@ -1329,7 +1490,7 @@ def test_directive_like_substrings_inside_larger_words_do_not_trigger_compound_r expected_decision_kind: str, expected_state: dict[str, object], ) -> None: - engine = create_engine() + engine = Engine() _import_state(engine, initial_state) decision = engine.step(user_input) @@ -1396,7 +1557,7 @@ def test_directive_like_substrings_inside_larger_words_do_not_trigger_compound_r def test_valid_single_directives_still_work( user_input: str, initial_state: dict[str, object], expected_state: dict[str, object] ) -> None: - engine = create_engine() + engine = Engine() _import_state(engine, initial_state) decision = engine.step(user_input) @@ -1429,7 +1590,7 @@ def test_valid_single_directives_still_work( def test_all_canonical_directive_starts_remain_single_directive_when_valid( directive_start: str, ) -> None: - engine = create_engine() + engine = Engine() _import_state(engine, {"premise": "baseline", "policies": {"podman": "use"}, "version": 2}) decision = engine.step(directive_start) @@ -1438,7 +1599,7 @@ def test_all_canonical_directive_starts_remain_single_directive_when_valid( def test_compound_no_directive_after_prior_missing_source_replacement_update() -> None: - engine = create_engine() + engine = Engine() first = engine.step("use kubectl instead of docker") assert first == { "kind": DECISION_UPDATE, diff --git a/tests/test_examples_behavior.py b/tests/test_examples_behavior.py index 1059673..9513c3e 100644 --- a/tests/test_examples_behavior.py +++ b/tests/test_examples_behavior.py @@ -87,7 +87,7 @@ def test_example_05_dispatches_no_directive_update_and_error_correctly( monkeypatch: pytest.MonkeyPatch, ) -> None: module = _load_example_module("05_llm_integration_pattern.py") - engine = module.create_engine() + engine = module.Engine() decision_kinds: list[str] = [] llm_calls: list[tuple[object, str]] = [] @@ -125,14 +125,14 @@ def test_example_06_sequences_steps_and_restores_state_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: module = _load_example_module("06_step_sequence_and_state_restore.py") - original_create_engine = module.create_engine + original_engine_class = module.Engine step_calls: list[str] = [] state_exports = 0 state_imports = 0 - def create_engine_wrapper() -> object: + def engine_class_wrapper() -> object: nonlocal state_exports, state_imports - engine = original_create_engine() + engine = original_engine_class() original_step = engine.step original_export = engine.export_json original_import = engine.import_json @@ -156,7 +156,7 @@ def import_wrapper(payload: str) -> None: engine.import_json = import_wrapper # type: ignore[assignment] return engine - monkeypatch.setattr(module, "create_engine", create_engine_wrapper) + monkeypatch.setattr(module, "Engine", engine_class_wrapper) module.main() output = capsys.readouterr().out diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 05fa487..85119bb 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -4,7 +4,10 @@ import pytest import context_compiler.grammar as grammar_module -from context_compiler import DECISION_ERROR, create_engine +from context_compiler import ( + DECISION_ERROR, + Engine, +) from context_compiler.grammar import ( CanonicalDirective, decompose_directive, @@ -163,7 +166,7 @@ def test_step_fixtures() -> None: _assert_fixture_path_matches_id(path, fixture_id) _validate_step_fixture(fixture, fixture_id) - engine = create_engine() + engine = Engine() engine.import_json( json.dumps(fixture["initial_state"], sort_keys=True, separators=(",", ":")) ) @@ -194,7 +197,7 @@ def test_state_json_fixtures() -> None: _assert_fixture_path_matches_id(path, fixture_id) _validate_state_json_fixture(fixture, fixture_id) - engine = create_engine() + engine = Engine() engine.import_json( json.dumps(fixture["initial_state"], sort_keys=True, separators=(",", ":")) ) @@ -255,7 +258,7 @@ def test_mutation_isolation_fixtures() -> None: fixture_id = fixture["id"] operation = fixture["operation"] fn = operation["fn"] - engine = create_engine() + engine = Engine() engine.import_json( json.dumps(fixture["initial_state"], sort_keys=True, separators=(",", ":")) ) diff --git a/tests/test_properties.py b/tests/test_properties.py index 504b127..c6e92d3 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -7,7 +7,12 @@ from hypothesis import strategies as st import context_compiler.grammar as grammar_module -from context_compiler import DECISION_ERROR, DECISION_NO_DIRECTIVE, DECISION_UPDATE, create_engine +from context_compiler import ( + DECISION_ERROR, + DECISION_NO_DIRECTIVE, + DECISION_UPDATE, + Engine, +) from context_compiler.grammar import ( CanonicalDirective, _DirectiveKind, @@ -20,7 +25,7 @@ def _observations(engine: object) -> tuple[object, dict[str, object]]: def _run_sequence(inputs: list[str]) -> tuple[object, dict[str, object]]: - engine = create_engine() + engine = Engine() for item in inputs: engine.step(item) return _observations(engine) @@ -183,11 +188,11 @@ def _build_deterministic_replacement_case( def _payload_has_stable_export_import_cycle(payload: dict[str, object]) -> bool: - engine = create_engine() + engine = Engine() engine.import_json(json.dumps(payload)) exported = engine.export_json() - restored = create_engine() + restored = Engine() try: restored.import_json(exported) except ValueError: @@ -266,7 +271,7 @@ def test_idempotent_use_item_is_update_and_stable_state(item: str) -> None: assume(_normalize_item_like_engine(item) != "") assume(not _contains_canonical_start_fragment(item)) assume(_is_canonical_directive(decompose_directive(f"use {item}"))) - engine = create_engine() + engine = Engine() d1 = engine.step(f"use {item}") d2 = engine.step(f"use {item}") @@ -278,7 +283,7 @@ def test_idempotent_use_item_is_update_and_stable_state(item: str) -> None: @given(item=st.text(alphabet=" \t", min_size=0, max_size=6)) def test_use_item_with_whitespace_only_payload_remains_no_directive(item: str) -> None: assert _normalize_item_like_engine(item) == "" - engine = create_engine() + engine = Engine() before = _observations(engine) d1 = engine.step(f"use {item}") @@ -294,7 +299,7 @@ def test_idempotent_prohibit_item_is_update_and_stable_state(item: str) -> None: assume(_normalize_item_like_engine(item) != "") assume(not _contains_canonical_start_fragment(item)) assume(_is_canonical_directive(decompose_directive(f"prohibit {item}"))) - engine = create_engine() + engine = Engine() d1 = engine.step(f"prohibit {item}") d2 = engine.step(f"prohibit {item}") @@ -306,7 +311,7 @@ def test_idempotent_prohibit_item_is_update_and_stable_state(item: str) -> None: @given(item=st.text(alphabet=" \t", min_size=0, max_size=6)) def test_prohibit_item_with_whitespace_only_payload_remains_no_directive(item: str) -> None: assert _normalize_item_like_engine(item) == "" - engine = create_engine() + engine = Engine() before = _observations(engine) d1 = engine.step(f"prohibit {item}") @@ -319,7 +324,7 @@ def test_prohibit_item_with_whitespace_only_payload_remains_no_directive(item: s @given(st.lists(st.text(max_size=80), min_size=0, max_size=20)) def test_non_matching_inputs_can_remain_no_directive_only(inputs: list[str]) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in inputs: @@ -331,7 +336,7 @@ def test_non_matching_inputs_can_remain_no_directive_only(inputs: list[str]) -> @given(st.lists(st.text(max_size=50), min_size=0, max_size=30)) def test_no_directive_sequence_preserves_state_and_decision_kind(inputs: list[str]) -> None: - engine = create_engine() + engine = Engine() before = _observations(engine) for text in inputs: @@ -345,7 +350,7 @@ def test_contradiction_use_after_prohibit_always_clarifies(item: str) -> None: assume(not _contains_canonical_start_fragment(item)) assume(_is_canonical_directive(decompose_directive(f"prohibit {item}"))) assume(_is_canonical_directive(decompose_directive(f"use {item}"))) - engine = create_engine() + engine = Engine() engine.step(f"prohibit {item}") before = _observations(engine) @@ -362,7 +367,7 @@ def test_contradiction_prohibit_after_use_always_clarifies(item: str) -> None: assume(not _contains_canonical_start_fragment(item)) assume(_is_canonical_directive(decompose_directive(f"use {item}"))) assume(_is_canonical_directive(decompose_directive(f"prohibit {item}"))) - engine = create_engine() + engine = Engine() engine.step(f"use {item}") before = _observations(engine) @@ -375,11 +380,11 @@ def test_contradiction_prohibit_after_use_always_clarifies(item: str) -> None: def test_export_import_round_trip_preserves_authoritative_state_for_generated_payloads( payload: dict[str, object], ) -> None: - source = create_engine() + source = Engine() source.import_json(json.dumps(payload)) canonical_state = _observations(source) - target = create_engine() + target = Engine() target.import_json(source.export_json()) assert _observations(target) == canonical_state @@ -389,14 +394,14 @@ def test_export_import_round_trip_preserves_authoritative_state_for_generated_pa def test_repeated_export_import_cycles_remain_stable( payload: dict[str, object], cycles: int ) -> None: - engine = create_engine() + engine = Engine() engine.import_json(json.dumps(payload)) expected_state = _observations(engine) expected_json = engine.export_json() for _ in range(cycles): - next_engine = create_engine() + next_engine = Engine() next_engine.import_json(expected_json) assert _observations(next_engine) == expected_state assert next_engine.export_json() == expected_json @@ -418,7 +423,7 @@ def test_deterministic_replacement_matches_equivalent_explicit_transition( assert isinstance(old_item, str) assert isinstance(old_present, bool) - oracle_engine = create_engine() + oracle_engine = Engine() oracle_engine.import_json( json.dumps(deepcopy(initial_state), sort_keys=True, separators=(",", ":")) ) @@ -426,7 +431,7 @@ def test_deterministic_replacement_matches_equivalent_explicit_transition( expected_decision = oracle_engine.step(f"use {new_item}") expected_state = _observations(oracle_engine) - engine = create_engine() + engine = Engine() engine.import_json(json.dumps(initial_state, sort_keys=True, separators=(",", ":"))) decision = engine.step(f"use {new_item} instead of {old_item}") diff --git a/tests/test_repl.py b/tests/test_repl.py index 3408c52..e11f6e1 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -8,7 +8,11 @@ import pytest import context_compiler.repl as repl_module -from context_compiler import DECISION_UPDATE, __version__, create_engine +from context_compiler import ( + DECISION_UPDATE, + Engine, + __version__, +) from context_compiler.repl import run_repl pytestmark = pytest.mark.contract @@ -203,7 +207,7 @@ def test_main_unknown_flag_prints_error_hint_and_exits_nonzero( def test_cli_initial_state_json_preload_works() -> None: - engine = create_engine() + engine = Engine() engine.step("set premise concise") payload = engine.export_json() @@ -214,7 +218,7 @@ def test_cli_initial_state_json_preload_works() -> None: def test_cli_initial_state_file_preload_works(tmp_path: pathlib.Path) -> None: - engine = create_engine() + engine = Engine() engine.step("use docker") path = tmp_path / "state.json" path.write_text(engine.export_json(), encoding="utf-8") @@ -234,11 +238,11 @@ def test_cli_invalid_initial_state_preload_fails_fast() -> None: def test_apply_preload_from_options_initial_state_json_restores_state() -> None: - source_engine = create_engine() + source_engine = Engine() source_engine.step("set premise concise") source_engine.step("use docker") - target_engine = create_engine() + target_engine = Engine() repl_module._apply_preload_from_options( target_engine, { @@ -254,7 +258,7 @@ def test_apply_preload_from_options_initial_state_json_restores_state() -> None: def test_apply_preload_from_options_initial_state_file_restores_state( tmp_path: pathlib.Path, ) -> None: - source_engine = create_engine() + source_engine = Engine() source_engine.step("set premise concise") source_engine.step("use docker") @@ -262,7 +266,7 @@ def test_apply_preload_from_options_initial_state_file_restores_state( path = tmp_path / "state.json" path.write_text(payload, encoding="utf-8") - target_engine = create_engine() + target_engine = Engine() repl_module._apply_preload_from_options( target_engine, { diff --git a/tests/test_repl_properties.py b/tests/test_repl_properties.py index 1bc35ec..159f31f 100644 --- a/tests/test_repl_properties.py +++ b/tests/test_repl_properties.py @@ -4,7 +4,11 @@ from hypothesis import assume, given from hypothesis import strategies as st -from context_compiler import DECISION_ERROR, DECISION_NO_DIRECTIVE, create_engine +from context_compiler import ( + DECISION_ERROR, + DECISION_NO_DIRECTIVE, + Engine, +) from context_compiler.repl import run_repl pytestmark = pytest.mark.contract @@ -73,7 +77,7 @@ def test_repl_matches_engine_for_non_exit_sequences(lines: list[str]) -> None: _, repl_lines = _run_repl_lines(lines) - engine = create_engine() + engine = Engine() oracle_lines = [ rendered_line for line in lines @@ -110,7 +114,7 @@ def test_repl_stops_processing_after_exit_or_quit( lines = [*prefix, stop_token, *suffix] _, repl_lines = _run_repl_lines(lines) - engine = create_engine() + engine = Engine() oracle_lines = [ rendered_line for line in prefix diff --git a/tests/test_structured_regression.py b/tests/test_structured_regression.py index 9deb585..6eba651 100644 --- a/tests/test_structured_regression.py +++ b/tests/test_structured_regression.py @@ -4,7 +4,7 @@ import pytest -from context_compiler import create_engine +from context_compiler import Engine _STRUCTURED_FIXTURES_DIR = ( Path(__file__).resolve().parent / "fixtures" / "engine-regression" / "structured" @@ -105,7 +105,7 @@ def test_structured_regression_scenarios() -> None: _validate_structured_expected_fixture(expected, scenario_id) assert expected["id"] == scenario_id, f"scenario_id_mismatch: {scenario_id}" - engine = create_engine() + engine = Engine() initial_state = scenario.get("initial_state") if initial_state is not None: diff --git a/uv.lock b/uv.lock index ba79235..97fc9b6 100644 --- a/uv.lock +++ b/uv.lock @@ -296,7 +296,7 @@ wheels = [ [[package]] name = "context-compiler" -version = "0.9.0.dev7" +version = "0.9.0.dev8" source = { editable = "." } [package.optional-dependencies]