context-compiler 0.9.0.dev9__tar.gz → 0.9.0.dev10__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.dev10}/PKG-INFO +6 -7
  2. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/README.md +5 -6
  3. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/08_llm_replacement_precondition.py +24 -24
  4. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/09_llm_confirmation_no_directive.py +24 -27
  5. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/pyproject.toml +1 -1
  6. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/engine.py +1 -1
  7. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/grammar.py +95 -38
  8. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/.gitignore +0 -0
  9. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/LICENSE +0 -0
  10. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/01_llm_contradiction_error.py +0 -0
  11. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/02_llm_constraint_guardrail.py +0 -0
  12. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/03_llm_premise_guardrail.py +0 -0
  13. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/04_llm_tool_denylist_guardrail.py +0 -0
  14. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/05_llm_prompt_drift_vs_state.py +0 -0
  15. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/06_llm_context_compaction.py +0 -0
  16. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/07_llm_prompt_vs_state.py +0 -0
  17. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/README.md +0 -0
  18. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/__init__.py +0 -0
  19. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/common.py +0 -0
  20. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/llm_client.py +0 -0
  21. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/demos/run_demo.py +0 -0
  22. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/01_persistent_guardrails.py +0 -0
  23. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/02_configuration_and_correction.py +0 -0
  24. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/03_ambiguity_with_error.py +0 -0
  25. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/04_tool_governance_denylist.py +0 -0
  26. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/05_llm_integration_pattern.py +0 -0
  27. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/06_step_sequence_and_state_restore.py +0 -0
  28. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/07_single_policy_correction.py +0 -0
  29. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/README.md +0 -0
  30. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/examples/_util.py +0 -0
  31. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/host_support/__init__.py +0 -0
  32. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/host_support/provider_mode.py +0 -0
  33. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/__init__.py +0 -0
  34. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/const.py +0 -0
  35. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/decision_helpers.py +0 -0
  36. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/src/context_compiler/py.typed +0 -0
  37. {context_compiler-0.9.0.dev9 → context_compiler-0.9.0.dev10}/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.dev10
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.0dev10"
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."
@@ -54,16 +54,31 @@ class InvalidDirectiveSyntax:
54
54
  @dataclass(frozen=True, slots=True)
55
55
  class _DirectiveSpec:
56
56
  kind: DirectiveKind
57
+ canonical_start: str
57
58
  operand_names: tuple[str, ...]
58
59
  exact_text: str | None
59
60
  renderer: Callable[[MappingProxyType[str, str]], str]
60
61
 
61
62
 
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 "
63
+ @dataclass(frozen=True, slots=True)
64
+ class DirectiveMetadata:
65
+ """Describe one canonical directive family without exposing parser internals."""
66
+
67
+ kind: DirectiveKind
68
+ canonical_start: str
69
+ operand_names: tuple[str, ...]
70
+
71
+
72
+ _SET_PREMISE_START = "set premise"
73
+ _CHANGE_PREMISE_START = "change premise to"
74
+ _USE_START = "use"
75
+ _PROHIBIT_START = "prohibit"
76
+ _REMOVE_POLICY_START = "remove policy"
77
+ _SET_PREMISE_PREFIX = f"{_SET_PREMISE_START} "
78
+ _CHANGE_PREMISE_PREFIX = f"{_CHANGE_PREMISE_START} "
79
+ _USE_PREFIX = f"{_USE_START} "
80
+ _PROHIBIT_PREFIX = f"{_PROHIBIT_START} "
81
+ _REMOVE_POLICY_PREFIX = f"{_REMOVE_POLICY_START} "
67
82
  _CLEAR_PREMISE_TEXT = "clear premise"
68
83
  _RESET_POLICIES_TEXT = "reset policies"
69
84
  _CLEAR_STATE_TEXT = "clear state"
@@ -81,39 +96,6 @@ _REPLACE_RE = re.compile(
81
96
  r"(?i)^use[ \t]+(?P<new_item>.*?)[ \t]+instead[ \t]+of[ \t]+(?P<old_item>.+)$"
82
97
  )
83
98
 
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
99
 
118
100
  def _render_with_prefix(
119
101
  prefix: str, operand_name: str
@@ -140,54 +122,63 @@ _DIRECTIVE_SPECS = MappingProxyType(
140
122
  {
141
123
  DirectiveKind.SET_PREMISE: _DirectiveSpec(
142
124
  kind=DirectiveKind.SET_PREMISE,
125
+ canonical_start=_SET_PREMISE_START,
143
126
  operand_names=("value",),
144
127
  exact_text=None,
145
128
  renderer=_render_with_prefix(_SET_PREMISE_PREFIX, "value"),
146
129
  ),
147
130
  DirectiveKind.CHANGE_PREMISE: _DirectiveSpec(
148
131
  kind=DirectiveKind.CHANGE_PREMISE,
132
+ canonical_start=_CHANGE_PREMISE_START,
149
133
  operand_names=("value",),
150
134
  exact_text=None,
151
135
  renderer=_render_with_prefix(_CHANGE_PREMISE_PREFIX, "value"),
152
136
  ),
153
137
  DirectiveKind.USE_ITEM: _DirectiveSpec(
154
138
  kind=DirectiveKind.USE_ITEM,
139
+ canonical_start=_USE_START,
155
140
  operand_names=("item",),
156
141
  exact_text=None,
157
142
  renderer=_render_with_prefix(_USE_PREFIX, "item"),
158
143
  ),
159
144
  DirectiveKind.PROHIBIT_ITEM: _DirectiveSpec(
160
145
  kind=DirectiveKind.PROHIBIT_ITEM,
146
+ canonical_start=_PROHIBIT_START,
161
147
  operand_names=("item",),
162
148
  exact_text=None,
163
149
  renderer=_render_with_prefix(_PROHIBIT_PREFIX, "item"),
164
150
  ),
165
151
  DirectiveKind.REMOVE_POLICY: _DirectiveSpec(
166
152
  kind=DirectiveKind.REMOVE_POLICY,
153
+ canonical_start=_REMOVE_POLICY_START,
167
154
  operand_names=("item",),
168
155
  exact_text=None,
169
156
  renderer=_render_with_prefix(_REMOVE_POLICY_PREFIX, "item"),
170
157
  ),
171
158
  DirectiveKind.REPLACE_USE: _DirectiveSpec(
172
159
  kind=DirectiveKind.REPLACE_USE,
160
+ canonical_start=_USE_START,
173
161
  operand_names=("new_item", "old_item"),
174
162
  exact_text=None,
175
163
  renderer=_render_replace_use,
176
164
  ),
177
165
  DirectiveKind.CLEAR_PREMISE: _DirectiveSpec(
178
166
  kind=DirectiveKind.CLEAR_PREMISE,
167
+ canonical_start=_CLEAR_PREMISE_TEXT,
179
168
  operand_names=(),
180
169
  exact_text=_CLEAR_PREMISE_TEXT,
181
170
  renderer=_render_exact(_CLEAR_PREMISE_TEXT),
182
171
  ),
183
172
  DirectiveKind.RESET_POLICIES: _DirectiveSpec(
184
173
  kind=DirectiveKind.RESET_POLICIES,
174
+ canonical_start=_RESET_POLICIES_TEXT,
185
175
  operand_names=(),
186
176
  exact_text=_RESET_POLICIES_TEXT,
187
177
  renderer=_render_exact(_RESET_POLICIES_TEXT),
188
178
  ),
189
179
  DirectiveKind.CLEAR_STATE: _DirectiveSpec(
190
180
  kind=DirectiveKind.CLEAR_STATE,
181
+ canonical_start=_CLEAR_STATE_TEXT,
191
182
  operand_names=(),
192
183
  exact_text=_CLEAR_STATE_TEXT,
193
184
  renderer=_render_exact(_CLEAR_STATE_TEXT),
@@ -196,6 +187,64 @@ _DIRECTIVE_SPECS = MappingProxyType(
196
187
  )
197
188
 
198
189
 
190
+ def _starts_with_descriptor(spec: _DirectiveSpec) -> tuple[str, bool]:
191
+ return (spec.canonical_start, bool(spec.operand_names))
192
+
193
+
194
+ def _unique_start_descriptors(specs: tuple[_DirectiveSpec, ...]) -> tuple[tuple[str, bool], ...]:
195
+ descriptors: list[tuple[str, bool]] = []
196
+ seen: set[tuple[str, bool]] = set()
197
+ for spec in specs:
198
+ descriptor = _starts_with_descriptor(spec)
199
+ if descriptor not in seen:
200
+ seen.add(descriptor)
201
+ descriptors.append(descriptor)
202
+ return tuple(descriptors)
203
+
204
+
205
+ _CANONICAL_START_ORDER = (
206
+ DirectiveKind.CHANGE_PREMISE,
207
+ DirectiveKind.SET_PREMISE,
208
+ DirectiveKind.REMOVE_POLICY,
209
+ DirectiveKind.RESET_POLICIES,
210
+ DirectiveKind.CLEAR_PREMISE,
211
+ DirectiveKind.CLEAR_STATE,
212
+ DirectiveKind.PROHIBIT_ITEM,
213
+ DirectiveKind.USE_ITEM,
214
+ )
215
+
216
+ _CANONICAL_DIRECTIVE_STARTS = _unique_start_descriptors(
217
+ tuple(_DIRECTIVE_SPECS[kind] for kind in _CANONICAL_START_ORDER)
218
+ )
219
+
220
+ _DIRECTIVE_FAMILY_STARTS = (
221
+ (_CHANGE_PREMISE_FAMILY, True),
222
+ *_unique_start_descriptors(
223
+ tuple(
224
+ _DIRECTIVE_SPECS[kind]
225
+ for kind in (
226
+ DirectiveKind.SET_PREMISE,
227
+ DirectiveKind.REMOVE_POLICY,
228
+ DirectiveKind.RESET_POLICIES,
229
+ DirectiveKind.CLEAR_PREMISE,
230
+ DirectiveKind.CLEAR_STATE,
231
+ DirectiveKind.PROHIBIT_ITEM,
232
+ DirectiveKind.USE_ITEM,
233
+ )
234
+ )
235
+ ),
236
+ )
237
+
238
+ _PUBLIC_DIRECTIVE_METADATA = tuple(
239
+ DirectiveMetadata(
240
+ kind=spec.kind,
241
+ canonical_start=spec.canonical_start,
242
+ operand_names=spec.operand_names,
243
+ )
244
+ for spec in _DIRECTIVE_SPECS.values()
245
+ )
246
+
247
+
199
248
  def _trim_ascii_whitespace(text: str) -> str:
200
249
  return text.strip(_ASCII_WHITESPACE)
201
250
 
@@ -533,6 +582,12 @@ def decompose_directive(text: str) -> CanonicalDirective | InvalidDirectiveSynta
533
582
  return _invalid_directive_syntax(DirectiveSyntaxFailure.MALFORMED_DIRECTIVE)
534
583
 
535
584
 
585
+ def get_directive_metadata() -> tuple[DirectiveMetadata, ...]:
586
+ """Return immutable public directive metadata derived from internal specs."""
587
+
588
+ return _PUBLIC_DIRECTIVE_METADATA
589
+
590
+
536
591
  def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
537
592
  """Produce canonical directive text from a semantic kind and operands."""
538
593
  try:
@@ -572,7 +627,9 @@ def _render_directive(kind: DirectiveKind | str, /, **operands: str) -> str:
572
627
  __all__ = [
573
628
  "DirectiveKind",
574
629
  "DirectiveSyntaxFailure",
630
+ "DirectiveMetadata",
575
631
  "CanonicalDirective",
576
632
  "InvalidDirectiveSyntax",
633
+ "get_directive_metadata",
577
634
  "decompose_directive",
578
635
  ]