lantern-grammar 0.3.0__py3-none-any.whl

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 (126) hide show
  1. lantern_grammar/__init__.py +33 -0
  2. lantern_grammar/_exceptions.py +40 -0
  3. lantern_grammar/_grammar.py +483 -0
  4. lantern_grammar/_model/index.json +695 -0
  5. lantern_grammar/_model/manifest.json +20 -0
  6. lantern_grammar/_model/objects/Entity/lg__artifacts__arch.json +7 -0
  7. lantern_grammar/_model/objects/Entity/lg__artifacts__ch.json +7 -0
  8. lantern_grammar/_model/objects/Entity/lg__artifacts__ci.json +7 -0
  9. lantern_grammar/_model/objects/Entity/lg__artifacts__db.json +7 -0
  10. lantern_grammar/_model/objects/Entity/lg__artifacts__dc.json +7 -0
  11. lantern_grammar/_model/objects/Entity/lg__artifacts__dip.json +7 -0
  12. lantern_grammar/_model/objects/Entity/lg__artifacts__initiative.json +7 -0
  13. lantern_grammar/_model/objects/Entity/lg__artifacts__issue.json +7 -0
  14. lantern_grammar/_model/objects/Entity/lg__artifacts__question.json +7 -0
  15. lantern_grammar/_model/objects/Entity/lg__artifacts__spec.json +7 -0
  16. lantern_grammar/_model/objects/Entity/lg__artifacts__td.json +7 -0
  17. lantern_grammar/_model/objects/Entity/lg__gates__gt_030.json +13 -0
  18. lantern_grammar/_model/objects/Entity/lg__gates__gt_050.json +15 -0
  19. lantern_grammar/_model/objects/Entity/lg__gates__gt_060.json +15 -0
  20. lantern_grammar/_model/objects/Entity/lg__gates__gt_110.json +17 -0
  21. lantern_grammar/_model/objects/Entity/lg__gates__gt_115.json +19 -0
  22. lantern_grammar/_model/objects/Entity/lg__gates__gt_120.json +18 -0
  23. lantern_grammar/_model/objects/Entity/lg__gates__gt_130.json +18 -0
  24. lantern_grammar/_model/objects/Entity/lg__records__dec.json +7 -0
  25. lantern_grammar/_model/objects/Entity/lg__records__ev.json +7 -0
  26. lantern_grammar/_model/objects/Entity/lg__reltypes__decomposes_to.json +7 -0
  27. lantern_grammar/_model/objects/Entity/lg__reltypes__requires_evidence.json +7 -0
  28. lantern_grammar/_model/objects/Entity/lg__reltypes__requires_input.json +7 -0
  29. lantern_grammar/_model/objects/Entity/lg__reltypes__requires_status.json +7 -0
  30. lantern_grammar/_model/objects/Entity/lg__statuses__addressed.json +7 -0
  31. lantern_grammar/_model/objects/Entity/lg__statuses__approved.json +7 -0
  32. lantern_grammar/_model/objects/Entity/lg__statuses__candidate.json +7 -0
  33. lantern_grammar/_model/objects/Entity/lg__statuses__concluded_initiative.json +7 -0
  34. lantern_grammar/_model/objects/Entity/lg__statuses__draft.json +7 -0
  35. lantern_grammar/_model/objects/Entity/lg__statuses__draft_initiative.json +7 -0
  36. lantern_grammar/_model/objects/Entity/lg__statuses__in_progress_initiative.json +7 -0
  37. lantern_grammar/_model/objects/Entity/lg__statuses__proposed.json +7 -0
  38. lantern_grammar/_model/objects/Entity/lg__statuses__proposed_initiative.json +7 -0
  39. lantern_grammar/_model/objects/Entity/lg__statuses__ready.json +7 -0
  40. lantern_grammar/_model/objects/Entity/lg__statuses__ready_initiative.json +7 -0
  41. lantern_grammar/_model/objects/Entity/lg__statuses__rejected.json +7 -0
  42. lantern_grammar/_model/objects/Entity/lg__statuses__selected.json +7 -0
  43. lantern_grammar/_model/objects/Entity/lg__statuses__superseded.json +7 -0
  44. lantern_grammar/_model/objects/Entity/lg__statuses__verified.json +7 -0
  45. lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_evidence.dec.json +10 -0
  46. lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_evidence.ev.json +10 -0
  47. lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_input.dip.json +10 -0
  48. lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_status.draft.json +10 -0
  49. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_evidence.dec.json +10 -0
  50. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_evidence.ev.json +10 -0
  51. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_input.arch.json +10 -0
  52. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_input.dip.json +10 -0
  53. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_status.approved.json +10 -0
  54. lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_status.draft.json +10 -0
  55. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_evidence.dec.json +10 -0
  56. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_evidence.ev.json +10 -0
  57. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_input.dip.json +10 -0
  58. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_input.spec.json +10 -0
  59. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_status.approved.json +10 -0
  60. lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_status.draft.json +10 -0
  61. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_evidence.dec.json +10 -0
  62. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_evidence.ev.json +10 -0
  63. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.arch.json +10 -0
  64. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.ch.json +10 -0
  65. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.spec.json +10 -0
  66. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.td.json +10 -0
  67. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_status.approved.json +10 -0
  68. lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_status.proposed.json +10 -0
  69. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_evidence.dec.json +10 -0
  70. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_evidence.ev.json +10 -0
  71. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.arch.json +10 -0
  72. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.ch.json +10 -0
  73. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.dc.json +10 -0
  74. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.spec.json +10 -0
  75. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.td.json +10 -0
  76. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.approved.json +10 -0
  77. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.candidate.json +10 -0
  78. lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.ready.json +10 -0
  79. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_evidence.dec.json +10 -0
  80. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_evidence.ev.json +10 -0
  81. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.ch.json +10 -0
  82. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.ci.json +10 -0
  83. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.db.json +10 -0
  84. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.td.json +10 -0
  85. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.approved.json +10 -0
  86. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.candidate.json +10 -0
  87. lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.ready.json +10 -0
  88. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_evidence.dec.json +10 -0
  89. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_evidence.ev.json +10 -0
  90. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.ch.json +10 -0
  91. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.ci.json +10 -0
  92. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.db.json +10 -0
  93. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.td.json +10 -0
  94. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.approved.json +10 -0
  95. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.ready.json +10 -0
  96. lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.selected.json +10 -0
  97. lantern_grammar/_model/objects/Relation/lg__rel__initiative.decomposes_to.ch.json +10 -0
  98. lantern_grammar/_model/objects/Term/lg__vocab__term_arch.json +12 -0
  99. lantern_grammar/_model/objects/Term/lg__vocab__term_ch.json +12 -0
  100. lantern_grammar/_model/objects/Term/lg__vocab__term_ci.json +12 -0
  101. lantern_grammar/_model/objects/Term/lg__vocab__term_db.json +12 -0
  102. lantern_grammar/_model/objects/Term/lg__vocab__term_dc.json +12 -0
  103. lantern_grammar/_model/objects/Term/lg__vocab__term_dec.json +12 -0
  104. lantern_grammar/_model/objects/Term/lg__vocab__term_dip.json +12 -0
  105. lantern_grammar/_model/objects/Term/lg__vocab__term_ev.json +12 -0
  106. lantern_grammar/_model/objects/Term/lg__vocab__term_gate.json +18 -0
  107. lantern_grammar/_model/objects/Term/lg__vocab__term_initiative.json +12 -0
  108. lantern_grammar/_model/objects/Term/lg__vocab__term_issue.json +11 -0
  109. lantern_grammar/_model/objects/Term/lg__vocab__term_question.json +11 -0
  110. lantern_grammar/_model/objects/Term/lg__vocab__term_spec.json +12 -0
  111. lantern_grammar/_model/objects/Term/lg__vocab__term_status_addressed.json +12 -0
  112. lantern_grammar/_model/objects/Term/lg__vocab__term_status_approved.json +12 -0
  113. lantern_grammar/_model/objects/Term/lg__vocab__term_status_draft.json +12 -0
  114. lantern_grammar/_model/objects/Term/lg__vocab__term_status_proposed.json +12 -0
  115. lantern_grammar/_model/objects/Term/lg__vocab__term_status_ready.json +12 -0
  116. lantern_grammar/_model/objects/Term/lg__vocab__term_status_rejected.json +12 -0
  117. lantern_grammar/_model/objects/Term/lg__vocab__term_status_selected.json +12 -0
  118. lantern_grammar/_model/objects/Term/lg__vocab__term_status_superseded.json +12 -0
  119. lantern_grammar/_model/objects/Term/lg__vocab__term_status_verified.json +12 -0
  120. lantern_grammar/_model/objects/Term/lg__vocab__term_td.json +12 -0
  121. lantern_grammar/py.typed +0 -0
  122. lantern_grammar-0.3.0.dist-info/METADATA +243 -0
  123. lantern_grammar-0.3.0.dist-info/RECORD +126 -0
  124. lantern_grammar-0.3.0.dist-info/WHEEL +5 -0
  125. lantern_grammar-0.3.0.dist-info/licenses/LICENSE +201 -0
  126. lantern_grammar-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,33 @@
1
+ # Copyright 2025 Lantern Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """lantern-grammar — authoritative semantic model for Lantern governed workflow.
16
+
17
+ Public stable exports
18
+ ---------------------
19
+ * :class:`Grammar` — read-only projection of the Lantern Grammar model.
20
+ * :exc:`LanternGrammarLoadError` — raised when the model cannot be loaded.
21
+
22
+ Canonical import pattern::
23
+
24
+ from lantern_grammar import Grammar, LanternGrammarLoadError
25
+
26
+ See the project README and decision note DN-LGR-PROP-004 for the full
27
+ compatibility-governed API contract.
28
+ """
29
+
30
+ from ._exceptions import LanternGrammarLoadError
31
+ from ._grammar import Grammar
32
+
33
+ __all__ = ["Grammar", "LanternGrammarLoadError"]
@@ -0,0 +1,40 @@
1
+ # Copyright 2025 Lantern Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Stable exception types for the lantern-grammar package.
16
+
17
+ These exceptions are part of the compatibility-governed public contract.
18
+ See DN-LGR-PROP-004 for the full exception contract.
19
+ """
20
+
21
+
22
+ class LanternGrammarLoadError(Exception):
23
+ """Raised when the Lantern Grammar model cannot be loaded.
24
+
25
+ Returned by construction methods (``Grammar.load()`` and
26
+ ``Grammar.from_directory()``) when model data is absent, unloadable, or
27
+ structurally invalid.
28
+
29
+ Subclasses may be introduced in later minor versions for more specific
30
+ failure modes; they are always catchable as ``LanternGrammarLoadError``.
31
+
32
+ Example::
33
+
34
+ from lantern_grammar import Grammar, LanternGrammarLoadError
35
+
36
+ try:
37
+ grammar = Grammar.load()
38
+ except LanternGrammarLoadError as exc:
39
+ raise RuntimeError("Could not load Lantern Grammar") from exc
40
+ """
@@ -0,0 +1,483 @@
1
+ # Copyright 2025 Lantern Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Core Grammar class — the read-only projection of Lantern Grammar model data.
16
+
17
+ This module is an implementation detail. Consumers should import only from
18
+ ``lantern_grammar``::
19
+
20
+ from lantern_grammar import Grammar, LanternGrammarLoadError
21
+
22
+ Thread safety
23
+ -------------
24
+ A ``Grammar`` instance is immutable after construction and is safe for
25
+ concurrent read-only access across threads. See DN-LGR-PROP-004.
26
+
27
+ Authority boundary
28
+ ------------------
29
+ The packaged model data is the semantic source of truth. This class is a
30
+ projection and query surface over that data; it does not invent or reinterpret
31
+ model meaning. Workflow policy, workbench IDs, and runtime posture remain
32
+ outside this package.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from pathlib import Path
39
+ from types import MappingProxyType
40
+ from typing import Iterator, Optional
41
+
42
+ from ._exceptions import LanternGrammarLoadError
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Location of bundled model data
47
+ # ---------------------------------------------------------------------------
48
+ # Resolution order for Grammar.load():
49
+ #
50
+ # 1. <package_dir>/_model/
51
+ # For installed (non-editable) packages: setuptools copies model/ into
52
+ # _model/ inside the wheel. For editable installs with a _model symlink
53
+ # manually created at src/lantern_grammar/_model -> ../../model, same path.
54
+ #
55
+ # 2. <project_root>/model/
56
+ # Fallback for editable installs in a src/ layout when no _model/ symlink
57
+ # exists. Detected by walking up from the package file to find model/.
58
+ #
59
+ def _find_model_bundle_dir() -> Path:
60
+ pkg_dir = Path(__file__).parent
61
+ # --- Primary: _model/ inside the installed package directory ---
62
+ bundled = pkg_dir / "_model"
63
+ if bundled.is_dir():
64
+ return bundled
65
+ # --- Fallback: project root model/ for src-layout editable installs ---
66
+ # src/lantern_grammar/ -> src/ -> project_root/ -> model/
67
+ candidate = pkg_dir.parent.parent / "model"
68
+ if candidate.is_dir():
69
+ return candidate
70
+ # Return primary path so load() surfaces a clear error.
71
+ return bundled
72
+
73
+
74
+ _MODEL_BUNDLE_DIR: Path = _find_model_bundle_dir()
75
+
76
+ # Canonical relation-type IDs used for gate-dependency categorisation.
77
+ _REQUIRES_INPUT = "lg:reltypes/requires_input"
78
+ _REQUIRES_EVIDENCE = "lg:reltypes/requires_evidence"
79
+ _REQUIRES_STATUS = "lg:reltypes/requires_status"
80
+ _GATE_PREFIX = "lg:gates/"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Grammar
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ class Grammar:
89
+ """Read-only projection of the released Lantern Grammar model.
90
+
91
+ Do not instantiate directly. Use one of the class-method constructors:
92
+
93
+ * ``Grammar.load()`` — load the model bundled with the distribution.
94
+ * ``Grammar.from_directory(path)`` — load from an explicit directory.
95
+
96
+ All public methods are read-only and thread-safe.
97
+ """
98
+
99
+ # --- Internal construction (not public API) ---
100
+
101
+ def __init__(
102
+ self,
103
+ manifest_data: dict,
104
+ entities: dict,
105
+ relations: dict,
106
+ terms: dict,
107
+ package_version: str,
108
+ ) -> None:
109
+ # All collections are built once and never mutated after this point,
110
+ # giving the thread-safety guarantee in the contract.
111
+ self._manifest: MappingProxyType = MappingProxyType(dict(manifest_data))
112
+ self._entities: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in entities.items()}
113
+ self._relations: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in relations.items()}
114
+ self._terms: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in terms.items()}
115
+ self._package_version = package_version
116
+
117
+ # --- Stable construction contract ---
118
+
119
+ @classmethod
120
+ def load(cls) -> "Grammar":
121
+ """Load the Lantern Grammar model bundled with the installed distribution.
122
+
123
+ Returns a ``Grammar`` instance ready for read-only queries.
124
+
125
+ Raises:
126
+ LanternGrammarLoadError: if packaged model data is absent,
127
+ unloadable, or structurally invalid.
128
+ """
129
+ if not _MODEL_BUNDLE_DIR.is_dir():
130
+ raise LanternGrammarLoadError(
131
+ f"Bundled model data not found at {_MODEL_BUNDLE_DIR}. "
132
+ "The lantern-grammar package may not have been installed correctly."
133
+ )
134
+ return cls._load_from_path(_MODEL_BUNDLE_DIR)
135
+
136
+ @classmethod
137
+ def from_directory(cls, path) -> "Grammar":
138
+ """Load a Lantern Grammar model from an explicit filesystem directory.
139
+
140
+ This constructor is stable because Lantern and package tests need a
141
+ deterministic way to load the grammar from local checkouts and to
142
+ validate against malformed test fixtures.
143
+
144
+ Args:
145
+ path: path-like object or str pointing to the grammar model root
146
+ (the directory that contains ``manifest.json`` and ``index.json``).
147
+
148
+ Returns a ``Grammar`` instance ready for read-only queries.
149
+
150
+ Raises:
151
+ FileNotFoundError: if *path* does not exist.
152
+ LanternGrammarLoadError: if *path* exists but does not contain a
153
+ valid Lantern Grammar model structure.
154
+ """
155
+ p = Path(path)
156
+ if not p.exists():
157
+ raise FileNotFoundError(f"Grammar model directory not found: {path}")
158
+ if not p.is_dir():
159
+ raise LanternGrammarLoadError(f"Grammar model path is not a directory: {path}")
160
+ return cls._load_from_path(p)
161
+
162
+ @classmethod
163
+ def _load_from_path(cls, model_dir: Path) -> "Grammar":
164
+ """Internal loader shared by both public constructors."""
165
+ manifest_data = _load_json_file(model_dir / "manifest.json", "manifest.json")
166
+ _require_fields(manifest_data, ("model_id", "model_version"), "manifest.json")
167
+
168
+ index_data = _load_json_file(model_dir / "index.json", "index.json")
169
+ if "entries" not in index_data:
170
+ raise LanternGrammarLoadError("index.json missing required 'entries' field")
171
+
172
+ entities: dict = {}
173
+ relations: dict = {}
174
+ terms: dict = {}
175
+
176
+ for entry in index_data["entries"]:
177
+ entry_id: Optional[str] = entry.get("id")
178
+ entry_kind: Optional[str] = entry.get("kind")
179
+ locator: Optional[str] = entry.get("locator")
180
+
181
+ if not (entry_id and entry_kind and locator):
182
+ raise LanternGrammarLoadError(f"Index entry missing required fields (id/kind/locator): {entry}")
183
+
184
+ # Locators are of the form "model/objects/<Kind>/<file>.json",
185
+ # expressed relative to the repository root with "model/" as the
186
+ # base segment. We rebase them onto model_dir so they work both
187
+ # in the source tree (where _model/ is a symlink to model/) and in
188
+ # an installed distribution (where _model/ is a real copy).
189
+ relative = locator.removeprefix("model/")
190
+ obj_path = model_dir / relative
191
+ obj_data = _load_json_file(obj_path, f"object for {entry_id!r}")
192
+
193
+ if entry_kind == "Entity":
194
+ entities[entry_id] = obj_data
195
+ elif entry_kind == "Relation":
196
+ relations[entry_id] = obj_data
197
+ elif entry_kind == "Term":
198
+ terms[entry_id] = obj_data
199
+ # Unknown future kinds are silently skipped; this preserves
200
+ # forward-compatibility as the grammar evolves.
201
+
202
+ pkg_version = _get_package_version()
203
+ return cls(manifest_data, entities, relations, terms, pkg_version)
204
+
205
+ # --- Manifest and version metadata ---
206
+
207
+ def manifest(self) -> MappingProxyType:
208
+ """Return the model manifest as a read-only mapping.
209
+
210
+ Stable minimum fields: ``model_id``, ``model_version``.
211
+ All other manifest fields are surfaced verbatim.
212
+ """
213
+ return self._manifest
214
+
215
+ def package_version(self) -> str:
216
+ """Return the installed Python distribution version string.
217
+
218
+ Distinct from ``manifest()["model_version"]``, which is the semantic
219
+ grammar version. Returns ``"unknown"`` when metadata is unavailable.
220
+ """
221
+ return self._package_version
222
+
223
+ # --- Entity access ---
224
+
225
+ def get_entity(self, entity_id: str) -> Optional[MappingProxyType]:
226
+ """Return the entity with *entity_id*, or ``None`` if not found.
227
+
228
+ Covers all entity kinds: artifacts, gates, statuses, relation types,
229
+ and record classes. Use the ``prefix`` filter on ``iter_entities()``
230
+ to iterate a specific family.
231
+
232
+ Stable minimum fields: ``id``, ``kind``, ``short_name``,
233
+ ``definition``, ``status``.
234
+ """
235
+ return self._entities.get(entity_id)
236
+
237
+ def iter_entities(
238
+ self,
239
+ *,
240
+ prefix: Optional[str] = None,
241
+ status: Optional[str] = None,
242
+ ) -> Iterator[MappingProxyType]:
243
+ """Iterate over entity mappings in the loaded model.
244
+
245
+ Args:
246
+ prefix: if given, yield only entities whose ``id`` starts with
247
+ this string (e.g. ``"lg:gates/"``).
248
+ status: if given, yield only entities with this ``status`` value
249
+ (e.g. ``"Released"``).
250
+ """
251
+ for entity in self._entities.values():
252
+ if prefix is not None and not entity["id"].startswith(prefix):
253
+ continue
254
+ if status is not None and entity.get("status") != status:
255
+ continue
256
+ yield entity
257
+
258
+ # --- Relation access ---
259
+
260
+ def get_relation(self, relation_id: str) -> Optional[MappingProxyType]:
261
+ """Return the relation with *relation_id*, or ``None`` if not found.
262
+
263
+ Stable minimum fields: ``id``, ``kind``, ``short_name``,
264
+ ``definition``, ``status``, ``relation_type_id``,
265
+ ``source_entity_id``, ``target_entity_id``.
266
+ """
267
+ return self._relations.get(relation_id)
268
+
269
+ def find_relations(
270
+ self,
271
+ *,
272
+ relation_type_id: Optional[str] = None,
273
+ source_entity_id: Optional[str] = None,
274
+ target_entity_id: Optional[str] = None,
275
+ ) -> Iterator[MappingProxyType]:
276
+ """Iterate over relations matching one or more filter criteria.
277
+
278
+ At least one filter argument must be supplied.
279
+
280
+ Args:
281
+ relation_type_id: match on ``relation_type_id`` field.
282
+ source_entity_id: match on ``source_entity_id`` field.
283
+ target_entity_id: match on ``target_entity_id`` field.
284
+
285
+ Raises:
286
+ ValueError: if no filter arguments are supplied.
287
+ """
288
+ if not any([relation_type_id, source_entity_id, target_entity_id]):
289
+ raise ValueError(
290
+ "find_relations() requires at least one filter argument: "
291
+ "relation_type_id, source_entity_id, or target_entity_id"
292
+ )
293
+ for rel in self._relations.values():
294
+ if relation_type_id is not None and rel.get("relation_type_id") != relation_type_id:
295
+ continue
296
+ if source_entity_id is not None and rel.get("source_entity_id") != source_entity_id:
297
+ continue
298
+ if target_entity_id is not None and rel.get("target_entity_id") != target_entity_id:
299
+ continue
300
+ yield rel
301
+
302
+ # --- Term lookup ---
303
+
304
+ def get_term(self, term_id: str) -> Optional[MappingProxyType]:
305
+ """Return the vocabulary term with *term_id*, or ``None`` if not found.
306
+
307
+ Stable minimum fields: ``id``, ``kind``, ``short_name``,
308
+ ``definition``, ``status``.
309
+ """
310
+ return self._terms.get(term_id)
311
+
312
+ def find_terms(
313
+ self,
314
+ *,
315
+ prefix: Optional[str] = None,
316
+ short_name: Optional[str] = None,
317
+ ) -> Iterator[MappingProxyType]:
318
+ """Iterate over vocabulary term mappings.
319
+
320
+ Args:
321
+ prefix: if given, yield only terms whose ``id`` starts with this
322
+ string.
323
+ short_name: if given, yield only terms with this exact
324
+ ``short_name``.
325
+ """
326
+ for term in self._terms.values():
327
+ if prefix is not None and not term["id"].startswith(prefix):
328
+ continue
329
+ if short_name is not None and term.get("short_name") != short_name:
330
+ continue
331
+ yield term
332
+
333
+ # --- Gate-dependency queries ---
334
+
335
+ def gate_dependencies(self, gate_id: str) -> MappingProxyType:
336
+ """Return the semantic dependencies for *gate_id*.
337
+
338
+ Args:
339
+ gate_id: canonical gate entity ID, e.g. ``"lg:gates/gt_115"``.
340
+
341
+ Returns a read-only mapping with stable keys:
342
+
343
+ * ``gate_id`` — the requested gate ID.
344
+ * ``requires_input`` — tuple of target entity IDs via
345
+ ``requires_input`` relations.
346
+ * ``requires_evidence`` — tuple of target entity IDs via
347
+ ``requires_evidence`` relations.
348
+ * ``requires_status`` — tuple of target entity IDs via
349
+ ``requires_status`` relations.
350
+ * ``relation_ids`` — tuple of all supporting relation IDs.
351
+
352
+ Raises:
353
+ KeyError: if *gate_id* does not exist or is not a gate entity.
354
+ """
355
+ entity = self._entities.get(gate_id)
356
+ if entity is None or not gate_id.startswith(_GATE_PREFIX):
357
+ raise KeyError(f"Gate not found in model: {gate_id!r}. " "Gate IDs must start with 'lg:gates/'.")
358
+
359
+ requires_input: list[str] = []
360
+ requires_evidence: list[str] = []
361
+ requires_status: list[str] = []
362
+ relation_ids: list[str] = []
363
+
364
+ for rel_id in entity.get("relation_ids", []):
365
+ rel = self._relations.get(rel_id)
366
+ if rel is None:
367
+ continue
368
+ rtype = rel.get("relation_type_id")
369
+ target = rel.get("target_entity_id", "")
370
+ relation_ids.append(rel_id)
371
+ if rtype == _REQUIRES_INPUT:
372
+ requires_input.append(target)
373
+ elif rtype == _REQUIRES_EVIDENCE:
374
+ requires_evidence.append(target)
375
+ elif rtype == _REQUIRES_STATUS:
376
+ requires_status.append(target)
377
+
378
+ return MappingProxyType(
379
+ {
380
+ "gate_id": gate_id,
381
+ "requires_input": tuple(requires_input),
382
+ "requires_evidence": tuple(requires_evidence),
383
+ "requires_status": tuple(requires_status),
384
+ "relation_ids": tuple(relation_ids),
385
+ }
386
+ )
387
+
388
+ # --- Integrity validation ---
389
+
390
+ def validate_integrity(self) -> MappingProxyType:
391
+ """Validate the structural integrity of the loaded model.
392
+
393
+ Returns a read-only mapping with stable keys:
394
+
395
+ * ``ok`` — ``True`` when no errors were found.
396
+ * ``errors`` — tuple of human-readable error messages.
397
+ * ``warnings`` — tuple of non-fatal notices.
398
+
399
+ Coverage:
400
+
401
+ * manifest has required fields.
402
+ * Each relation's ``source_entity_id``, ``target_entity_id``, and
403
+ ``relation_type_id`` exist in the entity index.
404
+ * Each gate entity's ``relation_ids`` list references existing
405
+ relations.
406
+ """
407
+ errors: list[str] = []
408
+ warnings: list[str] = []
409
+
410
+ # --- Manifest ---
411
+ for field in ("model_id", "model_version"):
412
+ if field not in self._manifest:
413
+ errors.append(f"manifest missing required field: {field!r}")
414
+
415
+ # --- Relation cross-references ---
416
+ for rel_id, rel in self._relations.items():
417
+ src = rel.get("source_entity_id")
418
+ tgt = rel.get("target_entity_id")
419
+ rtype = rel.get("relation_type_id")
420
+ if src and src not in self._entities:
421
+ errors.append(f"Relation {rel_id!r}: source_entity_id {src!r} " "not found in entity index")
422
+ if tgt and tgt not in self._entities:
423
+ errors.append(f"Relation {rel_id!r}: target_entity_id {tgt!r} " "not found in entity index")
424
+ if rtype and rtype not in self._entities:
425
+ errors.append(f"Relation {rel_id!r}: relation_type_id {rtype!r} " "not found in entity index")
426
+
427
+ # --- Gate relation_ids cross-references ---
428
+ for entity_id, entity in self._entities.items():
429
+ if entity_id.startswith(_GATE_PREFIX):
430
+ for rel_id in entity.get("relation_ids", []):
431
+ if rel_id not in self._relations:
432
+ errors.append(f"Gate {entity_id!r}: relation_id {rel_id!r} " "not found in relation index")
433
+
434
+ return MappingProxyType(
435
+ {
436
+ "ok": len(errors) == 0,
437
+ "errors": tuple(errors),
438
+ "warnings": tuple(warnings),
439
+ }
440
+ )
441
+
442
+ def __repr__(self) -> str:
443
+ model_id = self._manifest.get("model_id", "?")
444
+ version = self._manifest.get("model_version", "?")
445
+ return (
446
+ f"Grammar(model_id={model_id!r}, model_version={version!r}, "
447
+ f"entities={len(self._entities)}, relations={len(self._relations)}, "
448
+ f"terms={len(self._terms)})"
449
+ )
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Private helpers
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ def _load_json_file(path: Path, label: str) -> dict:
458
+ """Load a JSON file, raising LanternGrammarLoadError on failure."""
459
+ if not path.exists():
460
+ raise LanternGrammarLoadError(f"{label} not found at {path}")
461
+ try:
462
+ return json.loads(path.read_text(encoding="utf-8"))
463
+ except json.JSONDecodeError as exc:
464
+ raise LanternGrammarLoadError(f"Failed to parse {label}: {exc}") from exc
465
+ except OSError as exc:
466
+ raise LanternGrammarLoadError(f"Failed to read {label}: {exc}") from exc
467
+
468
+
469
+ def _require_fields(data: dict, fields: tuple, source: str) -> None:
470
+ """Raise LanternGrammarLoadError if any of *fields* is absent from *data*."""
471
+ for field in fields:
472
+ if field not in data:
473
+ raise LanternGrammarLoadError(f"{source} missing required field: {field!r}")
474
+
475
+
476
+ def _get_package_version() -> str:
477
+ """Return installed package version, or 'unknown' if unavailable."""
478
+ try:
479
+ from importlib.metadata import version
480
+
481
+ return version("lantern-grammar")
482
+ except Exception:
483
+ return "unknown"