context-compiler 0.9.0.dev9__tar.gz → 0.9.0.dev11__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.dev9 → context_compiler-0.9.0.dev11}/PKG-INFO +6 -7
  2. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/README.md +5 -6
  3. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/08_llm_replacement_precondition.py +24 -24
  4. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/09_llm_confirmation_no_directive.py +24 -27
  5. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/pyproject.toml +1 -1
  6. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/engine.py +11 -2
  7. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/grammar.py +193 -86
  8. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/.gitignore +0 -0
  9. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/LICENSE +0 -0
  10. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/01_llm_contradiction_error.py +0 -0
  11. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/02_llm_constraint_guardrail.py +0 -0
  12. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/03_llm_premise_guardrail.py +0 -0
  13. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/04_llm_tool_denylist_guardrail.py +0 -0
  14. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/05_llm_prompt_drift_vs_state.py +0 -0
  15. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/06_llm_context_compaction.py +0 -0
  16. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/07_llm_prompt_vs_state.py +0 -0
  17. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/README.md +0 -0
  18. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/__init__.py +0 -0
  19. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/common.py +0 -0
  20. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/llm_client.py +0 -0
  21. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/demos/run_demo.py +0 -0
  22. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/01_persistent_guardrails.py +0 -0
  23. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/02_configuration_and_correction.py +0 -0
  24. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/03_ambiguity_with_error.py +0 -0
  25. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/04_tool_governance_denylist.py +0 -0
  26. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/05_llm_integration_pattern.py +0 -0
  27. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/06_step_sequence_and_state_restore.py +0 -0
  28. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/07_single_policy_correction.py +0 -0
  29. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/README.md +0 -0
  30. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/examples/_util.py +0 -0
  31. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/host_support/__init__.py +0 -0
  32. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/host_support/provider_mode.py +0 -0
  33. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/const.py +0 -0
  35. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/decision_helpers.py +0 -0
  36. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/py.typed +0 -0
  37. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev11}/src/context_compiler/repl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: context-compiler
3
- Version: 0.9.0.dev9
3
+ Version: 0.9.0.dev11
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
@@ -414,10 +414,9 @@ Replacement:
414
414
  User: use podman instead of docker
415
415
  ```
416
416
 
417
- If `docker` is absent from saved state, that does not make the directive
418
- pending. The user's intended resulting state is still unambiguous, so the
419
- replacement follows the deterministic `use podman` transition when otherwise
420
- semantically valid.
417
+ If `docker` is absent from saved state, that is a semantic `error`.
418
+ Canonical replacement requires an active existing source `use` policy and
419
+ does not degrade to plain `use podman`.
421
420
 
422
421
  Removal and reset:
423
422
 
@@ -435,8 +434,8 @@ evaluation against authoritative state.
435
434
  Pending continuation is a separate runtime layer. It may exist only after a
436
435
  canonical directive reaches a supported semantic `error` case. It never
437
436
  repairs malformed syntax or reinterprets non-canonical input as a directive.
438
- An absent source item in a canonical replacement directive is not, by itself,
439
- such a `error` case.
437
+ An absent source item in a canonical replacement directive is itself a
438
+ semantic `error` case and does not authorize degradation to plain `use`.
440
439
 
441
440
  Examples:
442
441
 
@@ -392,10 +392,9 @@ Replacement:
392
392
  User: use podman instead of docker
393
393
  ```
394
394
 
395
- If `docker` is absent from saved state, that does not make the directive
396
- pending. The user's intended resulting state is still unambiguous, so the
397
- replacement follows the deterministic `use podman` transition when otherwise
398
- semantically valid.
395
+ If `docker` is absent from saved state, that is a semantic `error`.
396
+ Canonical replacement requires an active existing source `use` policy and
397
+ does not degrade to plain `use podman`.
399
398
 
400
399
  Removal and reset:
401
400
 
@@ -413,8 +412,8 @@ evaluation against authoritative state.
413
412
  Pending continuation is a separate runtime layer. It may exist only after a
414
413
  canonical directive reaches a supported semantic `error` case. It never
415
414
  repairs malformed syntax or reinterprets non-canonical input as a directive.
416
- An absent source item in a canonical replacement directive is not, by itself,
417
- such a `error` case.
415
+ An absent source item in a canonical replacement directive is itself a
416
+ semantic `error` case and does not authorize degradation to plain `use`.
418
417
 
419
418
  Examples:
420
419
 
@@ -1,10 +1,10 @@
1
- """Demo 8: missing-source replacement applies deterministically from authoritative state."""
1
+ """Demo 8: missing-source replacement fails without mutating authoritative state."""
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
6
  Engine,
7
- is_update,
7
+ is_error,
8
8
  )
9
9
  from demos.common import (
10
10
  build_baseline_messages,
@@ -22,7 +22,7 @@ from demos.common import (
22
22
  )
23
23
  from demos.llm_client import complete_messages
24
24
 
25
- DEMO_NAME = "08_replacement_precondition — missing-source replacement applies deterministically"
25
+ DEMO_NAME = "08_replacement_precondition — missing-source replacement requires active source"
26
26
  USER_INPUT = "use podman instead of docker"
27
27
 
28
28
 
@@ -69,38 +69,38 @@ def main() -> None:
69
69
  reinjected_output = complete_messages(reinjected_messages)
70
70
  print_model_output("Reinjected-state", reinjected_output)
71
71
 
72
- if is_update(decision):
72
+ if is_error(decision):
73
73
  print_messages("compiler-mediated (full)", [])
74
- mediated_output = "[no call] authoritative state applied deterministic replacement update"
74
+ mediated_output = "[no call] authoritative state blocked replacement without source use"
75
75
  print_model_output("Compiler-mediated (full)", mediated_output)
76
76
  else:
77
77
  print_messages("compiler-mediated (full)", [])
78
- mediated_output = "[no call] expected update was not produced"
78
+ mediated_output = "[no call] expected semantic replacement error was not produced"
79
79
  print_model_output("Compiler-mediated (full)", mediated_output)
80
80
 
81
81
  compacted_turns, compacted_state, compacted_prompt = compact_user_turns(user_inputs)
82
- if compacted_prompt is None:
82
+ if compacted_prompt is not None:
83
83
  print_messages("compiler-mediated + compact", [])
84
- compact_output = "[no call] compaction preserved deterministic state update"
84
+ compact_output = "[no call] unexpected error was produced during compaction"
85
85
  print_model_output("Compiler-mediated + compact", compact_output)
86
86
  else:
87
87
  print_messages("compiler-mediated + compact", [])
88
- compact_output = "[no call] unexpected error was produced during compaction"
88
+ compact_output = "[no call] compaction preserved replacement error without state mutation"
89
89
  print_model_output("Compiler-mediated + compact", compact_output)
90
90
 
91
91
  premise, policies = observe_engine(engine)
92
92
  compacted_premise, compacted_policies = state_observations(compacted_state)
93
- state_applied = not _is_initial_authoritative_state(premise=premise, policies=policies)
94
- compact_state_applied = not _is_initial_authoritative_state(
93
+ state_preserved = _is_initial_authoritative_state(premise=premise, policies=policies)
94
+ compact_state_preserved = _is_initial_authoritative_state(
95
95
  premise=compacted_premise,
96
96
  policies=compacted_policies,
97
97
  )
98
- compact_no_pending = compacted_prompt is None
98
+ compact_error_preserved = compacted_prompt is not None
99
99
 
100
100
  baseline_has_authoritative_precondition = False
101
101
  reinjected_has_authoritative_precondition = False
102
- compiler_pass = is_update(decision) and state_applied
103
- compact_pass = compacted_prompt is None and compact_state_applied and compact_no_pending
102
+ compiler_pass = is_error(decision) and state_preserved
103
+ compact_pass = compact_error_preserved and compact_state_preserved
104
104
 
105
105
  print_host_check(
106
106
  "BASELINE_AUTHORITATIVE_PRECONDITION",
@@ -114,12 +114,12 @@ def main() -> None:
114
114
  )
115
115
  print_host_check(
116
116
  "COMPILER_BLOCKED_INVALID_REPLACEMENT",
117
- yes_no(is_update(decision)),
117
+ yes_no(is_error(decision)),
118
118
  context="compiler-mediated",
119
119
  )
120
120
  print_host_check(
121
- "COMPILER_STATE_APPLIED",
122
- yes_no(state_applied),
121
+ "COMPILER_STATE_PRESERVED",
122
+ yes_no(state_preserved),
123
123
  context="compiler-mediated",
124
124
  )
125
125
 
@@ -130,18 +130,18 @@ def main() -> None:
130
130
  compiler_pass=compiler_pass,
131
131
  compiler_compact_pass=compact_pass,
132
132
  expected=(
133
- "missing-source replacement should deterministically apply the resulting use update "
134
- "without pending continuation"
133
+ "missing-source replacement should return semantic error without mutating "
134
+ "authoritative state"
135
135
  ),
136
136
  actual=(
137
- "compiler applied deterministic replacement update; baseline and reinjected paths "
138
- "still lack authoritative state enforcement"
137
+ "compiler blocked missing-source replacement without mutating state; baseline and "
138
+ "reinjected paths still lack authoritative state enforcement"
139
139
  if compiler_pass and compact_pass
140
- else "compiler did not consistently apply deterministic replacement behavior"
140
+ else "compiler did not consistently enforce the replacement source precondition"
141
141
  ),
142
142
  passed=compiler_pass and compact_pass,
143
- result_pass="missing-source replacement applied deterministically",
144
- result_fail="missing-source replacement did not apply deterministically",
143
+ result_pass="missing-source replacement was rejected without mutation",
144
+ result_fail="missing-source replacement was not rejected correctly",
145
145
  )
146
146
 
147
147
 
@@ -1,11 +1,11 @@
1
- """Demo 9: confirmation-style followups remain ordinary no_directive."""
1
+ """Demo 9: replacement errors do not create confirmation-style followup state."""
2
2
 
3
3
  from collections.abc import Mapping
4
4
 
5
5
  from context_compiler import (
6
6
  Engine,
7
+ is_error,
7
8
  is_no_directive,
8
- is_update,
9
9
  )
10
10
  from demos.common import (
11
11
  build_baseline_messages,
@@ -24,8 +24,7 @@ from demos.common import (
24
24
  from demos.llm_client import complete_messages
25
25
 
26
26
  DEMO_NAME = (
27
- "09_confirmation_no_directive_boundary — "
28
- "missing-source replacement does not create a confirmation state"
27
+ "09_confirmation_no_directive_boundary — replacement errors do not create a confirmation state"
29
28
  )
30
29
  TURN_1 = "use podman instead of docker"
31
30
  TURN_2 = "maybe"
@@ -34,10 +33,6 @@ INITIAL_PREMISE: str | None = None
34
33
  INITIAL_POLICIES: dict[str, str] = {}
35
34
 
36
35
 
37
- def _has_podman_use(policies: Mapping[str, str]) -> bool:
38
- return policies.get("podman") == "use"
39
-
40
-
41
36
  def _is_initial_authoritative_state(*, premise: str | None, policies: Mapping[str, str]) -> bool:
42
37
  return premise == INITIAL_PREMISE and dict(policies) == INITIAL_POLICIES
43
38
 
@@ -50,12 +45,17 @@ def main() -> None:
50
45
  first = engine.step(TURN_1)
51
46
  premise, policies = observe_engine(engine)
52
47
  print_decision("turn 1", first, premise=premise, policies=policies)
53
- state_applied_after_first = _has_podman_use(policies)
48
+ state_preserved_after_first = _is_initial_authoritative_state(
49
+ premise=premise, policies=policies
50
+ )
54
51
 
55
52
  second = engine.step(TURN_2)
56
53
  premise, policies = observe_engine(engine)
57
54
  print_decision("turn 2", second, premise=premise, policies=policies)
58
- state_preserved_after_second = _has_podman_use(policies)
55
+ state_preserved_after_second = _is_initial_authoritative_state(
56
+ premise=premise,
57
+ policies=policies,
58
+ )
59
59
 
60
60
  third = engine.step(TURN_3)
61
61
  premise, policies = observe_engine(engine)
@@ -96,7 +96,7 @@ def main() -> None:
96
96
  compacted_turns, compacted_state, compacted_prompt = compact_user_turns(user_inputs)
97
97
  if compacted_prompt is not None:
98
98
  print_messages("compiler-mediated + compact", [])
99
- compact_output = f"[no call] error required: {compacted_prompt}"
99
+ compact_output = f"[no call] replacement error preserved: {compacted_prompt}"
100
100
  print_model_output("Compiler-mediated + compact", compact_output)
101
101
  else:
102
102
  print_messages("compiler-mediated + compact", [])
@@ -105,32 +105,30 @@ def main() -> None:
105
105
  )
106
106
  print_model_output("Compiler-mediated + compact", compact_output)
107
107
 
108
- deterministic_initial_update = is_update(first) and state_applied_after_first
108
+ deterministic_initial_error = is_error(first) and state_preserved_after_first
109
109
  unrelated_followup_no_directive = is_no_directive(second) and state_preserved_after_second
110
110
  confirmation_token_not_consumed = is_no_directive(third)
111
111
  premise, policies = observe_engine(engine)
112
- deterministic_final_state = _has_podman_use(policies)
112
+ deterministic_final_state = _is_initial_authoritative_state(premise=premise, policies=policies)
113
113
  _, compacted_policies = state_observations(compacted_state)
114
114
 
115
115
  baseline_has_confirmation_state_machine = False
116
116
  reinjected_has_confirmation_state_machine = False
117
117
 
118
118
  compiler_pass = (
119
- deterministic_initial_update
119
+ deterministic_initial_error
120
120
  and unrelated_followup_no_directive
121
121
  and confirmation_token_not_consumed
122
122
  and deterministic_final_state
123
123
  )
124
124
 
125
125
  compact_pass = (
126
- compacted_prompt is None
127
- and compacted_turns == [TURN_2, TURN_3]
128
- and _has_podman_use(compacted_policies)
126
+ compacted_prompt is not None and compacted_turns == [TURN_1] and compacted_policies == {}
129
127
  )
130
128
 
131
129
  print_host_check(
132
- "DETERMINISTIC_INITIAL_UPDATE",
133
- yes_no(deterministic_initial_update),
130
+ "DETERMINISTIC_INITIAL_ERROR",
131
+ yes_no(deterministic_initial_error),
134
132
  context="compiler-mediated",
135
133
  )
136
134
  print_host_check(
@@ -144,7 +142,7 @@ def main() -> None:
144
142
  context="compiler-mediated",
145
143
  )
146
144
  print_host_check(
147
- "FINAL_POLICY_PODMAN_PRESENT",
145
+ "FINAL_STATE_UNCHANGED",
148
146
  yes_no(deterministic_final_state),
149
147
  context="compiler-mediated",
150
148
  )
@@ -156,19 +154,18 @@ def main() -> None:
156
154
  compiler_pass=compiler_pass,
157
155
  compiler_compact_pass=compact_pass,
158
156
  expected=(
159
- "missing-source replacement should apply without creating an engine-owned "
160
- "confirmation state, and later yes/no-style input should remain ordinary "
161
- "no_directive"
157
+ "replacement error should not create an engine-owned confirmation state, "
158
+ "and later yes/no-style input should remain ordinary no_directive"
162
159
  ),
163
160
  actual=(
164
- "compiler applied deterministic replacement update and treated later inputs as "
165
- "ordinary no_directive"
161
+ "compiler returned semantic error and treated later inputs as ordinary "
162
+ "no_directive without mutating state"
166
163
  if compiler_pass and compact_pass
167
164
  else "compiler did not consistently preserve the confirmation-no_directive boundary"
168
165
  ),
169
166
  passed=compiler_pass and compact_pass,
170
- result_pass="missing-source replacement stayed outside engine-owned confirmation state",
171
- result_fail="missing-source replacement still behaved like engine-owned confirmation state",
167
+ result_pass="replacement error stayed outside engine-owned confirmation state",
168
+ result_fail="replacement error still behaved like engine-owned confirmation state",
172
169
  )
173
170
 
174
171
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "context-compiler"
7
- version = "0.9.0dev9"
7
+ version = "0.9.0dev11"
8
8
  description = "Deterministic conversational state engine for LLM applications."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -176,7 +176,7 @@ class Engine:
176
176
  f'"{new_item}" is currently prohibited.\n'
177
177
  "Submit explicit directive(s) to remove it or use a different item."
178
178
  )
179
- if old_state not in {None, POLICY_USE}:
179
+ if old_state != POLICY_USE:
180
180
  return _error(
181
181
  f'"{old_item}" is not currently in use.\n'
182
182
  "Replacement requires an active 'use' policy."
@@ -276,7 +276,12 @@ def _load_state_obj(raw: object) -> _State:
276
276
  if not isinstance(policies, dict):
277
277
  raise ValueError("Invalid state payload.")
278
278
 
279
+ sanitized_premise = None if premise is None else _sanitize_premise_value(premise)
280
+ if premise is not None and sanitized_premise == "":
281
+ raise ValueError("Invalid state payload.")
282
+
279
283
  normalized_policies: dict[str, PolicyValue] = {}
284
+ seen_policy_sources: dict[str, str] = {}
280
285
  for key, value in policies.items():
281
286
  if not isinstance(key, str):
282
287
  raise ValueError("Invalid state payload.")
@@ -285,10 +290,14 @@ def _load_state_obj(raw: object) -> _State:
285
290
  normalized_key = _normalize_item(key)
286
291
  if normalized_key == "":
287
292
  raise ValueError("Invalid state payload.")
293
+ prior_source = seen_policy_sources.get(normalized_key)
294
+ if prior_source is not None and prior_source != key:
295
+ raise ValueError("Invalid state payload.")
296
+ seen_policy_sources[normalized_key] = key
288
297
  normalized_policies[normalized_key] = value
289
298
 
290
299
  return {
291
- STATE_PREMISE: None if premise is None else _sanitize_premise_value(premise),
300
+ STATE_PREMISE: sanitized_premise,
292
301
  STATE_POLICIES: dict(sorted(normalized_policies.items())),
293
302
  STATE_VERSION: SCHEMA_VERSION,
294
303
  }
@@ -1,7 +1,7 @@
1
1
  """Immutable canonical grammar helpers for Context Compiler directives."""
2
2
 
3
3
  import re
4
- from collections.abc import Callable
4
+ from collections.abc import Callable, Mapping
5
5
  from dataclasses import dataclass
6
6
  from enum import StrEnum
7
7
  from types import MappingProxyType
@@ -33,14 +33,24 @@ class DirectiveSyntaxFailure(StrEnum):
33
33
  class CanonicalDirective:
34
34
  """Represent one parsed canonical directive and its named operands.
35
35
 
36
- ``text`` preserves the original accepted input text. It may retain caller
37
- formatting or casing and is not canonical serialized directive text.
36
+ ``text`` is the canonical serialized directive text derived from ``kind``
37
+ and ``operands``.
38
38
  """
39
39
 
40
- text: str
41
40
  kind: DirectiveKind
42
41
  operands: MappingProxyType[str, str]
43
42
 
43
+ def __post_init__(self) -> None:
44
+ normalized_kind = _normalize_directive_kind(self.kind)
45
+ normalized_operands = _normalize_canonical_operands(normalized_kind, self.operands)
46
+ object.__setattr__(self, "kind", normalized_kind)
47
+ object.__setattr__(self, "operands", MappingProxyType(normalized_operands))
48
+
49
+ @property
50
+ def text(self) -> str:
51
+ """Return the canonical serialized directive text."""
52
+ return _serialize_canonical_directive(self.kind, self.operands)
53
+
44
54
 
45
55
  @dataclass(frozen=True, slots=True)
46
56
  class InvalidDirectiveSyntax:
@@ -54,16 +64,31 @@ class InvalidDirectiveSyntax:
54
64
  @dataclass(frozen=True, slots=True)
55
65
  class _DirectiveSpec:
56
66
  kind: DirectiveKind
67
+ canonical_start: str
57
68
  operand_names: tuple[str, ...]
58
69
  exact_text: str | None
59
70
  renderer: Callable[[MappingProxyType[str, str]], str]
60
71
 
61
72
 
62
- _SET_PREMISE_PREFIX = "set premise "
63
- _CHANGE_PREMISE_PREFIX = "change premise to "
64
- _USE_PREFIX = "use "
65
- _PROHIBIT_PREFIX = "prohibit "
66
- _REMOVE_POLICY_PREFIX = "remove policy "
73
+ @dataclass(frozen=True, slots=True)
74
+ class DirectiveMetadata:
75
+ """Describe one canonical directive family without exposing parser internals."""
76
+
77
+ kind: DirectiveKind
78
+ canonical_start: str
79
+ operand_names: tuple[str, ...]
80
+
81
+
82
+ _SET_PREMISE_START = "set premise"
83
+ _CHANGE_PREMISE_START = "change premise to"
84
+ _USE_START = "use"
85
+ _PROHIBIT_START = "prohibit"
86
+ _REMOVE_POLICY_START = "remove policy"
87
+ _SET_PREMISE_PREFIX = f"{_SET_PREMISE_START} "
88
+ _CHANGE_PREMISE_PREFIX = f"{_CHANGE_PREMISE_START} "
89
+ _USE_PREFIX = f"{_USE_START} "
90
+ _PROHIBIT_PREFIX = f"{_PROHIBIT_START} "
91
+ _REMOVE_POLICY_PREFIX = f"{_REMOVE_POLICY_START} "
67
92
  _CLEAR_PREMISE_TEXT = "clear premise"
68
93
  _RESET_POLICIES_TEXT = "reset policies"
69
94
  _CLEAR_STATE_TEXT = "clear state"
@@ -81,39 +106,6 @@ _REPLACE_RE = re.compile(
81
106
  r"(?i)^use[ \t]+(?P<new_item>.*?)[ \t]+instead[ \t]+of[ \t]+(?P<old_item>.+)$"
82
107
  )
83
108
 
84
- _PREFIX_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
85
- (_CHANGE_PREMISE_PREFIX.removesuffix(" "), True),
86
- (_SET_PREMISE_PREFIX.removesuffix(" "), True),
87
- (_REMOVE_POLICY_PREFIX.removesuffix(" "), True),
88
- (_PROHIBIT_PREFIX.removesuffix(" "), True),
89
- (_USE_PREFIX.removesuffix(" "), True),
90
- )
91
-
92
- _EXACT_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
93
- (_RESET_POLICIES_TEXT, False),
94
- (_CLEAR_PREMISE_TEXT, False),
95
- (_CLEAR_STATE_TEXT, False),
96
- )
97
-
98
- _CANONICAL_DIRECTIVE_STARTS: tuple[tuple[str, bool], ...] = (
99
- _PREFIX_DIRECTIVE_STARTS[0],
100
- _PREFIX_DIRECTIVE_STARTS[1],
101
- _PREFIX_DIRECTIVE_STARTS[2],
102
- _EXACT_DIRECTIVE_STARTS[0],
103
- _EXACT_DIRECTIVE_STARTS[1],
104
- _EXACT_DIRECTIVE_STARTS[2],
105
- _PREFIX_DIRECTIVE_STARTS[3],
106
- _PREFIX_DIRECTIVE_STARTS[4],
107
- )
108
-
109
- _DIRECTIVE_FAMILY_STARTS: tuple[tuple[str, bool], ...] = (
110
- (_CHANGE_PREMISE_FAMILY, True),
111
- _PREFIX_DIRECTIVE_STARTS[1],
112
- _PREFIX_DIRECTIVE_STARTS[2],
113
- *_EXACT_DIRECTIVE_STARTS,
114
- *_PREFIX_DIRECTIVE_STARTS[3:],
115
- )
116
-
117
109
 
118
110
  def _render_with_prefix(
119
111
  prefix: str, operand_name: str
@@ -140,54 +132,63 @@ _DIRECTIVE_SPECS = MappingProxyType(
140
132
  {
141
133
  DirectiveKind.SET_PREMISE: _DirectiveSpec(
142
134
  kind=DirectiveKind.SET_PREMISE,
135
+ canonical_start=_SET_PREMISE_START,
143
136
  operand_names=("value",),
144
137
  exact_text=None,
145
138
  renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"),
146
139
  ),
147
140
  DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
148
141
  kind=DirectiveKind.CHANGE_PREMISE,
142
+ canonical_start=_CHANGE_PREMISE_START,
149
143
  operand_names=("value",),
150
144
  exact_text=None,
151
145
  renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"),
152
146
  ),
153
147
  DirectiveKind.USE_ITEM: _DirectiveSpec(
154
148
  kind=DirectiveKind.USE_ITEM,
149
+ canonical_start=_USE_START,
155
150
  operand_names=("item",),
156
151
  exact_text=None,
157
152
  renderer=_render_with_prefix(_USE_PREFIX, "item"),
158
153
  ),
159
154
  DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
160
155
  kind=DirectiveKind.PROHIBIT_ITEM,
156
+ canonical_start=_PROHIBIT_START,
161
157
  operand_names=("item",),
162
158
  exact_text=None,
163
159
  renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"),
164
160
  ),
165
161
  DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
166
162
  kind=DirectiveKind.REMOVE_POLICY,
163
+ canonical_start=_REMOVE_POLICY_START,
167
164
  operand_names=("item",),
168
165
  exact_text=None,
169
166
  renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"),
170
167
  ),
171
168
  DirectiveKind.REPLACE_USE: _DirectiveSpec(
172
169
  kind=DirectiveKind.REPLACE_USE,
170
+ canonical_start=_USE_START,
173
171
  operand_names=("new_item", "old_item"),
174
172
  exact_text=None,
175
173
  renderer=_render_replace_use,
176
174
  ),
177
175
  DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
178
176
  kind=DirectiveKind.CLEAR_PREMISE,
177
+ canonical_start=_CLEAR_PREMISE_TEXT,
179
178
  operand_names=(),
180
179
  exact_text=_CLEAR_PREMISE_TEXT,
181
180
  renderer=_render_exact(_CLEAR_PREMISE_TEXT),
182
181
  ),
183
182
  DirectiveKind.RESET_POLICIES: _DirectiveSpec(
184
183
  kind=DirectiveKind.RESET_POLICIES,
184
+ canonical_start=_RESET_POLICIES_TEXT,
185
185
  operand_names=(),
186
186
  exact_text=_RESET_POLICIES_TEXT,
187
187
  renderer=_render_exact(_RESET_POLICIES_TEXT),
188
188
  ),
189
189
  DirectiveKind.CLEAR_STATE: _DirectiveSpec(
190
190
  kind=DirectiveKind.CLEAR_STATE,
191
+ canonical_start=_CLEAR_STATE_TEXT,
191
192
  operand_names=(),
192
193
  exact_text=_CLEAR_STATE_TEXT,
193
194
  renderer=_render_exact(_CLEAR_STATE_TEXT),
@@ -196,6 +197,64 @@ _DIRECTIVE_SPECS = MappingProxyType(
196
197
  )
197
198
 
198
199
 
200
+ def _starts_with_descriptor(spec: _DirectiveSpec) -> tuple[str, bool]:
201
+ return (spec.canonical_start, bool(spec.operand_names))
202
+
203
+
204
+ def _unique_start_descriptors(specs: tuple[_DirectiveSpec, ...]) -> tuple[tuple[str, bool], ...]:
205
+ descriptors: list[tuple[str, bool]] = []
206
+ seen: set[tuple[str, bool]] = set()
207
+ for spec in specs:
208
+ descriptor = _starts_with_descriptor(spec)
209
+ if descriptor not in seen:
210
+ seen.add(descriptor)
211
+ descriptors.append(descriptor)
212
+ return tuple(descriptors)
213
+
214
+
215
+ _CANONICAL_START_ORDER = (
216
+ DirectiveKind.CHANGE_PREMISE,
217
+ DirectiveKind.SET_PREMISE,
218
+ DirectiveKind.REMOVE_POLICY,
219
+ DirectiveKind.RESET_POLICIES,
220
+ DirectiveKind.CLEAR_PREMISE,
221
+ DirectiveKind.CLEAR_STATE,
222
+ DirectiveKind.PROHIBIT_ITEM,
223
+ DirectiveKind.USE_ITEM,
224
+ )
225
+
226
+ _CANONICAL_DIRECTIVE_STARTS = _unique_start_descriptors(
227
+ tuple(_DIRECTIVE_SPECS[kind] for kind in _CANONICAL_START_ORDER)
228
+ )
229
+
230
+ _DIRECTIVE_FAMILY_STARTS = (
231
+ (_CHANGE_PREMISE_FAMILY, True),
232
+ *_unique_start_descriptors(
233
+ tuple(
234
+ _DIRECTIVE_SPECS[kind]
235
+ for kind in (
236
+ DirectiveKind.SET_PREMISE,
237
+ DirectiveKind.REMOVE_POLICY,
238
+ DirectiveKind.RESET_POLICIES,
239
+ DirectiveKind.CLEAR_PREMISE,
240
+ DirectiveKind.CLEAR_STATE,
241
+ DirectiveKind.PROHIBIT_ITEM,
242
+ DirectiveKind.USE_ITEM,
243
+ )
244
+ )
245
+ ),
246
+ )
247
+
248
+ _PUBLIC_DIRECTIVE_METADATA = tuple(
249
+ DirectiveMetadata(
250
+ kind=spec.kind,
251
+ canonical_start=spec.canonical_start,
252
+ operand_names=spec.operand_names,
253
+ )
254
+ for spec in _DIRECTIVE_SPECS.values()
255
+ )
256
+
257
+
199
258
  def _trim_ascii_whitespace(text: str) -> str:
200
259
  return text.strip(_ASCII_WHITESPACE)
201
260
 
@@ -316,7 +375,6 @@ def _parse_replace_use(trimmed_text: str) -> CanonicalDirective | None:
316
375
  if normalized_payload.count(_INSTEAD_OF_DELIMITER) != 1:
317
376
  return None
318
377
  return CanonicalDirective(
319
- text=trimmed_text,
320
378
  kind=DirectiveKind.REPLACE_USE,
321
379
  operands=MappingProxyType({"new_item": new_item, "old_item": old_item}),
322
380
  )
@@ -335,6 +393,74 @@ def _invalid_directive_syntax(
335
393
  )
336
394
 
337
395
 
396
+ def _normalize_directive_kind(kind: DirectiveKind | str) -> DirectiveKind:
397
+ try:
398
+ return kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
399
+ except ValueError as exc:
400
+ raise ValueError(f"Unsupported directive kind: {kind!r}") from exc
401
+
402
+
403
+ def _normalize_canonical_operands(
404
+ kind: DirectiveKind,
405
+ operands: Mapping[str, str],
406
+ ) -> dict[str, str]:
407
+ spec = _DIRECTIVE_SPECS[kind]
408
+ expected_names = set(spec.operand_names)
409
+ actual_names = set(operands)
410
+ unexpected_names = actual_names - expected_names
411
+ missing_names = expected_names - actual_names
412
+ if missing_names:
413
+ missing = ", ".join(sorted(missing_names))
414
+ raise ValueError(f"Missing required operands for {kind.value}: {missing}")
415
+ if unexpected_names:
416
+ unexpected = ", ".join(sorted(unexpected_names))
417
+ raise ValueError(f"Unexpected operands for {kind.value}: {unexpected}")
418
+
419
+ normalized_operands: dict[str, str] = {}
420
+ for name in spec.operand_names:
421
+ raw_value = operands[name]
422
+ if not isinstance(raw_value, str):
423
+ raise ValueError(f"Operand {name!r} for {kind.value} must be a string.")
424
+ if not _operand_has_content(raw_value):
425
+ raise ValueError(f"Operand {name!r} for {kind.value} cannot be empty.")
426
+ normalized_operands[name] = raw_value
427
+
428
+ _validate_operand_constraints(kind, normalized_operands)
429
+ _validate_rendered_canonical_shape(kind, normalized_operands)
430
+ return normalized_operands
431
+
432
+
433
+ def _validate_operand_constraints(kind: DirectiveKind, operands: Mapping[str, str]) -> None:
434
+ if kind is DirectiveKind.SET_PREMISE:
435
+ value = operands["value"]
436
+ if _operand_starts_with_token(value, "to"):
437
+ raise ValueError(f"Operands do not produce a canonical {kind.value} directive.")
438
+ return
439
+
440
+ if kind is DirectiveKind.USE_ITEM:
441
+ item = operands["item"]
442
+ normalized_item = _normalized_for_matching(item)
443
+ if normalized_item == "instead of" or normalized_item.startswith("instead of "):
444
+ raise ValueError(f"Operands do not produce a canonical {kind.value} directive.")
445
+ if _INSTEAD_OF_DELIMITER in normalized_item:
446
+ raise ValueError(f"Operands do not produce a canonical {kind.value} directive.")
447
+ return
448
+
449
+ if kind is DirectiveKind.REPLACE_USE:
450
+ new_item = operands["new_item"]
451
+ old_item = operands["old_item"]
452
+ if _INSTEAD_OF_DELIMITER in _normalized_for_matching(
453
+ new_item
454
+ ) or _INSTEAD_OF_DELIMITER in _normalized_for_matching(old_item):
455
+ raise ValueError(f"Operands do not produce a canonical {kind.value} directive.")
456
+
457
+
458
+ def _validate_rendered_canonical_shape(kind: DirectiveKind, operands: Mapping[str, str]) -> None:
459
+ rendered = _serialize_canonical_directive(kind, MappingProxyType(dict(operands)))
460
+ if _contains_multiple_canonical_directives(rendered):
461
+ raise ValueError(f"Operands do not produce a canonical {kind.value} directive.")
462
+
463
+
338
464
  def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSyntax | None:
339
465
  """Parse one canonical directive into its semantic kind and operands.
340
466
 
@@ -356,19 +482,14 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
356
482
  normalized = _normalized_for_matching(trimmed_text)
357
483
 
358
484
  if normalized == _CLEAR_PREMISE_TEXT:
359
- return CanonicalDirective(
360
- text=text, kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({})
361
- )
485
+ return CanonicalDirective(kind=DirectiveKind.CLEAR_PREMISE, operands=MappingProxyType({}))
362
486
  if normalized == _RESET_POLICIES_TEXT:
363
487
  return CanonicalDirective(
364
- text=text,
365
488
  kind=DirectiveKind.RESET_POLICIES,
366
489
  operands=MappingProxyType({}),
367
490
  )
368
491
  if normalized == _CLEAR_STATE_TEXT:
369
- return CanonicalDirective(
370
- text=text, kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({})
371
- )
492
+ return CanonicalDirective(kind=DirectiveKind.CLEAR_STATE, operands=MappingProxyType({}))
372
493
 
373
494
  if normalized == "set premise":
374
495
  return _invalid_directive_syntax(
@@ -391,7 +512,6 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
391
512
  directive_kind=DirectiveKind.SET_PREMISE,
392
513
  )
393
514
  return CanonicalDirective(
394
- text=text,
395
515
  kind=DirectiveKind.SET_PREMISE,
396
516
  operands=MappingProxyType({"value": value}),
397
517
  )
@@ -419,7 +539,6 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
419
539
  missing_operand="value",
420
540
  )
421
541
  return CanonicalDirective(
422
- text=text,
423
542
  kind=DirectiveKind.CHANGE_PREMISE,
424
543
  operands=MappingProxyType({"value": value}),
425
544
  )
@@ -469,7 +588,6 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
469
588
  directive_kind=DirectiveKind.USE_ITEM,
470
589
  )
471
590
  return CanonicalDirective(
472
- text=text,
473
591
  kind=DirectiveKind.USE_ITEM,
474
592
  operands=MappingProxyType({"item": item}),
475
593
  )
@@ -497,7 +615,6 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
497
615
  missing_operand="item",
498
616
  )
499
617
  return CanonicalDirective(
500
- text=text,
501
618
  kind=DirectiveKind.PROHIBIT_ITEM,
502
619
  operands=MappingProxyType({"item": item}),
503
620
  )
@@ -525,7 +642,6 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
525
642
  missing_operand="item",
526
643
  )
527
644
  return CanonicalDirective(
528
- text=text,
529
645
  kind=DirectiveKind.REMOVE_POLICY,
530
646
  operands=MappingProxyType({"item": item}),
531
647
  )
@@ -533,46 +649,37 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
533
649
  return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE)
534
650
 
535
651
 
536
- def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
537
- """Produce canonical directive text from a semantic kind and operands."""
538
- try:
539
- normalized_kind = kind if isinstance(kind, DirectiveKind) else DirectiveKind(kind)
540
- spec = _DIRECTIVE_SPECS[normalized_kind]
541
- except (KeyError, ValueError) as exc:
542
- raise ValueError(f"Unsupported directive kind: {kind!r}") from exc
652
+ def get_directive_metadata() -> tuple[DirectiveMetadata, ...]:
653
+ """Return immutable public directive metadata derived from internal specs."""
543
654
 
544
- expected_names = set(spec.operand_names)
545
- actual_names = set(operands)
546
- unexpected_names = actual_names - expected_names
547
- missing_names = expected_names - actual_names
548
- if missing_names:
549
- missing = ", ".join(sorted(missing_names))
550
- raise ValueError(f"Missing required operands for {normalized_kind.value}: {missing}")
551
- if unexpected_names:
552
- unexpected = ", ".join(sorted(unexpected_names))
553
- raise ValueError(f"Unexpected operands for {normalized_kind.value}: {unexpected}")
655
+ return _PUBLIC_DIRECTIVE_METADATA
554
656
 
555
- normalized_operands: dict[str, str] = {}
556
- for name in spec.operand_names:
557
- raw_value = operands[name]
558
- if not isinstance(raw_value, str):
559
- raise ValueError(f"Operand {name!r} for {normalized_kind.value} must be a string.")
560
- if raw_value.strip() == "":
561
- raise ValueError(f"Operand {name!r} for {normalized_kind.value} cannot be empty.")
562
- normalized_operands[name] = raw_value
563
657
 
564
- operand_view = MappingProxyType(normalized_operands)
565
- rendered = spec.renderer(operand_view)
566
- decomposed = decompose_directive(rendered)
567
- if not isinstance(decomposed, CanonicalDirective) or decomposed.kind is not normalized_kind:
568
- raise ValueError(f"Operands do not produce a canonical {normalized_kind.value} directive.")
569
- return rendered
658
+ def _serialize_canonical_directive(
659
+ kind: DirectiveKind | str, operands: MappingProxyType[str, str]
660
+ ) -> str:
661
+ """Serialize a validated semantic directive without reparsing it."""
662
+ normalized_kind = _normalize_directive_kind(kind)
663
+ spec = _DIRECTIVE_SPECS[normalized_kind]
664
+
665
+ return spec.renderer(operands)
666
+
667
+
668
+ def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
669
+ """Produce canonical directive text from a semantic kind and operands."""
670
+ normalized_kind = _normalize_directive_kind(kind)
671
+ return CanonicalDirective(
672
+ kind=normalized_kind,
673
+ operands=MappingProxyType(dict(operands)),
674
+ ).text
570
675
 
571
676
 
572
677
  __all__ = [
573
678
  "DirectiveKind",
574
679
  "DirectiveSyntaxFailure",
680
+ "DirectiveMetadata",
575
681
  "CanonicalDirective",
576
682
  "InvalidDirectiveSyntax",
683
+ "get_directive_metadata",
577
684
  "decompose_directive",
578
685
  ]