omop-semantics 0.2.2__tar.gz → 0.4.0__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 (47) hide show
  1. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/PKG-INFO +5 -2
  2. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/pyproject.toml +6 -2
  3. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/__init__.py +6 -1
  4. omop_semantics-0.4.0/src/omop_semantics/runtime/__init__.py +102 -0
  5. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/instance_loader.py +7 -0
  6. omop_semantics-0.4.0/src/omop_semantics/runtime/output_definitions.py +469 -0
  7. omop_semantics-0.4.0/src/omop_semantics/runtime/projection.py +211 -0
  8. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/resolver.py +31 -0
  9. omop_semantics-0.4.0/src/omop_semantics/runtime/tui/__init__.py +5 -0
  10. omop_semantics-0.4.0/src/omop_semantics/runtime/tui/app.py +318 -0
  11. omop_semantics-0.4.0/src/omop_semantics/runtime/tui/widgets.py +109 -0
  12. omop_semantics-0.4.0/src/omop_semantics/runtime/viz.py +281 -0
  13. omop_semantics-0.4.0/src/omop_semantics/schema/configuration/core/omop_profiles.yaml +81 -0
  14. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/omop_semantic_registry.py +13 -0
  15. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/profile_groups.yaml +44 -1
  16. omop_semantics-0.4.0/src/omop_semantics/schema/instances/profiles.yaml +102 -0
  17. omop_semantics-0.2.2/src/omop_semantics/runtime/__init__.py +0 -42
  18. omop_semantics-0.2.2/src/omop_semantics/schema/configuration/core/omop_profiles.yaml +0 -43
  19. omop_semantics-0.2.2/src/omop_semantics/schema/instances/profiles.yaml +0 -39
  20. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/README.md +0 -0
  21. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/base.py +0 -0
  22. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/default_valuesets.py +0 -0
  23. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/renderers.py +0 -0
  24. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/unknown_handlers.py +0 -0
  25. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/value_sets.py +0 -0
  26. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/__init__.py +0 -0
  27. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/codegen.py +0 -0
  28. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_base.yaml +0 -0
  29. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_named_sets.yaml +0 -0
  30. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_templates.yaml +0 -0
  31. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_unknowns.yaml +0 -0
  32. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_episodes.yaml +0 -0
  33. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_modifiers.yaml +0 -0
  34. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_staging.yaml +0 -0
  35. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/registry/omop_semantic_registry.yaml +0 -0
  36. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/dump.py +0 -0
  37. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/__init__.py +0 -0
  38. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/omop_named_sets.py +0 -0
  39. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/template_set.py +0 -0
  40. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/demographic.yaml +0 -0
  41. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/enumerators.yaml +0 -0
  42. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/genomic.yaml +0 -0
  43. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/provider_specialty.yaml +0 -0
  44. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/valuesets.yaml +0 -0
  45. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/unknowns.py +0 -0
  46. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/utils/__init__.py +0 -0
  47. {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/utils/paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: omop-semantics
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Define, validate, and use schema-backed semantic conventions for OMOP CDM
5
5
  Author: gkennos
6
6
  Author-email: gkennos <georgina.kennedy@unsw.edu.au>
@@ -17,19 +17,22 @@ Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
17
17
  Requires-Dist: mypy>=1.8 ; extra == 'dev'
18
18
  Requires-Dist: ruff>=0.4 ; extra == 'dev'
19
19
  Requires-Dist: rich>=13.0 ; extra == 'dev'
20
- Requires-Dist: tornado>=6.5.5 ; extra == 'dev'
20
+ Requires-Dist: textual>=0.60 ; extra == 'dev'
21
+ Requires-Dist: tornado>=6.5.7 ; extra == 'dev'
21
22
  Requires-Dist: pygments>=2.20.0 ; extra == 'dev'
22
23
  Requires-Dist: mkdocs-material>=9.7.6 ; extra == 'docs'
23
24
  Requires-Dist: mkdocstrings-python>=2.0.1 ; extra == 'docs'
24
25
  Requires-Dist: mkdocs>=1.6.1 ; extra == 'docs'
25
26
  Requires-Dist: mkdocs-mermaid2-plugin>=1.2.3 ; extra == 'docs'
26
27
  Requires-Dist: pygments>=2.20.0 ; extra == 'docs'
28
+ Requires-Dist: textual>=0.60 ; extra == 'tui'
27
29
  Requires-Python: >=3.12
28
30
  Project-URL: Homepage, https://australiancancerdatanetwork.github.io/omop-semantics/
29
31
  Project-URL: Repository, https://github.com/AustralianCancerDataNetwork/omop-semantics
30
32
  Project-URL: Issues, https://github.com/AustralianCancerDataNetwork/omop-semantics/issues
31
33
  Provides-Extra: dev
32
34
  Provides-Extra: docs
35
+ Provides-Extra: tui
33
36
  Description-Content-Type: text/markdown
34
37
 
35
38
  # omop-semantics
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omop-semantics"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "Define, validate, and use schema-backed semantic conventions for OMOP CDM"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -28,9 +28,13 @@ dev = [
28
28
  "mypy>=1.8",
29
29
  "ruff>=0.4",
30
30
  "rich>=13.0",
31
- "tornado>=6.5.5",
31
+ "textual>=0.60",
32
+ "tornado>=6.5.7",
32
33
  "pygments>=2.20.0"
33
34
  ]
35
+ tui = [
36
+ "textual>=0.60",
37
+ ]
34
38
  docs = [
35
39
  "mkdocs-material>=9.7.6",
36
40
  "mkdocstrings-python>=2.0.1",
@@ -33,6 +33,7 @@ def main(argv: "list[str] | None" = None) -> int:
33
33
 
34
34
  Subcommands:
35
35
  gen-models (re)generate the committed pydantic models from the LinkML schema.
36
+ tui launch the interactive output-definition explorer.
36
37
  """
37
38
  import sys
38
39
 
@@ -41,6 +42,10 @@ def main(argv: "list[str] | None" = None) -> int:
41
42
  from omop_semantics.schema.codegen import cli as gen_models_cli
42
43
 
43
44
  return gen_models_cli(argv[1:])
45
+ if argv and argv[0] == "tui":
46
+ from omop_semantics.runtime.tui.app import cli as tui_cli
44
47
 
45
- print("usage: omop-semantics gen-models [--check] [--out DIR]", file=sys.stderr)
48
+ return tui_cli(argv[1:])
49
+
50
+ print("usage: omop-semantics {gen-models|tui} ...", file=sys.stderr)
46
51
  return 2
@@ -0,0 +1,102 @@
1
+ """
2
+ Public runtime exports for `omop_semantics`.
3
+
4
+ Use this package when you need compiled templates, profile inspection, or
5
+ low-level YAML loading helpers. For the default named-id surface, import
6
+ ``runtime`` from ``omop_semantics.runtime.default_valuesets``.
7
+ """
8
+
9
+ from .instance_loader import load_registry_fragment, merge_registry_fragments, load_symbol_module
10
+ from .renderers import render_registry_fragment, render_profile_groups, Html
11
+ from .resolver import (
12
+ OmopSemanticResolver,
13
+ OmopTemplateRuntime,
14
+ OmopRegistryRuntime,
15
+ OmopCdmProfile,
16
+ RegistryFragment,
17
+ OmopTemplate,
18
+ RuntimeTemplate,
19
+ OmopSemanticEngine,
20
+ OmopSemanticObject,
21
+ SemanticProfileRuntime
22
+ )
23
+ from .projection import (
24
+ ProjectionProfileRuntime,
25
+ RuntimeProjectionProfile,
26
+ ProjectedOutputRow,
27
+ ProjectedOutputLink,
28
+ ProjectedOutputBundle,
29
+ SuppressedRow,
30
+ )
31
+ from .output_definitions import (
32
+ ContextFieldRef,
33
+ OutputRowProjection,
34
+ OutputLinkRule,
35
+ OutputDefinition,
36
+ CompiledOutputDefinition,
37
+ OutputDefinitionRuntime,
38
+ DerivationRule,
39
+ NO_DEFAULT,
40
+ SpecialValuePolicy,
41
+ SUPPRESSION_MODES,
42
+ )
43
+ from .viz import (
44
+ DefinitionOutline,
45
+ MermaidIdAllocator,
46
+ RowOutline,
47
+ bundle_to_html,
48
+ bundle_to_mermaid,
49
+ catalogue_to_html,
50
+ catalogue_to_mermaid,
51
+ derive_status,
52
+ describe_definition,
53
+ escape_html_text,
54
+ escape_mermaid_label,
55
+ )
56
+ from typing import TYPE_CHECKING
57
+
58
+
59
+ __all__ = [
60
+ "OmopSemanticResolver",
61
+ "OmopTemplateRuntime",
62
+ "OmopRegistryRuntime",
63
+ "OmopCdmProfile",
64
+ "RegistryFragment",
65
+ "OmopTemplate",
66
+ "RuntimeTemplate",
67
+ "OmopSemanticEngine",
68
+ "OmopSemanticObject",
69
+ "load_registry_fragment",
70
+ "merge_registry_fragments",
71
+ "load_symbol_module",
72
+ "render_registry_fragment",
73
+ "render_profile_groups",
74
+ "SemanticProfileRuntime",
75
+ "ProjectionProfileRuntime",
76
+ "RuntimeProjectionProfile",
77
+ "ProjectedOutputRow",
78
+ "ProjectedOutputLink",
79
+ "ProjectedOutputBundle",
80
+ "SuppressedRow",
81
+ "ContextFieldRef",
82
+ "OutputRowProjection",
83
+ "OutputLinkRule",
84
+ "OutputDefinition",
85
+ "CompiledOutputDefinition",
86
+ "OutputDefinitionRuntime",
87
+ "DerivationRule",
88
+ "NO_DEFAULT",
89
+ "SpecialValuePolicy",
90
+ "SUPPRESSION_MODES",
91
+ "DefinitionOutline",
92
+ "MermaidIdAllocator",
93
+ "RowOutline",
94
+ "bundle_to_html",
95
+ "bundle_to_mermaid",
96
+ "catalogue_to_html",
97
+ "catalogue_to_mermaid",
98
+ "derive_status",
99
+ "describe_definition",
100
+ "escape_html_text",
101
+ "escape_mermaid_label",
102
+ ]
@@ -45,6 +45,13 @@ def interpolate_profiles(group: dict, profiles: dict[str, OmopCdmProfile]) -> No
45
45
  "cdm_table": profile.cdm_table,
46
46
  "concept_slot": profile.concept_slot,
47
47
  "value_slot": profile.value_slot,
48
+ "unit_slot": profile.unit_slot,
49
+ "operator_slot": profile.operator_slot,
50
+ "modifier_slot": profile.modifier_slot,
51
+ "extra_concept_slots": profile.extra_concept_slots,
52
+ "numeric_slots": profile.numeric_slots,
53
+ "string_slots": profile.string_slots,
54
+ "reference_slots": profile.reference_slots,
48
55
  }
49
56
 
50
57
  member.pop("concept_slot", None)
@@ -0,0 +1,469 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Iterable, Mapping, Sequence
5
+
6
+ from .projection import (
7
+ ProjectedOutputBundle,
8
+ ProjectedOutputLink,
9
+ ProjectedOutputRow,
10
+ ProjectionProfileRuntime,
11
+ RuntimeProjectionProfile,
12
+ SuppressedRow,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ContextFieldRef:
18
+ """
19
+ Reference to a value in the projection context.
20
+
21
+ The path is dot-delimited, for example:
22
+
23
+ - `grounded.concept_id`
24
+ - `source.numeric_value`
25
+ - `modifiers.intent_concept_id`
26
+ """
27
+
28
+ path: str
29
+
30
+
31
+ BindingValue = Any | ContextFieldRef
32
+
33
+
34
+ class _NoDefault:
35
+ def __repr__(self) -> str:
36
+ return "NO_DEFAULT"
37
+
38
+
39
+ NO_DEFAULT: Any = _NoDefault()
40
+ """Sentinel distinguishing "no default configured" from a configured default of `None`."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class DerivationRule:
45
+ """
46
+ Resolve one row's slot value from a source field other than the one that
47
+ grounded the row's entity concept, via a source-code lookup — with an
48
+ explicit escape hatch for codes that should suppress the row entirely.
49
+
50
+ This is the n:1 fan-in counterpart to `field_bindings` (which only ever
51
+ read from the same context a row's own entity concept was grounded from).
52
+ The canonical case is a diagnosis paired with a separately-collected
53
+ role/status field (e.g. Primary/Contributing/Non-contributing): the role
54
+ field is never grounded on its own, and its raw code either resolves
55
+ `condition_status_concept_id` via `code_map` or, for "Non-contributing",
56
+ drops the record entirely via `suppress_codes`.
57
+ """
58
+
59
+ target_row: str
60
+ target_slot: str
61
+ source_field: ContextFieldRef
62
+ code_map: Mapping[str, Any] = field(default_factory=dict)
63
+ suppress_codes: frozenset[str] = frozenset()
64
+ default: Any = NO_DEFAULT
65
+
66
+
67
+ SUPPRESSION_MODES = frozenset({"drop", "fail", "keep_as_value", "keep_as_modifier"})
68
+ """Recognized `SpecialValuePolicy.suppression_mode` values.
69
+
70
+ Only `drop` and `fail` have runtime behavior today — both are the degenerate,
71
+ single-field case validated against real data (a "meets criteria for X" Yes/No
72
+ item, where the negative answer carries no positive clinical content of its
73
+ own). `keep_as_value` and `keep_as_modifier` are named here per the schema
74
+ proposal so definitions can be authored against a stable enum, but they raise
75
+ `NotImplementedError` if actually triggered — there is no validated use case
76
+ yet for what they should bind.
77
+ """
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SpecialValuePolicy:
82
+ """
83
+ Row-level suppression (or other handling) driven by the row's *own*
84
+ source value — the counterpart to `DerivationRule`, which reads a
85
+ different source field. Attach to `OutputRowProjection.special_value_policy`.
86
+
87
+ The canonical case is a Yes/No field phrased "meets criteria for X": Yes
88
+ grounds the row normally, No means nothing should be written for it. There
89
+ is no sibling field to consult, so no `DerivationRule` is involved — the
90
+ same value used to ground the row is checked against
91
+ `allowed_special_values`.
92
+ """
93
+
94
+ source_field: ContextFieldRef
95
+ allowed_special_values: frozenset[str] = frozenset()
96
+ suppression_mode: str = "drop"
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class OutputRowProjection:
101
+ """
102
+ Declarative row projection definition.
103
+ """
104
+
105
+ row_id: str
106
+ profile_name: str
107
+ field_bindings: Mapping[str, BindingValue] = field(default_factory=dict)
108
+ defaults: Mapping[str, Any] = field(default_factory=dict)
109
+ required: bool = True
110
+ special_value_policy: SpecialValuePolicy | None = None
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class OutputLinkRule:
115
+ """
116
+ Declarative relationship between projected rows.
117
+ """
118
+
119
+ source_row: str
120
+ target_row: str
121
+ relationship_type: str
122
+ source_field: str | None = None
123
+ target_field: str | None = None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class OutputDefinition:
128
+ """
129
+ Minimal programmatic output definition.
130
+
131
+ This is intentionally additive and runtime-only for the first execution
132
+ slice. It provides enough structure to compile and deterministically
133
+ project rows before a dedicated YAML/schema authoring surface is added.
134
+ """
135
+
136
+ name: str
137
+ role: str
138
+ template_names: tuple[str, ...] = ()
139
+ row_projections: tuple[OutputRowProjection, ...] = ()
140
+ link_rules: tuple[OutputLinkRule, ...] = ()
141
+ derivation_rules: tuple[DerivationRule, ...] = ()
142
+ notes: tuple[str, ...] = ()
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class CompiledRowProjection:
147
+ row_id: str
148
+ profile: RuntimeProjectionProfile
149
+ field_bindings: Mapping[str, BindingValue]
150
+ defaults: Mapping[str, Any]
151
+ required: bool = True
152
+ special_value_policy: SpecialValuePolicy | None = None
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class CompiledOutputDefinition:
157
+ name: str
158
+ role: str
159
+ template_names: tuple[str, ...]
160
+ row_projections: tuple[CompiledRowProjection, ...]
161
+ link_rules: tuple[OutputLinkRule, ...]
162
+ derivation_rules: tuple[DerivationRule, ...]
163
+ notes: tuple[str, ...]
164
+
165
+
166
+ class OutputDefinitionRuntime:
167
+ """
168
+ Compiler and executor for programmatic output definitions.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ definitions: Iterable[OutputDefinition],
174
+ projection_profiles: ProjectionProfileRuntime,
175
+ ) -> None:
176
+ self.projection_profiles = projection_profiles
177
+ self._compiled_by_name: dict[str, CompiledOutputDefinition] = {}
178
+ self._compiled_by_template: dict[str, list[CompiledOutputDefinition]] = {}
179
+ for definition in definitions:
180
+ compiled = self._compile_definition(definition)
181
+ if compiled.name in self._compiled_by_name:
182
+ raise ValueError(f"Duplicate output definition name: '{compiled.name}'")
183
+ self._compiled_by_name[compiled.name] = compiled
184
+ for template_name in compiled.template_names:
185
+ self._compiled_by_template.setdefault(template_name, []).append(compiled)
186
+
187
+ def names(self) -> tuple[str, ...]:
188
+ return tuple(sorted(self._compiled_by_name))
189
+
190
+ def get(self, name: str) -> CompiledOutputDefinition:
191
+ return self._compiled_by_name[name]
192
+
193
+ def for_template(self, template_name: str) -> list[CompiledOutputDefinition]:
194
+ return list(self._compiled_by_template.get(template_name, ()))
195
+
196
+ def project(self, definition_name: str, context: Mapping[str, Any]) -> ProjectedOutputBundle:
197
+ compiled = self.get(definition_name)
198
+ projected_rows: list[ProjectedOutputRow] = []
199
+ row_ids: set[str] = set()
200
+ unresolved_fields: list[dict[str, Any]] = []
201
+ suppressed_rows: list[SuppressedRow] = []
202
+ audit_notes = list(compiled.notes)
203
+
204
+ for row_projection in compiled.row_projections:
205
+ resolved_fields = dict(row_projection.defaults)
206
+ missing_fields: list[str] = []
207
+ row_suppressed = False
208
+
209
+ for slot, binding in row_projection.field_bindings.items():
210
+ found, value = _resolve_binding(binding, context)
211
+ if not found:
212
+ missing_fields.append(slot)
213
+ continue
214
+ resolved_fields[slot] = value
215
+
216
+ for rule in compiled.derivation_rules:
217
+ if rule.target_row != row_projection.row_id:
218
+ continue
219
+
220
+ found, raw_value = _resolve_binding(rule.source_field, context)
221
+ if not found:
222
+ missing_fields.append(rule.target_slot)
223
+ continue
224
+
225
+ code = str(raw_value)
226
+ if code in rule.suppress_codes:
227
+ suppressed_rows.append(
228
+ SuppressedRow(
229
+ row_id=row_projection.row_id,
230
+ reason=(
231
+ f"derivation rule for '{rule.target_slot}' matched a "
232
+ f"suppress code on '{rule.source_field.path}'"
233
+ ),
234
+ source_field=rule.source_field.path,
235
+ source_code=code,
236
+ )
237
+ )
238
+ row_suppressed = True
239
+ break
240
+
241
+ if code in rule.code_map:
242
+ resolved_fields[rule.target_slot] = rule.code_map[code]
243
+ elif rule.default is not NO_DEFAULT:
244
+ resolved_fields[rule.target_slot] = rule.default
245
+ else:
246
+ missing_fields.append(rule.target_slot)
247
+
248
+ policy = row_projection.special_value_policy
249
+ if policy is not None:
250
+ found, raw_value = _resolve_binding(policy.source_field, context)
251
+ if found:
252
+ code = str(raw_value)
253
+ if code in policy.allowed_special_values:
254
+ if policy.suppression_mode == "drop":
255
+ suppressed_rows.append(
256
+ SuppressedRow(
257
+ row_id=row_projection.row_id,
258
+ reason=(
259
+ "special_value_policy matched "
260
+ f"'{policy.source_field.path}' == '{code}'"
261
+ ),
262
+ source_field=policy.source_field.path,
263
+ source_code=code,
264
+ )
265
+ )
266
+ row_suppressed = True
267
+ elif policy.suppression_mode == "fail":
268
+ raise ValueError(
269
+ f"row '{row_projection.row_id}' hit special value '{code}' "
270
+ f"on '{policy.source_field.path}', configured to fail"
271
+ )
272
+ else:
273
+ raise NotImplementedError(
274
+ f"suppression_mode '{policy.suppression_mode}' has no "
275
+ "runtime behavior yet"
276
+ )
277
+
278
+ if row_suppressed:
279
+ continue
280
+
281
+ if missing_fields:
282
+ unresolved_fields.append(
283
+ {
284
+ "row_id": row_projection.row_id,
285
+ "missing_fields": missing_fields,
286
+ "required": row_projection.required,
287
+ }
288
+ )
289
+ if row_projection.required:
290
+ continue
291
+
292
+ if row_projection.profile.concept_slot not in resolved_fields:
293
+ unresolved_fields.append(
294
+ {
295
+ "row_id": row_projection.row_id,
296
+ "missing_fields": [row_projection.profile.concept_slot],
297
+ "required": row_projection.required,
298
+ }
299
+ )
300
+ if row_projection.required:
301
+ continue
302
+
303
+ row = ProjectedOutputRow(
304
+ row_id=row_projection.row_id,
305
+ profile=row_projection.profile,
306
+ fields=resolved_fields,
307
+ )
308
+ projected_rows.append(row)
309
+ row_ids.add(row.row_id)
310
+
311
+ links: list[ProjectedOutputLink] = []
312
+ for link_rule in compiled.link_rules:
313
+ if link_rule.source_row in row_ids and link_rule.target_row in row_ids:
314
+ links.append(
315
+ ProjectedOutputLink(
316
+ source_row=link_rule.source_row,
317
+ target_row=link_rule.target_row,
318
+ relationship_type=link_rule.relationship_type,
319
+ source_field=link_rule.source_field,
320
+ target_field=link_rule.target_field,
321
+ )
322
+ )
323
+ else:
324
+ unresolved_fields.append(
325
+ {
326
+ "link": {
327
+ "source_row": link_rule.source_row,
328
+ "target_row": link_rule.target_row,
329
+ "relationship_type": link_rule.relationship_type,
330
+ },
331
+ "missing_rows": [
332
+ row_id
333
+ for row_id in (link_rule.source_row, link_rule.target_row)
334
+ if row_id not in row_ids
335
+ ],
336
+ }
337
+ )
338
+
339
+ return ProjectedOutputBundle(
340
+ definition_name=compiled.name,
341
+ role=compiled.role,
342
+ rows=projected_rows,
343
+ links=links,
344
+ unresolved_fields=unresolved_fields,
345
+ suppressed_rows=suppressed_rows,
346
+ audit_notes=audit_notes,
347
+ )
348
+
349
+ def project_for_template(
350
+ self,
351
+ template_name: str,
352
+ context: Mapping[str, Any],
353
+ *,
354
+ definition_name: str | None = None,
355
+ ) -> ProjectedOutputBundle:
356
+ matches = self.for_template(template_name)
357
+ if definition_name is not None:
358
+ matches = [item for item in matches if item.name == definition_name]
359
+ if not matches:
360
+ raise KeyError(f"No output definition found for template '{template_name}'")
361
+ if len(matches) > 1:
362
+ names = ", ".join(sorted(item.name for item in matches))
363
+ raise ValueError(
364
+ f"Multiple output definitions match template '{template_name}': {names}"
365
+ )
366
+ return self.project(matches[0].name, context)
367
+
368
+ def _compile_definition(self, definition: OutputDefinition) -> CompiledOutputDefinition:
369
+ row_ids: set[str] = set()
370
+ compiled_rows: list[CompiledRowProjection] = []
371
+ profile_by_row: dict[str, RuntimeProjectionProfile] = {}
372
+ errors: list[str] = []
373
+
374
+ for row_projection in definition.row_projections:
375
+ if row_projection.row_id in row_ids:
376
+ errors.append(f"duplicate row_id '{row_projection.row_id}'")
377
+ continue
378
+ row_ids.add(row_projection.row_id)
379
+
380
+ profile = self.projection_profiles.get(row_projection.profile_name)
381
+ declared_slots = set(row_projection.defaults) | set(row_projection.field_bindings)
382
+ invalid_slots = sorted(slot for slot in declared_slots if not profile.allows_slot(slot))
383
+ if invalid_slots:
384
+ errors.append(
385
+ f"row '{row_projection.row_id}' uses slots not present in profile "
386
+ f"'{profile.name}': {invalid_slots}"
387
+ )
388
+ continue
389
+
390
+ policy = row_projection.special_value_policy
391
+ if policy is not None and policy.suppression_mode not in SUPPRESSION_MODES:
392
+ errors.append(
393
+ f"row '{row_projection.row_id}' has special_value_policy with "
394
+ f"unrecognized suppression_mode '{policy.suppression_mode}'; "
395
+ f"expected one of {sorted(SUPPRESSION_MODES)}"
396
+ )
397
+ continue
398
+
399
+ profile_by_row[row_projection.row_id] = profile
400
+ compiled_rows.append(
401
+ CompiledRowProjection(
402
+ row_id=row_projection.row_id,
403
+ profile=profile,
404
+ field_bindings=dict(row_projection.field_bindings),
405
+ defaults=dict(row_projection.defaults),
406
+ required=row_projection.required,
407
+ special_value_policy=policy,
408
+ )
409
+ )
410
+
411
+ for link_rule in definition.link_rules:
412
+ if link_rule.source_row not in row_ids:
413
+ errors.append(
414
+ f"link rule references unknown source_row '{link_rule.source_row}'"
415
+ )
416
+ if link_rule.target_row not in row_ids:
417
+ errors.append(
418
+ f"link rule references unknown target_row '{link_rule.target_row}'"
419
+ )
420
+
421
+ for derivation_rule in definition.derivation_rules:
422
+ if derivation_rule.target_row not in row_ids:
423
+ errors.append(
424
+ "derivation rule references unknown target_row "
425
+ f"'{derivation_rule.target_row}'"
426
+ )
427
+ continue
428
+ profile = profile_by_row.get(derivation_rule.target_row)
429
+ if profile is not None and not profile.allows_slot(derivation_rule.target_slot):
430
+ errors.append(
431
+ f"derivation rule for row '{derivation_rule.target_row}' targets slot "
432
+ f"'{derivation_rule.target_slot}' not present in profile '{profile.name}'"
433
+ )
434
+
435
+ if errors:
436
+ raise ValueError(
437
+ f"Output definition '{definition.name}' failed validation:\n"
438
+ + "\n".join(f" - {item}" for item in errors)
439
+ )
440
+
441
+ return CompiledOutputDefinition(
442
+ name=definition.name,
443
+ role=definition.role,
444
+ template_names=tuple(definition.template_names),
445
+ row_projections=tuple(compiled_rows),
446
+ link_rules=tuple(definition.link_rules),
447
+ derivation_rules=tuple(definition.derivation_rules),
448
+ notes=tuple(definition.notes),
449
+ )
450
+
451
+
452
+ def _resolve_binding(binding: BindingValue, context: Mapping[str, Any]) -> tuple[bool, Any]:
453
+ if isinstance(binding, ContextFieldRef):
454
+ return _resolve_context_path(context, binding.path)
455
+ return True, binding
456
+
457
+
458
+ def _resolve_context_path(context: Mapping[str, Any], path: str) -> tuple[bool, Any]:
459
+ current: Any = context
460
+ for part in path.split("."):
461
+ if isinstance(current, Mapping):
462
+ if part not in current:
463
+ return False, None
464
+ current = current[part]
465
+ continue
466
+ if not hasattr(current, part):
467
+ return False, None
468
+ current = getattr(current, part)
469
+ return True, current