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.
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/PKG-INFO +5 -2
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/pyproject.toml +6 -2
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/__init__.py +6 -1
- omop_semantics-0.4.0/src/omop_semantics/runtime/__init__.py +102 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/instance_loader.py +7 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/output_definitions.py +469 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/projection.py +211 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/resolver.py +31 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/tui/__init__.py +5 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/tui/app.py +318 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/tui/widgets.py +109 -0
- omop_semantics-0.4.0/src/omop_semantics/runtime/viz.py +281 -0
- omop_semantics-0.4.0/src/omop_semantics/schema/configuration/core/omop_profiles.yaml +81 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/omop_semantic_registry.py +13 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/profile_groups.yaml +44 -1
- omop_semantics-0.4.0/src/omop_semantics/schema/instances/profiles.yaml +102 -0
- omop_semantics-0.2.2/src/omop_semantics/runtime/__init__.py +0 -42
- omop_semantics-0.2.2/src/omop_semantics/schema/configuration/core/omop_profiles.yaml +0 -43
- omop_semantics-0.2.2/src/omop_semantics/schema/instances/profiles.yaml +0 -39
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/README.md +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/base.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/default_valuesets.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/renderers.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/unknown_handlers.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/runtime/value_sets.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/__init__.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/codegen.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_base.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_named_sets.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_templates.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/core/omop_unknowns.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_episodes.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_modifiers.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/profiles/omop_staging.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/configuration/registry/omop_semantic_registry.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/dump.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/__init__.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/omop_named_sets.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/generated_models/template_set.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/demographic.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/enumerators.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/genomic.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/provider_specialty.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/schema/instances/valuesets.yaml +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/unknowns.py +0 -0
- {omop_semantics-0.2.2 → omop_semantics-0.4.0}/src/omop_semantics/utils/__init__.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|