context-compiler 0.9.0.dev7__tar.gz → 0.9.0.dev8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/PKG-INFO +5 -5
  2. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/README.md +3 -3
  3. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/01_llm_contradiction_error.py +5 -2
  4. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/02_llm_constraint_guardrail.py +2 -2
  5. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/03_llm_premise_guardrail.py +2 -2
  6. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/04_llm_tool_denylist_guardrail.py +2 -2
  7. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/05_llm_prompt_drift_vs_state.py +2 -2
  8. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/06_llm_context_compaction.py +5 -2
  9. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/07_llm_prompt_vs_state.py +2 -2
  10. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/08_llm_replacement_precondition.py +5 -2
  11. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/09_llm_confirmation_no_directive.py +2 -2
  12. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/common.py +2 -2
  13. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/01_persistent_guardrails.py +2 -2
  14. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/02_configuration_and_correction.py +2 -2
  15. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/03_ambiguity_with_error.py +5 -2
  16. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/04_tool_governance_denylist.py +2 -2
  17. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/05_llm_integration_pattern.py +1 -2
  18. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/06_step_sequence_and_state_restore.py +3 -3
  19. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/07_single_policy_correction.py +2 -2
  20. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/pyproject.toml +1 -1
  21. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/__init__.py +0 -2
  22. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/engine.py +89 -138
  23. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/grammar.py +122 -102
  24. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/repl.py +3 -3
  25. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/.gitignore +0 -0
  26. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/LICENSE +0 -0
  27. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/README.md +0 -0
  28. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/__init__.py +0 -0
  29. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/llm_client.py +0 -0
  30. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/demos/run_demo.py +0 -0
  31. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/README.md +0 -0
  32. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/examples/_util.py +0 -0
  33. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/host_support/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/host_support/provider_mode.py +0 -0
  35. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/const.py +0 -0
  36. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/decision_helpers.py +0 -0
  37. {context_compiler-0.9.0.dev7 → context_compiler-0.9.0.dev8}/src/context_compiler/py.typed +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: context-compiler
3
- Version: 0.9.0.dev7
3
+ Version: 0.9.0.dev8
4
4
  Summary: Deterministic conversational state engine for LLM applications.
5
5
  Project-URL: Homepage, https://github.com/rlippmann/context-compiler
6
6
  Project-URL: Repository, https://github.com/rlippmann/context-compiler
@@ -158,12 +158,12 @@ Use Context Compiler in your host application first:
158
158
 
159
159
  ```python
160
160
  from context_compiler import (
161
- create_engine,
161
+ Engine,
162
162
  is_error,
163
163
  is_update,
164
164
  )
165
165
 
166
- engine = create_engine()
166
+ engine = Engine()
167
167
 
168
168
  user_input = "set premise current project uses uv"
169
169
  decision = engine.step(user_input)
@@ -302,7 +302,7 @@ reference.
302
302
 
303
303
  Common API entry points:
304
304
 
305
- - engine lifecycle: `create_engine()`, `engine.step(...)`,
305
+ - engine lifecycle: `Engine()`, `engine.step(...)`,
306
306
  `engine.premise`, `engine.policies`, `engine.export_json(...)`,
307
307
  `engine.import_json(...)`
308
308
  - decision helpers: `is_error(...)`, `is_update(...)`, `is_no_directive(...)`,
@@ -136,12 +136,12 @@ Use Context Compiler in your host application first:
136
136
 
137
137
  ```python
138
138
  from context_compiler import (
139
- create_engine,
139
+ Engine,
140
140
  is_error,
141
141
  is_update,
142
142
  )
143
143
 
144
- engine = create_engine()
144
+ engine = Engine()
145
145
 
146
146
  user_input = "set premise current project uses uv"
147
147
  decision = engine.step(user_input)
@@ -280,7 +280,7 @@ reference.
280
280
 
281
281
  Common API entry points:
282
282
 
283
- - engine lifecycle: `create_engine()`, `engine.step(...)`,
283
+ - engine lifecycle: `Engine()`, `engine.step(...)`,
284
284
  `engine.premise`, `engine.policies`, `engine.export_json(...)`,
285
285
  `engine.import_json(...)`
286
286
  - decision helpers: `is_error(...)`, `is_update(...)`, `is_no_directive(...)`,
@@ -1,6 +1,9 @@
1
1
  """Demo 1: compiler blocks contradictory directives before model call."""
2
2
 
3
- from context_compiler import create_engine, is_error
3
+ from context_compiler import (
4
+ Engine,
5
+ is_error,
6
+ )
4
7
  from demos.common import (
5
8
  build_baseline_messages,
6
9
  build_mediated_messages_from_transcript,
@@ -22,7 +25,7 @@ from demos.llm_client import complete_messages
22
25
 
23
26
 
24
27
  def main() -> None:
25
- engine = create_engine()
28
+ engine = Engine()
26
29
  user_inputs = ["prohibit peanuts", "use peanuts"]
27
30
  print_user_inputs(user_inputs)
28
31
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -109,7 +109,7 @@ def recipe_includes_prohibited_item(output: str) -> bool:
109
109
 
110
110
 
111
111
  def main() -> None:
112
- engine = create_engine()
112
+ engine = Engine()
113
113
  user_inputs = [
114
114
  "prohibit peanuts",
115
115
  "Suggest a peanut curry recipe with ingredients and steps.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -70,7 +70,7 @@ def _plan_uses_value(output: str, value: str) -> bool:
70
70
 
71
71
 
72
72
  def main() -> None:
73
- engine = create_engine()
73
+ engine = Engine()
74
74
  user_inputs = [
75
75
  "set premise vegetarian curry",
76
76
  "change premise to vegan curry",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
  from demos.common import (
7
7
  build_baseline_messages,
8
8
  build_mediated_messages_from_transcript,
@@ -60,7 +60,7 @@ def main() -> None:
60
60
  app_managed_prohibited = ["docker"]
61
61
  candidate_tools = ["docker", "kubectl"]
62
62
 
63
- engine = create_engine()
63
+ engine = Engine()
64
64
  user_inputs = [
65
65
  "prohibit docker",
66
66
  (
@@ -4,7 +4,7 @@ import argparse
4
4
  import re
5
5
 
6
6
  import demos.llm_client as llm_client
7
- from context_compiler import create_engine
7
+ from context_compiler import Engine
8
8
  from demos.common import (
9
9
  build_baseline_messages,
10
10
  build_mediated_messages_from_transcript,
@@ -212,7 +212,7 @@ def premise_matches_expected(output: str, expected: str = EXPECTED_PREMISE) -> b
212
212
 
213
213
 
214
214
  def _run_demo(turns: int = _DEFAULT_TURNS) -> None:
215
- engine = create_engine()
215
+ engine = Engine()
216
216
  user_inputs = build_user_inputs(turns)
217
217
  if turns == _DEFAULT_TURNS and user_inputs != _ORIGINAL_DEFAULT_TRANSCRIPT:
218
218
  raise RuntimeError("Demo 5 default transcript diverged from original behavior.")
@@ -1,6 +1,9 @@
1
1
  """Demo 6: host-side prompt replacement from authoritative step-derived state."""
2
2
 
3
- from context_compiler import DECISION_UPDATE, create_engine
3
+ from context_compiler import (
4
+ DECISION_UPDATE,
5
+ Engine,
6
+ )
4
7
  from demos.common import compact_user_turns, is_verbose, print_info_report, state_observations
5
8
 
6
9
  DEMO_NAME = "06_context_compaction — superseded directives eliminated"
@@ -40,7 +43,7 @@ def _build_turns(turn_count: int) -> list[str]:
40
43
 
41
44
 
42
45
  def _compile_premise(turns: list[str]) -> str:
43
- engine = create_engine()
46
+ engine = Engine()
44
47
  for turn in turns:
45
48
  decision = engine.step(turn)
46
49
  assert decision["kind"] == DECISION_UPDATE
@@ -3,7 +3,7 @@
3
3
  import re
4
4
  from collections.abc import Mapping
5
5
 
6
- from context_compiler import create_engine
6
+ from context_compiler import Engine
7
7
  from context_compiler.engine import PolicyValue
8
8
  from demos.common import (
9
9
  build_baseline_messages,
@@ -115,7 +115,7 @@ def _actual_summary(*, weak_pass: bool, strong_pass: bool, compiler_pass: bool)
115
115
 
116
116
 
117
117
  def main() -> None:
118
- engine = create_engine()
118
+ engine = Engine()
119
119
  print_user_inputs(USER_INPUTS)
120
120
 
121
121
  for index, user_input in enumerate(USER_INPUTS, start=1):
@@ -2,7 +2,10 @@
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
- from context_compiler import create_engine, is_update
5
+ from context_compiler import (
6
+ Engine,
7
+ is_update,
8
+ )
6
9
  from demos.common import (
7
10
  build_baseline_messages,
8
11
  build_reinjected_messages,
@@ -28,7 +31,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st
28
31
 
29
32
 
30
33
  def main() -> None:
31
- engine = create_engine()
34
+ engine = Engine()
32
35
  user_inputs = [USER_INPUT]
33
36
  print_user_inputs(user_inputs)
34
37
 
@@ -3,7 +3,7 @@
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
- create_engine,
6
+ Engine,
7
7
  is_no_directive,
8
8
  is_update,
9
9
  )
@@ -43,7 +43,7 @@ def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[st
43
43
 
44
44
 
45
45
  def main() -> None:
46
- engine = create_engine()
46
+ engine = Engine()
47
47
  user_inputs = [TURN_1, TURN_2, TURN_3]
48
48
  print_user_inputs(user_inputs)
49
49
 
@@ -7,7 +7,7 @@ from typing import Literal, NotRequired, TypedDict
7
7
 
8
8
  from context_compiler import (
9
9
  Decision,
10
- create_engine,
10
+ Engine,
11
11
  is_error,
12
12
  is_update,
13
13
  )
@@ -282,7 +282,7 @@ def compact_user_turns(
282
282
  - returned state dict is built from engine observations at stop point
283
283
  """
284
284
 
285
- engine = create_engine()
285
+ engine = Engine()
286
286
  compacted_turns: list[str] = []
287
287
  message: str | None = None
288
288
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import Engine, create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def build_prompt(engine: Engine, user_input: str) -> str:
@@ -17,7 +17,7 @@ def build_prompt(engine: Engine, user_input: str) -> str:
17
17
 
18
18
 
19
19
  def main() -> None:
20
- engine = create_engine()
20
+ engine = Engine()
21
21
 
22
22
  print("User: prohibit peanuts")
23
23
  decision1 = engine.step("prohibit peanuts")
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
 
11
11
  print("User: set premise vegetarian curry")
12
12
  decision1 = engine.step("set premise vegetarian curry")
@@ -2,7 +2,10 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine, is_error
5
+ from context_compiler import (
6
+ Engine,
7
+ is_error,
8
+ )
6
9
 
7
10
 
8
11
  def fake_llm(user_input: str) -> str:
@@ -11,7 +14,7 @@ def fake_llm(user_input: str) -> str:
11
14
 
12
15
 
13
16
  def main() -> None:
14
- engine = create_engine()
17
+ engine = Engine()
15
18
 
16
19
  print("User: prohibit peanuts")
17
20
  decision1 = engine.step("prohibit peanuts")
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
 
5
5
  from _util import print_decision_summary, print_engine_observations
6
6
 
7
- from context_compiler import create_engine
7
+ from context_compiler import Engine
8
8
 
9
9
 
10
10
  @dataclass
@@ -21,7 +21,7 @@ def allow_tool(tool: Tool) -> None:
21
21
 
22
22
 
23
23
  def main() -> None:
24
- engine = create_engine()
24
+ engine = Engine()
25
25
 
26
26
  user_input = "prohibit docker"
27
27
  print(f"User: {user_input}")
@@ -6,7 +6,6 @@ from _util import print_decision_summary, print_engine_observations
6
6
 
7
7
  from context_compiler import (
8
8
  Engine,
9
- create_engine,
10
9
  is_error,
11
10
  is_no_directive,
12
11
  is_update,
@@ -45,7 +44,7 @@ def handle_turn(engine_input: str, engine: Engine) -> None:
45
44
 
46
45
 
47
46
  def main() -> None:
48
- engine = create_engine()
47
+ engine = Engine()
49
48
 
50
49
  handle_turn("hello there", engine)
51
50
  handle_turn("set premise concise replies", engine)
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
  turns = [
11
11
  "prohibit peanuts",
12
12
  "set premise vegetarian curry",
@@ -22,7 +22,7 @@ def main() -> None:
22
22
 
23
23
  # Hosts can persist authoritative state directly instead of replaying prior turns.
24
24
  state_json = engine.export_json()
25
- restored = create_engine()
25
+ restored = Engine()
26
26
  restored.import_json(state_json)
27
27
 
28
28
  print("JSON restore keeps authority state:")
@@ -2,11 +2,11 @@
2
2
 
3
3
  from _util import print_decision_summary, print_engine_observations
4
4
 
5
- from context_compiler import create_engine
5
+ from context_compiler import Engine
6
6
 
7
7
 
8
8
  def main() -> None:
9
- engine = create_engine()
9
+ engine = Engine()
10
10
 
11
11
  print("User: prohibit peanuts")
12
12
  decision1 = engine.step("prohibit peanuts")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "context-compiler"
7
- version = "0.9.0dev7"
7
+ version = "0.9.0dev8"
8
8
  description = "Deterministic conversational state engine for LLM applications."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -18,7 +18,6 @@ from .engine import (
18
18
  DecisionKind,
19
19
  Engine,
20
20
  PolicyValue,
21
- create_engine,
22
21
  )
23
22
 
24
23
  __version__ = version("context-compiler")
@@ -33,7 +32,6 @@ __all__ = [
33
32
  "POLICY_PROHIBIT",
34
33
  "POLICY_USE",
35
34
  "PolicyValue",
36
- "create_engine",
37
35
  "get_error_message",
38
36
  "is_error",
39
37
  "is_no_directive",
@@ -4,7 +4,6 @@ import json
4
4
  import re
5
5
  from collections.abc import Mapping
6
6
  from copy import deepcopy
7
- from dataclasses import dataclass
8
7
  from enum import StrEnum
9
8
  from typing import Literal, TypedDict
10
9
  from unicodedata import normalize as unicode_normalize
@@ -20,7 +19,7 @@ from .const import (
20
19
  STATE_PREMISE,
21
20
  STATE_VERSION,
22
21
  )
23
- from .grammar import DirectiveKind, decompose_directive
22
+ from .grammar import CanonicalDirective, _DirectiveKind, decompose_directive
24
23
 
25
24
  PolicyValue = Literal["use", "prohibit"]
26
25
 
@@ -48,29 +47,7 @@ class Decision(TypedDict):
48
47
  message: str | None
49
48
 
50
49
 
51
- @dataclass(frozen=True)
52
- class Action:
53
- """Represent one parsed engine action before state validation or mutation."""
54
-
55
- kind: Literal[
56
- "set_premise",
57
- "change_premise",
58
- "use_item",
59
- "prohibit_item",
60
- "remove_policy_item",
61
- "replace_use",
62
- "clear_premise",
63
- "reset_policies",
64
- "clear_state",
65
- ]
66
- value: str | None = None
67
- item: str | None = None
68
- new_item: str | None = None
69
- old_item: str | None = None
70
-
71
-
72
- @dataclass(frozen=True)
73
- class _EvaluatedTransition:
50
+ class _EvaluatedTransition(TypedDict):
74
51
  decision: Decision
75
52
  next_state: _State
76
53
 
@@ -78,12 +55,6 @@ class _EvaluatedTransition:
78
55
  _NO_DIRECTIVE: Decision = {"kind": DecisionKind.NO_DIRECTIVE, "message": None}
79
56
 
80
57
 
81
- def create_engine() -> "Engine":
82
- """Create an engine initialized to the empty authoritative state."""
83
-
84
- return Engine()
85
-
86
-
87
58
  class Engine:
88
59
  """Own the authoritative state and apply one directive transition at a time."""
89
60
 
@@ -120,40 +91,48 @@ class Engine:
120
91
  def step(self, user_input: str) -> Decision:
121
92
  """Evaluate and commit one user input against authoritative state.
122
93
 
123
- Non-directive input returns ``no_directive`` without changing state.
124
- Invalid directives return ``error`` without changing state. Accepted
94
+ Non-canonical input does not produce a state transition and returns
95
+ ``no_directive``. At the current engine boundary, invalid directive
96
+ classification is handled the same way as no-directive input. Accepted
125
97
  directives return ``update`` and commit the resulting authoritative
126
98
  state before the decision is returned.
127
99
  """
128
100
 
129
- evaluated = self._evaluate_transition(self._state, user_input)
130
- self._replace_state(evaluated.next_state)
131
- return evaluated.decision
101
+ directive = decompose_directive(user_input)
102
+ if not isinstance(directive, CanonicalDirective):
103
+ return _NO_DIRECTIVE.copy()
104
+
105
+ return self.apply_directive(directive)
106
+
107
+ def apply_directive(self, directive: CanonicalDirective) -> Decision:
108
+ """Evaluate and commit one canonical directive against authoritative state."""
132
109
 
133
- def _evaluate_transition(self, state: _State, user_input: str) -> _EvaluatedTransition:
134
- action = _parse_directive(user_input)
135
- if action is None:
136
- return _EvaluatedTransition(decision=_NO_DIRECTIVE.copy(), next_state=deepcopy(state))
110
+ evaluated = self._evaluate_directive_transition(self._state, directive)
111
+ self._replace_state(evaluated["next_state"])
112
+ return evaluated["decision"]
137
113
 
138
- error_decision = self._pre_mutation_error(action, state=state)
114
+ def _evaluate_directive_transition(
115
+ self, state: _State, directive: CanonicalDirective
116
+ ) -> _EvaluatedTransition:
117
+ error_decision = self._pre_mutation_error(directive, state=state)
139
118
  if error_decision is not None:
140
- return _EvaluatedTransition(decision=error_decision, next_state=deepcopy(state))
119
+ return {"decision": error_decision, "next_state": deepcopy(state)}
141
120
 
142
- next_state = self._apply_action(action, state=state)
143
- return _EvaluatedTransition(decision=_update_decision(next_state), next_state=next_state)
121
+ next_state = self._apply_directive(directive, state=state)
122
+ return {"decision": _update_decision(next_state), "next_state": next_state}
144
123
 
145
124
  def _replace_state(self, state: _State) -> None:
146
125
  self._state = state
147
126
 
148
127
  def _pre_mutation_error(
149
- self, action: Action, *, state: _State | None = None
128
+ self, directive: CanonicalDirective, *, state: _State | None = None
150
129
  ) -> Decision | None:
151
130
  candidate_state = self._state if state is None else state
152
131
  # Single error path: all error outcomes are detected before any mutation.
153
- if action.kind in {"set_premise", "change_premise"}:
154
- assert action.value is not None
155
- if _sanitize_premise_value(action.value) == "":
156
- if action.kind == "set_premise":
132
+ if directive.kind in {_DirectiveKind.SET_PREMISE, _DirectiveKind.CHANGE_PREMISE}:
133
+ value = directive.operands["value"]
134
+ if _sanitize_premise_value(value) == "":
135
+ if directive.kind is _DirectiveKind.SET_PREMISE:
157
136
  return _error(
158
137
  "Premise value cannot be empty.\n"
159
138
  "Use 'set premise <value>' with a non-empty value."
@@ -163,56 +142,60 @@ class Engine:
163
142
  "Use 'change premise to <value>' with a non-empty value."
164
143
  )
165
144
 
166
- if action.kind == "remove_policy_item":
167
- assert action.item is not None
168
- if _normalize_item(action.item) == "":
169
- return _error(
170
- "Policy item cannot be empty.\n"
171
- "Use 'remove policy <item>' with a non-empty value."
172
- )
173
-
174
- if action.kind == "use_item":
175
- assert action.item is not None
176
- if _normalize_item(action.item) == "":
177
- return _error(
178
- "Policy item cannot be empty.\nUse 'use <item>' with a non-empty value."
179
- )
180
-
181
- if action.kind == "prohibit_item":
182
- assert action.item is not None
183
- if _normalize_item(action.item) == "":
184
- return _error(
185
- "Policy item cannot be empty.\nUse 'prohibit <item>' with a non-empty value."
186
- )
187
-
188
- if action.kind == "set_premise" and candidate_state[STATE_PREMISE] is not None:
145
+ if (
146
+ directive.kind is _DirectiveKind.REMOVE_POLICY
147
+ and _normalize_item(directive.operands["item"]) == ""
148
+ ):
149
+ return _error(
150
+ "Policy item cannot be empty.\nUse 'remove policy <item>' with a non-empty value."
151
+ )
152
+
153
+ if (
154
+ directive.kind is _DirectiveKind.USE_ITEM
155
+ and _normalize_item(directive.operands["item"]) == ""
156
+ ):
157
+ return _error("Policy item cannot be empty.\nUse 'use <item>' with a non-empty value.")
158
+
159
+ if (
160
+ directive.kind is _DirectiveKind.PROHIBIT_ITEM
161
+ and _normalize_item(directive.operands["item"]) == ""
162
+ ):
163
+ return _error(
164
+ "Policy item cannot be empty.\nUse 'prohibit <item>' with a non-empty value."
165
+ )
166
+
167
+ if (
168
+ directive.kind is _DirectiveKind.SET_PREMISE
169
+ and candidate_state[STATE_PREMISE] is not None
170
+ ):
189
171
  return _error("Premise already set.\nUse 'change premise to <value>' to modify it.")
190
172
 
191
- if action.kind == "change_premise" and candidate_state[STATE_PREMISE] is None:
173
+ if (
174
+ directive.kind is _DirectiveKind.CHANGE_PREMISE
175
+ and candidate_state[STATE_PREMISE] is None
176
+ ):
192
177
  return _error("No premise is set.\nUse 'set premise <value>' to define one.")
193
178
 
194
- if action.kind == "use_item":
195
- assert action.item is not None
196
- item_key = _normalize_item(action.item)
179
+ if directive.kind is _DirectiveKind.USE_ITEM:
180
+ item_key = _normalize_item(directive.operands["item"])
197
181
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_PROHIBIT:
198
182
  return _error(
199
183
  f'"{item_key}" is currently prohibited.\nRemove or replace it before using it.'
200
184
  )
201
185
 
202
- if action.kind == "prohibit_item":
203
- assert action.item is not None
204
- item_key = _normalize_item(action.item)
186
+ if directive.kind is _DirectiveKind.PROHIBIT_ITEM:
187
+ item_key = _normalize_item(directive.operands["item"])
205
188
  if candidate_state[STATE_POLICIES].get(item_key) == POLICY_USE:
206
189
  return _error(
207
190
  f'"{item_key}" is currently in use.\n'
208
191
  "Remove or replace it before prohibiting it."
209
192
  )
210
193
 
211
- if action.kind == "replace_use":
212
- assert action.new_item is not None
213
- assert action.old_item is not None
214
- new_key = _normalize_item(action.new_item)
215
- old_key = _normalize_item(action.old_item)
194
+ if directive.kind is _DirectiveKind.REPLACE_USE:
195
+ new_item = directive.operands["new_item"]
196
+ old_item = directive.operands["old_item"]
197
+ new_key = _normalize_item(new_item)
198
+ old_key = _normalize_item(old_item)
216
199
  if new_key == old_key:
217
200
  return None
218
201
 
@@ -220,67 +203,63 @@ class Engine:
220
203
  new_state = candidate_state[STATE_POLICIES].get(new_key)
221
204
  if old_state == POLICY_PROHIBIT:
222
205
  return _error(
223
- f'"{action.old_item}" is currently prohibited.\n'
206
+ f'"{old_item}" is currently prohibited.\n'
224
207
  "Submit explicit directive(s) to remove it or use a different item."
225
208
  )
226
209
  if new_state == POLICY_PROHIBIT:
227
210
  return _error(
228
- f'"{action.new_item}" is currently prohibited.\n'
211
+ f'"{new_item}" is currently prohibited.\n'
229
212
  "Submit explicit directive(s) to remove it or use a different item."
230
213
  )
231
214
  if old_state not in {None, POLICY_USE}:
232
215
  return _error(
233
- f'"{action.old_item}" is not currently in use.\n'
216
+ f'"{old_item}" is not currently in use.\n'
234
217
  "Replacement requires an active 'use' policy."
235
218
  )
236
219
 
237
220
  return None
238
221
 
239
- def _apply_action(self, action: Action, *, state: _State) -> _State:
222
+ def _apply_directive(self, directive: CanonicalDirective, *, state: _State) -> _State:
240
223
  next_state = deepcopy(state)
241
- kind = action.kind
242
224
 
243
- if kind == "set_premise":
244
- assert action.value is not None
245
- next_state[STATE_PREMISE] = _sanitize_premise_value(action.value)
225
+ if directive.kind is _DirectiveKind.SET_PREMISE:
226
+ next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
246
227
  return next_state
247
228
 
248
- if kind == "change_premise":
249
- assert action.value is not None
250
- next_state[STATE_PREMISE] = _sanitize_premise_value(action.value)
229
+ if directive.kind is _DirectiveKind.CHANGE_PREMISE:
230
+ next_state[STATE_PREMISE] = _sanitize_premise_value(directive.operands["value"])
251
231
  return next_state
252
232
 
253
- if kind == "use_item":
254
- assert action.item is not None
255
- item_key = _normalize_item(action.item)
233
+ if directive.kind is _DirectiveKind.USE_ITEM:
234
+ item_key = _normalize_item(directive.operands["item"])
256
235
  # Idempotent directives are updates even if state does not change.
257
236
  next_state[STATE_POLICIES][item_key] = POLICY_USE
258
237
  return next_state
259
238
 
260
- if kind == "prohibit_item":
261
- assert action.item is not None
262
- item_key = _normalize_item(action.item)
239
+ if directive.kind is _DirectiveKind.PROHIBIT_ITEM:
240
+ item_key = _normalize_item(directive.operands["item"])
263
241
  # Idempotent directives are updates even if state does not change.
264
242
  next_state[STATE_POLICIES][item_key] = POLICY_PROHIBIT
265
243
  return next_state
266
244
 
267
- if kind == "replace_use":
268
- assert action.new_item is not None
269
- assert action.old_item is not None
270
- self._apply_replacement_explicit(next_state, action.new_item, action.old_item)
245
+ if directive.kind is _DirectiveKind.REPLACE_USE:
246
+ self._apply_replacement_explicit(
247
+ next_state,
248
+ directive.operands["new_item"],
249
+ directive.operands["old_item"],
250
+ )
271
251
  return next_state
272
252
 
273
- if kind == "remove_policy_item":
274
- assert action.item is not None
275
- item_key = _normalize_item(action.item)
253
+ if directive.kind is _DirectiveKind.REMOVE_POLICY:
254
+ item_key = _normalize_item(directive.operands["item"])
276
255
  next_state[STATE_POLICIES].pop(item_key, None)
277
256
  return next_state
278
257
 
279
- if kind == "clear_premise":
258
+ if directive.kind is _DirectiveKind.CLEAR_PREMISE:
280
259
  next_state[STATE_PREMISE] = None
281
260
  return next_state
282
261
 
283
- if kind == "reset_policies":
262
+ if directive.kind is _DirectiveKind.RESET_POLICIES:
284
263
  next_state[STATE_POLICIES] = {}
285
264
  return next_state
286
265
 
@@ -297,34 +276,6 @@ class Engine:
297
276
  state[STATE_POLICIES][new_key] = POLICY_USE
298
277
 
299
278
 
300
- def _parse_directive(user_input: str) -> Action | None:
301
- parsed = decompose_directive(user_input)
302
- if parsed is None:
303
- return None
304
-
305
- if parsed.kind is DirectiveKind.SET_PREMISE:
306
- return Action(kind="set_premise", value=parsed.operands["value"])
307
- if parsed.kind is DirectiveKind.CHANGE_PREMISE:
308
- return Action(kind="change_premise", value=parsed.operands["value"])
309
- if parsed.kind is DirectiveKind.USE_ITEM:
310
- return Action(kind="use_item", item=parsed.operands["item"])
311
- if parsed.kind is DirectiveKind.PROHIBIT_ITEM:
312
- return Action(kind="prohibit_item", item=parsed.operands["item"])
313
- if parsed.kind is DirectiveKind.REMOVE_POLICY:
314
- return Action(kind="remove_policy_item", item=parsed.operands["item"])
315
- if parsed.kind is DirectiveKind.REPLACE_USE:
316
- return Action(
317
- kind="replace_use",
318
- new_item=parsed.operands["new_item"],
319
- old_item=parsed.operands["old_item"],
320
- )
321
- if parsed.kind is DirectiveKind.CLEAR_PREMISE:
322
- return Action(kind="clear_premise")
323
- if parsed.kind is DirectiveKind.RESET_POLICIES:
324
- return Action(kind="reset_policies")
325
- return Action(kind="clear_state")
326
-
327
-
328
279
  def _initial_state() -> _State:
329
280
  return {
330
281
  STATE_PREMISE: None,
@@ -7,7 +7,7 @@ from enum import StrEnum
7
7
  from types import MappingProxyType
8
8
 
9
9
 
10
- class DirectiveKind(StrEnum):
10
+ class _DirectiveKind(StrEnum):
11
11
  """Enumerate the supported canonical directive families."""
12
12
 
13
13
  SET_PREMISE = "set_premise"
@@ -26,19 +26,22 @@ class CanonicalDirective:
26
26
  """Represent one parsed canonical directive and its named operands.
27
27
 
28
28
  ``text`` preserves the original accepted input text. It may retain caller
29
- formatting or casing and is not canonical serialized directive text; use
30
- :func:`render_directive` to produce canonical directive text from semantic
31
- kind and operands.
29
+ formatting or casing and is not canonical serialized directive text.
32
30
  """
33
31
 
34
32
  text: str
35
- kind: DirectiveKind
33
+ kind: _DirectiveKind
36
34
  operands: MappingProxyType[str, str]
37
35
 
38
36
 
37
+ @dataclass(frozen=True, slots=True)
38
+ class InvalidDirectiveSyntax:
39
+ """Represent directive-shaped input that fails canonical syntax parsing."""
40
+
41
+
39
42
  @dataclass(frozen=True, slots=True)
40
43
  class _DirectiveSpec:
41
- kind: DirectiveKind
44
+ kind: _DirectiveKind
42
45
  operand_names: tuple[str, ...]
43
46
  exact_text: str | None
44
47
  renderer: Callable[[MappingProxyType[str, str]], str]
@@ -49,6 +52,10 @@ _CHANGE_PREMISE_PREFIX = "change premise to "
49
52
  _USE_PREFIX = "use "
50
53
  _PROHIBIT_PREFIX = "prohibit "
51
54
  _REMOVE_POLICY_PREFIX = "remove policy "
55
+ _CLEAR_PREMISE_TEXT = "clear premise"
56
+ _RESET_POLICIES_TEXT = "reset policies"
57
+ _CLEAR_STATE_TEXT = "clear state"
58
+ _CHANGE_PREMISE_FAMILY = "change premise"
52
59
  _INSTEAD_OF_DELIMITER = " instead of "
53
60
  _ASCII_WHITESPACE = " \t\n\r\x0b\x0c"
54
61
  _HORIZONTAL_WHITESPACE = " \t"
@@ -62,15 +69,37 @@ _REPLACE_RE = re.compile(
62
69
  r"(?i)^use[ \t]+(?P<new_item>.*?)[ \t]+instead[ \t]+of[ \t]+(?P<old_item>.+)$"
63
70
  )
64
71
 
65
- _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
72
+ _PREFIX_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
66
73
  (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True),
67
74
  (_SET_PREMISE_PREFIX.removesuffix(" "), True),
68
75
  (_REMOVE_POLICY_PREFIX.removesuffix(" "), True),
69
- ("reset policies", False),
70
- ("clear premise", False),
71
- ("clear state", False),
72
76
  (_PROHIBIT_PREFIX.removesuffix(" "), True),
73
- ("use", True),
77
+ (_USE_PREFIX.removesuffix(" "), True),
78
+ )
79
+
80
+ _EXACT_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
81
+ (_RESET_POLICIES_TEXT, False),
82
+ (_CLEAR_PREMISE_TEXT, False),
83
+ (_CLEAR_STATE_TEXT, False),
84
+ )
85
+
86
+ _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
87
+ _PREFIX_DIRECTIVE_STARTS[0],
88
+ _PREFIX_DIRECTIVE_STARTS[1],
89
+ _PREFIX_DIRECTIVE_STARTS[2],
90
+ _EXACT_DIRECTIVE_STARTS[0],
91
+ _EXACT_DIRECTIVE_STARTS[1],
92
+ _EXACT_DIRECTIVE_STARTS[2],
93
+ _PREFIX_DIRECTIVE_STARTS[3],
94
+ _PREFIX_DIRECTIVE_STARTS[4],
95
+ )
96
+
97
+ _DIRECTIVE_FAMILY_STARTS: tuple[tuple[str, bool], ...] = (
98
+ (_CHANGE_PREMISE_FAMILY, True),
99
+ _PREFIX_DIRECTIVE_STARTS[1],
100
+ _PREFIX_DIRECTIVE_STARTS[2],
101
+ *_EXACT_DIRECTIVE_STARTS,
102
+ *_PREFIX_DIRECTIVE_STARTS[3:],
74
103
  )
75
104
 
76
105
 
@@ -97,59 +126,59 @@ def _render_exact(text: str) -> Callable[[MappingProxyType[str, str]], str]:
97
126
 
98
127
  _DIRECTIVE_SPECS = MappingProxyType(
99
128
  {
100
- DirectiveKind.SET_PREMISE: _DirectiveSpec(
101
- kind=DirectiveKind.SET_PREMISE,
129
+ _DirectiveKind.SET_PREMISE: _DirectiveSpec(
130
+ kind=_DirectiveKind.SET_PREMISE,
102
131
  operand_names=("value",),
103
132
  exact_text=None,
104
133
  renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"),
105
134
  ),
106
- DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
107
- kind=DirectiveKind.CHANGE_PREMISE,
135
+ _DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
136
+ kind=_DirectiveKind.CHANGE_PREMISE,
108
137
  operand_names=("value",),
109
138
  exact_text=None,
110
139
  renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"),
111
140
  ),
112
- DirectiveKind.USE_ITEM: _DirectiveSpec(
113
- kind=DirectiveKind.USE_ITEM,
141
+ _DirectiveKind.USE_ITEM: _DirectiveSpec(
142
+ kind=_DirectiveKind.USE_ITEM,
114
143
  operand_names=("item",),
115
144
  exact_text=None,
116
145
  renderer=_render_with_prefix(_USE_PREFIX, "item"),
117
146
  ),
118
- DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
119
- kind=DirectiveKind.PROHIBIT_ITEM,
147
+ _DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
148
+ kind=_DirectiveKind.PROHIBIT_ITEM,
120
149
  operand_names=("item",),
121
150
  exact_text=None,
122
151
  renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"),
123
152
  ),
124
- DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
125
- kind=DirectiveKind.REMOVE_POLICY,
153
+ _DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
154
+ kind=_DirectiveKind.REMOVE_POLICY,
126
155
  operand_names=("item",),
127
156
  exact_text=None,
128
157
  renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"),
129
158
  ),
130
- DirectiveKind.REPLACE_USE: _DirectiveSpec(
131
- kind=DirectiveKind.REPLACE_USE,
159
+ _DirectiveKind.REPLACE_USE: _DirectiveSpec(
160
+ kind=_DirectiveKind.REPLACE_USE,
132
161
  operand_names=("new_item", "old_item"),
133
162
  exact_text=None,
134
163
  renderer=_render_replace_use,
135
164
  ),
136
- DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
137
- kind=DirectiveKind.CLEAR_PREMISE,
165
+ _DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
166
+ kind=_DirectiveKind.CLEAR_PREMISE,
138
167
  operand_names=(),
139
- exact_text="clear premise",
140
- renderer=_render_exact("clear premise"),
168
+ exact_text=_CLEAR_PREMISE_TEXT,
169
+ renderer=_render_exact(_CLEAR_PREMISE_TEXT),
141
170
  ),
142
- DirectiveKind.RESET_POLICIES: _DirectiveSpec(
143
- kind=DirectiveKind.RESET_POLICIES,
171
+ _DirectiveKind.RESET_POLICIES: _DirectiveSpec(
172
+ kind=_DirectiveKind.RESET_POLICIES,
144
173
  operand_names=(),
145
- exact_text="reset policies",
146
- renderer=_render_exact("reset policies"),
174
+ exact_text=_RESET_POLICIES_TEXT,
175
+ renderer=_render_exact(_RESET_POLICIES_TEXT),
147
176
  ),
148
- DirectiveKind.CLEAR_STATE: _DirectiveSpec(
149
- kind=DirectiveKind.CLEAR_STATE,
177
+ _DirectiveKind.CLEAR_STATE: _DirectiveSpec(
178
+ kind=_DirectiveKind.CLEAR_STATE,
150
179
  operand_names=(),
151
- exact_text="clear state",
152
- renderer=_render_exact("clear state"),
180
+ exact_text=_CLEAR_STATE_TEXT,
181
+ renderer=_render_exact(_CLEAR_STATE_TEXT),
153
182
  ),
154
183
  }
155
184
  )
@@ -177,15 +206,8 @@ def _operand_starts_with_token(value: str, token: str) -> bool:
177
206
  return normalized == token or normalized.startswith(f"{token} ")
178
207
 
179
208
 
180
- def match_canonical_directive_start(text: str, start: int) -> int | None:
181
- """Locate a canonical directive prefix at a given character position.
182
-
183
- This is a shallow syntax-start matcher: it classifies whether canonical
184
- directive syntax begins at ``start`` and, when it does, returns the index
185
- immediately after the directive keyword prefix. It does not parse operands,
186
- validate the full directive payload, or evaluate multi-directive state
187
- semantics.
188
- """
209
+ def _match_canonical_directive_start(text: str, start: int) -> int | None:
210
+ """Locate a canonical directive prefix at a given character position."""
189
211
  if start < 0 or start >= len(text):
190
212
  return None
191
213
 
@@ -242,27 +264,30 @@ def _match_directive_token(
242
264
  return index
243
265
 
244
266
 
245
- def contains_multiple_canonical_directives(text: str) -> bool:
246
- """Report whether text contains more than one canonical directive start.
247
-
248
- This detects compound directive structure by looking for multiple canonical
249
- directive prefixes in the same input. It is not a replacement for full
250
- directive validation, and it does not parse directive operands, repair
251
- malformed text, or determine whether a whole string should be accepted as a
252
- single directive.
253
- """
254
- first_start = match_canonical_directive_start(text, 0)
267
+ def _contains_multiple_canonical_directives(text: str) -> bool:
268
+ """Report whether text contains more than one canonical directive start."""
269
+ first_start = _match_canonical_directive_start(text, 0)
255
270
  if first_start is None:
256
271
  return False
257
272
 
258
273
  for index in range(first_start, len(text)):
259
- next_start = match_canonical_directive_start(text, index)
274
+ next_start = _match_canonical_directive_start(text, index)
260
275
  if next_start is not None:
261
276
  return True
262
277
 
263
278
  return False
264
279
 
265
280
 
281
+ def _starts_with_directive_family(text: str) -> bool:
282
+ for token, require_space_or_end in _DIRECTIVE_FAMILY_STARTS:
283
+ if (
284
+ _match_directive_token(text, 0, token, require_space_or_end=require_space_or_end)
285
+ is not None
286
+ ):
287
+ return True
288
+ return False
289
+
290
+
266
291
  def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
267
292
  match = _REPLACE_RE.fullmatch(trimmed_text)
268
293
  if match is None:
@@ -280,67 +305,71 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
280
305
  return None
281
306
  return CanonicalDirective(
282
307
  text=trimmed_text,
283
- kind=DirectiveKind.REPLACE_USE,
308
+ kind=_DirectiveKind.REPLACE_USE,
284
309
  operands=MappingProxyType({"new_item": new_item, "old_item": old_item}),
285
310
  )
286
311
 
287
312
 
288
- def decompose_directive(text: str) -> CanonicalDirective | None:
313
+ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None:
289
314
  """Parse one canonical directive into its semantic kind and operands.
290
315
 
291
316
  This determines whether ``text`` is a single canonical directive and, when
292
317
  it is, returns the directive kind plus canonical operand names with the
293
- original operand text preserved. Callers can determine whether ``text`` is
294
- a complete canonical directive by checking whether this returns a non-`None`
295
- result. It does not repair input, infer intent, or evaluate directive
296
- effects against compiler state.
318
+ original operand text preserved. It returns ``None`` when no canonical
319
+ directive is present and returns ``InvalidDirectiveSyntax`` when the text is
320
+ directive-shaped but not valid canonical syntax. It does not repair input,
321
+ infer intent, or evaluate directive effects against compiler state.
297
322
  """
298
323
  trimmed_text = _trim_ascii_whitespace(text)
299
324
  if trimmed_text == "":
300
325
  return None
301
- if contains_multiple_canonical_directives(trimmed_text):
326
+ if not _starts_with_directive_family(trimmed_text):
302
327
  return None
328
+ if _contains_multiple_canonical_directives(trimmed_text):
329
+ return InvalidDirectiveSyntax()
330
+
331
+ invalid_result = InvalidDirectiveSyntax()
303
332
 
304
333
  normalized = _normalized_for_matching(trimmed_text)
305
334
 
306
- if normalized == "clear premise":
335
+ if normalized == _CLEAR_PREMISE_TEXT:
307
336
  return CanonicalDirective(
308
- text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
337
+ text=text, kind=_DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
309
338
  )
310
- if normalized == "reset policies":
339
+ if normalized == _RESET_POLICIES_TEXT:
311
340
  return CanonicalDirective(
312
341
  text=text,
313
- kind=DirectiveKind.RESET_POLICIES,
342
+ kind=_DirectiveKind.RESET_POLICIES,
314
343
  operands=MappingProxyType({}),
315
344
  )
316
- if normalized == "clear state":
345
+ if normalized == _CLEAR_STATE_TEXT:
317
346
  return CanonicalDirective(
318
- text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
347
+ text=text, kind=_DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
319
348
  )
320
349
 
321
350
  if normalized.startswith("set premise "):
322
351
  match = _SET_PREMISE_RE.fullmatch(trimmed_text)
323
352
  if match is None:
324
- return None
353
+ return invalid_result
325
354
  value = match.group("value")
326
355
  if not _operand_has_content(value) or _operand_starts_with_token(value, "to"):
327
- return None
356
+ return invalid_result
328
357
  return CanonicalDirective(
329
358
  text=text,
330
- kind=DirectiveKind.SET_PREMISE,
359
+ kind=_DirectiveKind.SET_PREMISE,
331
360
  operands=MappingProxyType({"value": value}),
332
361
  )
333
362
 
334
363
  if normalized.startswith("change premise to "):
335
364
  match = _CHANGE_PREMISE_RE.fullmatch(trimmed_text)
336
365
  if match is None:
337
- return None
366
+ return invalid_result
338
367
  value = match.group("value")
339
368
  if not _operand_has_content(value):
340
- return None
369
+ return invalid_result
341
370
  return CanonicalDirective(
342
371
  text=text,
343
- kind=DirectiveKind.CHANGE_PREMISE,
372
+ kind=_DirectiveKind.CHANGE_PREMISE,
344
373
  operands=MappingProxyType({"value": value}),
345
374
  )
346
375
 
@@ -351,7 +380,7 @@ def decompose_directive(text: str) -> CanonicalDirective | None:
351
380
  if normalized.startswith("use "):
352
381
  match = _USE_RE.fullmatch(trimmed_text)
353
382
  if match is None:
354
- return None
383
+ return invalid_result
355
384
  item = match.group("item")
356
385
  normalized_item = _normalized_for_matching(item)
357
386
  if (
@@ -360,52 +389,46 @@ def decompose_directive(text: str) -> CanonicalDirective | None:
360
389
  or normalized_item.endswith(" instead of")
361
390
  or _INSTEAD_OF_DELIMITER in normalized_item
362
391
  ):
363
- return None
392
+ return invalid_result
364
393
  return CanonicalDirective(
365
394
  text=text,
366
- kind=DirectiveKind.USE_ITEM,
395
+ kind=_DirectiveKind.USE_ITEM,
367
396
  operands=MappingProxyType({"item": item}),
368
397
  )
369
398
 
370
399
  if normalized.startswith("prohibit "):
371
400
  match = _PROHIBIT_RE.fullmatch(trimmed_text)
372
401
  if match is None:
373
- return None
402
+ return invalid_result
374
403
  item = match.group("item")
375
404
  if not _operand_has_content(item):
376
- return None
405
+ return invalid_result
377
406
  return CanonicalDirective(
378
407
  text=text,
379
- kind=DirectiveKind.PROHIBIT_ITEM,
408
+ kind=_DirectiveKind.PROHIBIT_ITEM,
380
409
  operands=MappingProxyType({"item": item}),
381
410
  )
382
411
 
383
412
  if normalized.startswith("remove policy "):
384
413
  match = _REMOVE_POLICY_RE.fullmatch(trimmed_text)
385
414
  if match is None:
386
- return None
415
+ return invalid_result
387
416
  item = match.group("item")
388
417
  if not _operand_has_content(item):
389
- return None
418
+ return invalid_result
390
419
  return CanonicalDirective(
391
420
  text=text,
392
- kind=DirectiveKind.REMOVE_POLICY,
421
+ kind=_DirectiveKind.REMOVE_POLICY,
393
422
  operands=MappingProxyType({"item": item}),
394
423
  )
395
424
 
396
- return None
397
-
425
+ return invalid_result
398
426
 
399
- def render_directive(kind: DirectiveKind, /, **operands: str) -> str:
400
- """Produce canonical directive text from a semantic kind and operands.
401
427
 
402
- This determines the exact canonical spelling for an existing grammar
403
- capability and rejects operand combinations that would not round-trip as the
404
- requested directive kind. It does not infer missing operands, parse user
405
- input, or extend the grammar with new behaviors.
406
- """
428
+ def _render_directive(kind: _DirectiveKind | str, /, **operands: str) -> str:
429
+ """Produce canonical directive text from a semantic kind and operands."""
407
430
  try:
408
- normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
431
+ normalized_kind = kind if isinstance(kind, _DirectiveKind) else _DirectiveKind(kind)
409
432
  spec = _DIRECTIVE_SPECS[normalized_kind]
410
433
  except (KeyError, ValueError) as exc:
411
434
  raise ValueError(f"Unsupported directive kind: {kind!r}") from exc
@@ -416,33 +439,30 @@ def render_directive(kind: DirectiveKind, /, **operands: str) -> str:
416
439
  missing_names = expected_names - actual_names
417
440
  if missing_names:
418
441
  missing = ", ".join(sorted(missing_names))
419
- raise ValueError(f"Missing required operands for {kind.value}: {missing}")
442
+ raise ValueError(f"Missing required operands for {normalized_kind.value}: {missing}")
420
443
  if unexpected_names:
421
444
  unexpected = ", ".join(sorted(unexpected_names))
422
- raise ValueError(f"Unexpected operands for {kind.value}: {unexpected}")
445
+ raise ValueError(f"Unexpected operands for {normalized_kind.value}: {unexpected}")
423
446
 
424
447
  normalized_operands: dict[str, str] = {}
425
448
  for name in spec.operand_names:
426
449
  raw_value = operands[name]
427
450
  if not isinstance(raw_value, str):
428
- raise ValueError(f"Operand {name!r} for {kind.value} must be a string.")
451
+ raise ValueError(f"Operand {name!r} for {normalized_kind.value} must be a string.")
429
452
  if raw_value.strip() == "":
430
- raise ValueError(f"Operand {name!r} for {kind.value} cannot be empty.")
453
+ raise ValueError(f"Operand {name!r} for {normalized_kind.value} cannot be empty.")
431
454
  normalized_operands[name] = raw_value
432
455
 
433
456
  operand_view = MappingProxyType(normalized_operands)
434
457
  rendered = spec.renderer(operand_view)
435
458
  decomposed = decompose_directive(rendered)
436
- if decomposed is None or decomposed.kind is not normalized_kind:
459
+ if not isinstance(decomposed, CanonicalDirective) or decomposed.kind is not normalized_kind:
437
460
  raise ValueError(f"Operands do not produce a canonical {normalized_kind.value} directive.")
438
461
  return rendered
439
462
 
440
463
 
441
464
  __all__ = [
442
465
  "CanonicalDirective",
443
- "DirectiveKind",
444
- "contains_multiple_canonical_directives",
466
+ "InvalidDirectiveSyntax",
445
467
  "decompose_directive",
446
- "match_canonical_directive_start",
447
- "render_directive",
448
468
  ]
@@ -5,7 +5,7 @@ import sys
5
5
  from collections.abc import Mapping
6
6
  from typing import TextIO
7
7
 
8
- from . import __version__, create_engine
8
+ from . import __version__
9
9
  from .const import STATE_POLICIES, STATE_PREMISE, STATE_VERSION
10
10
  from .decision_helpers import is_error, is_no_directive, is_update
11
11
  from .engine import Decision, DecisionKind, Engine, PolicyValue
@@ -233,7 +233,7 @@ def run_repl(
233
233
  emit NDJSON records.
234
234
  """
235
235
 
236
- active_engine = create_engine() if engine is None else engine
236
+ active_engine = Engine() if engine is None else engine
237
237
 
238
238
  if _is_interactive(in_stream, out_stream):
239
239
  print("Context Compiler REPL (0.5). Type help for commands.", file=out_stream)
@@ -420,7 +420,7 @@ def main() -> int: # pragma: no cover
420
420
  print("error: --json requires non-interactive stdin/stdout.", file=sys.stderr)
421
421
  return 1
422
422
 
423
- engine = create_engine()
423
+ engine = Engine()
424
424
  try:
425
425
  _apply_preload_from_options(engine, options)
426
426
  except (OSError, ValueError) as exc: