File size: 19,091 Bytes
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e163a2c
 
eea689d
 
 
 
 
 
 
 
 
 
 
e163a2c
 
 
 
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
"""Compile the target data dictionary into a canonical concept list (#91, the A2 pass).

The A2 pass reads the target dictionary and emits one canonical concept per variable:
its category, whether it is asserted or derived, its cardinality, and, for a derived
concept, the input primitives and the named rule that produce it. Every concept is cited
to the registry, and a concept that resolves to no citation fails the pass.

This list is the column spec that field-spec generation (#96) and export (#101) consume,
and that the registry browser (#94) renders.

The registry is the source of truth for the concept list, not `src/endopath/fields.py`:

- `data/taxonomy/dependency_graph.json` supplies the structural part of every concept:
  its id, its category, whether it is asserted or derived, and, for a derived concept, the
  input concepts it is computed from (the `derived-from` and `feeds-stage` edges). That file
  is itself a validated transcription of `docs/taxonomy.md` sections 1 and 2.
- `_ENRICHMENT` below supplies the three facts the graph does not carry: the cardinality
  (from each dimension's closure tag in `docs/taxonomy.md` section 1), the named rule (from
  the derived-values table in section 2 and the stage algorithm in section 4), and the
  registry citation. It is hand-transcribed the same way the dependency graph is, and the
  compiler validates that it covers exactly the graph's nodes, so a node added to the graph
  fails the pass until its cardinality, rule, and citation are supplied here.

The two invariants this pass serves:

1. Numbers stay numbers. The raw millimetre quantities (`invasion_depth`,
   `myometrial_thickness`, and their cervical counterparts) are asserted concepts, and the
   percentage and the category are separate derived concepts computed from them by the
   `ratio_percent` formula and a threshold. A record that stored only the category could not
   answer the other convention's question.
2. No value without its evidence. `cited_to` resolves against the registry: either an
   `extraction_id` in `data/taxonomy/extractions.csv` (a checksummed, page-anchored, verbatim
   span, #29), or a section of `docs/taxonomy.md` for a concept the registry currently
   evidences in prose rather than in a value-level extraction. A `cited_to` that resolves to
   neither raises `CompilationError`.

`rule_kind` is closed at the same set #35 closes its derivations on: `formula`, `threshold`,
and `membership`, plus `algorithm` for FIGO stage, which `docs/taxonomy.md` section 2 and #35
both treat as an edition-specific algorithm rather than one of the three simple kinds. `rule`
carries a named formula where the registry names one (`ratio_percent`, bound once and used for
both the myometrial and the cervical ratio; `derive_stage` for the stage algorithm) and
otherwise repeats the `rule_kind`. No cell holds a free-text formula.

`category` uses the registry's own category ids (`histotype`, `myo`, `lvsi`, `nodes`,
`molecular`, `stage`), the ones `data/taxonomy/dependency_graph.json` and the registry browser
already key on, so the compiled list joins to them without a translation table.
"""

from __future__ import annotations

import csv
import json
import re
from enum import Enum
from pathlib import Path
from typing import Optional

from pydantic import BaseModel, ConfigDict, model_validator

REPO_ROOT = Path(__file__).resolve().parents[2]
TAXONOMY_DIR = REPO_ROOT / "data" / "taxonomy"
DEPENDENCY_GRAPH_PATH = TAXONOMY_DIR / "dependency_graph.json"
EXTRACTIONS_PATH = TAXONOMY_DIR / "extractions.csv"
TAXONOMY_DOC_PATH = REPO_ROOT / "docs" / "taxonomy.md"

# A citation of this form points at a heading in docs/taxonomy.md, e.g.
# "docs/taxonomy.md#1.3" is section 1.3. Used for concepts the registry currently
# evidences in prose rather than in a value-level extraction row.
_DOC_CITATION_RE = re.compile(r"^docs/taxonomy\.md#(?P<section>\d+(?:\.\d+)*)$")
# Heading numbers in docs/taxonomy.md: "## 2. Derived values" -> "2",
# "### 1.3 Myometrial invasion" -> "1.3".
_DOC_HEADING_RE = re.compile(r"^#{2,4}\s+(?P<section>\d+(?:\.\d+)*)\.?\s")


class CompilationError(ValueError):
    """The dictionary did not compile: an uncited concept, an unknown input, a
    rule outside the closed vocabulary, or a drift between the enrichment table
    and the dependency graph."""


class AssertionType(str, Enum):
    """A value a reviewer reads from the report (`asserted`), or one the pass
    computes from asserted values by a named rule (`derived`)."""

    ASSERTED = "asserted"
    DERIVED = "derived"


class Cardinality(str, Enum):
    """How many values a single case may take for this concept, from the
    closure tag in docs/taxonomy.md section 1. A quantity that is either stated
    once or absent is `zero_or_one`; a closed value set with a residual is
    `exactly_one`."""

    EXACTLY_ONE = "exactly_one"
    ZERO_OR_ONE = "zero_or_one"
    ZERO_OR_MORE = "zero_or_more"


class RuleKind(str, Enum):
    """The closed set of derivation kinds (#35). `algorithm` is the FIGO-stage
    exception docs/taxonomy.md section 2 names: an edition-specific algorithm,
    not one of the three simple kinds."""

    FORMULA = "formula"
    THRESHOLD = "threshold"
    MEMBERSHIP = "membership"
    ALGORITHM = "algorithm"


# The named rules the registry gives a stable identifier beyond the bare rule_kind.
# `ratio_percent` is registered once and bound twice (docs/taxonomy.md section 2, #35);
# `derive_stage` is the stage algorithm (docs/taxonomy.md section 4). Every other derived
# value repeats its rule_kind as its rule, so no cell holds a free-text formula.
_NAMED_FORMULAS: frozenset[str] = frozenset({"ratio_percent"})
_NAMED_ALGORITHMS: frozenset[str] = frozenset({"derive_stage"})


class DerivedRule(BaseModel):
    """The derivation of a derived concept: the input concepts and the named rule."""

    model_config = ConfigDict(frozen=True)

    inputs: tuple[str, ...]
    rule: str
    rule_kind: RuleKind

    @model_validator(mode="after")
    def _rule_is_in_the_closed_vocabulary(self) -> "DerivedRule":
        if not self.inputs:
            raise CompilationError("a derived concept must name at least one input")
        kind = self.rule_kind
        if kind is RuleKind.FORMULA and self.rule not in _NAMED_FORMULAS:
            raise CompilationError(
                f"formula rule {self.rule!r} is not a named formula in {sorted(_NAMED_FORMULAS)}"
            )
        if kind is RuleKind.ALGORITHM and self.rule not in _NAMED_ALGORITHMS:
            raise CompilationError(
                f"algorithm rule {self.rule!r} is not a named algorithm in {sorted(_NAMED_ALGORITHMS)}"
            )
        if kind in (RuleKind.THRESHOLD, RuleKind.MEMBERSHIP) and self.rule != kind.value:
            raise CompilationError(
                f"{kind.value} rule must repeat its rule_kind (the registry names no formula "
                f"for it), got rule={self.rule!r}"
            )
        return self


class CompiledConcept(BaseModel):
    """One canonical concept: the #91 contract row."""

    model_config = ConfigDict(frozen=True)

    concept_id: str
    category: str
    assertion_type: AssertionType
    cardinality: Cardinality
    derived: Optional[DerivedRule] = None
    cited_to: str

    @model_validator(mode="after")
    def _derived_block_matches_assertion_type(self) -> "CompiledConcept":
        if self.assertion_type is AssertionType.DERIVED and self.derived is None:
            raise CompilationError(f"{self.concept_id} is derived but names no rule or inputs")
        if self.assertion_type is AssertionType.ASSERTED and self.derived is not None:
            raise CompilationError(f"{self.concept_id} is asserted but carries a derived block")
        return self


# id -> the three facts dependency_graph.json does not carry: cardinality (docs/taxonomy.md
# section 1 closure tags), the named rule for a derived concept (section 2 derived-values
# table and section 4 stage algorithm), and the registry citation. An asserted concept omits
# `rule`/`rule_kind`; a derived concept carries both. The compiler validates this table covers
# exactly the graph's nodes.
#
# A citation is an extraction_id (data/taxonomy/extractions.csv) where the registry has a
# value-level or element-level span, and a docs/taxonomy.md section where it currently has only
# prose. The prose-only concepts are the raw myometrial and cervical quantities, their ratios
# and category, the molecular assays, and the ProMisE and TCGA classes: #29 has not yet cut
# element-level extraction rows for them, and this pass picks up the extraction automatically
# once one lands.
_ENRICHMENT: dict[str, dict] = {
    # --- Histotype ---------------------------------------------------------------------
    "histologic_type": {"cardinality": "exactly_one", "cited_to": "ex_cap_histotype_element"},
    "figo_grade": {"cardinality": "exactly_one", "cited_to": "ex_cap_grade_element"},
    "grade_binary": {
        "cardinality": "exactly_one",
        "rule": "membership",
        "rule_kind": "membership",
        "cited_to": "docs/taxonomy.md#2",
    },
    "histotype_aggressiveness": {
        "cardinality": "exactly_one",
        "rule": "membership",
        "rule_kind": "membership",
        "cited_to": "ex_figo2023_aggressive_types",
    },
    # --- Myometrial and cervical invasion ----------------------------------------------
    # Numbers stay numbers: the two millimetre quantities are asserted, the percentage and
    # the category are derived from them.
    "invasion_depth": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.3"},
    "myometrial_thickness": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.3"},
    "myometrial_invasion_percent": {
        "cardinality": "zero_or_one",
        "rule": "ratio_percent",
        "rule_kind": "formula",
        "cited_to": "docs/taxonomy.md#2",
    },
    "myometrial_invasion_category": {
        "cardinality": "exactly_one",
        "rule": "threshold",
        "rule_kind": "threshold",
        "cited_to": "docs/taxonomy.md#2",
    },
    "cervical_stromal_invasion": {"cardinality": "exactly_one", "cited_to": "docs/taxonomy.md#1.4"},
    "cervical_invasion_depth": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.4"},
    "cervical_wall_thickness": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.4"},
    "cervical_wall_percent": {
        "cardinality": "zero_or_one",
        "rule": "ratio_percent",
        "rule_kind": "formula",
        "cited_to": "docs/taxonomy.md#2",
    },
    # --- Lymphovascular space invasion -------------------------------------------------
    "lvsi_status": {"cardinality": "exactly_one", "cited_to": "ex_cap_lvsi_equivalence"},
    "lvsi_foci_count": {"cardinality": "zero_or_one", "cited_to": "ex_cap_lvsi_count_instruction"},
    "lvsi_extent": {
        "cardinality": "exactly_one",
        "rule": "threshold",
        "rule_kind": "threshold",
        "cited_to": "ex_figo2023_lvsi_substantial_rule",
    },
    # --- Regional lymph nodes ----------------------------------------------------------
    "pn_category": {"cardinality": "exactly_one", "cited_to": "docs/taxonomy.md#1.6"},
    # --- Molecular ---------------------------------------------------------------------
    "pole_exonuclease_mutation": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.7"},
    "mmr_ihc": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.7"},
    "p53_ihc": {"cardinality": "zero_or_one", "cited_to": "docs/taxonomy.md#1.7"},
    "promise_class": {
        "cardinality": "exactly_one",
        "rule": "membership",
        "rule_kind": "membership",
        "cited_to": "docs/taxonomy.md#1.7",
    },
    "tcga_class": {"cardinality": "exactly_one", "cited_to": "docs/taxonomy.md#1.7"},
    # --- Stage -------------------------------------------------------------------------
    "figo_stage": {
        "cardinality": "exactly_one",
        "rule": "derive_stage",
        "rule_kind": "algorithm",
        "cited_to": "ex_cap_figo2009_element",
    },
}


# Which built-in CAP checklist variable (src/endopath/fields.py) each compiles to. The
# derived variables map to their derived concept, whose inputs the compiled list carries
# separately. Every checklist field the MVP carries maps to a concept here; a field with no
# concept would be listed in UNMODELED_CAP_FIELDS as an explicit gap rather than fabricated.
CAP_CHECKLIST_COVERAGE: dict[str, str] = {
    "histologic_type": "histologic_type",
    "histologic_grade": "figo_grade",
    "myometrial_invasion_percent": "myometrial_invasion_percent",
    "cervical_stromal_invasion": "cervical_stromal_invasion",
    "lymphovascular_space_invasion": "lvsi_extent",
    "regional_lymph_node_status": "pn_category",
    "pathologic_stage": "figo_stage",
    "molecular_classification": "promise_class",
}

# CAP fields fields.py lists that the taxonomy registry does not model, and so route to the reviewer
# queue as unresolved rather than to a fabricated concept. Empty in the MVP: every checklist field maps
# to a concept in CAP_CHECKLIST_COVERAGE.
UNMODELED_CAP_FIELDS: frozenset[str] = frozenset()


def _load_graph() -> tuple[dict[str, dict], dict[str, list[str]], frozenset[str]]:
    """Return (nodes by id, inputs by derived concept id, category id set) from the
    dependency graph. Inputs are the `derived-from` and `feeds-stage` targets, in graph order."""
    data = json.loads(DEPENDENCY_GRAPH_PATH.read_text(encoding="utf-8"))
    nodes = {node["id"]: node for node in data["nodes"]}
    inputs: dict[str, list[str]] = {}
    for edge in data["edges"]:
        if edge["relation"] in ("derived-from", "feeds-stage"):
            inputs.setdefault(edge["from"], []).append(edge["to"])
    categories = frozenset(node["category"] for node in data["nodes"])
    return nodes, inputs, categories


def _load_extraction_ids() -> frozenset[str]:
    with EXTRACTIONS_PATH.open(newline="", encoding="utf-8") as handle:
        return frozenset(row["extraction_id"] for row in csv.DictReader(handle))


def _load_doc_sections() -> frozenset[str]:
    sections: set[str] = set()
    for line in TAXONOMY_DOC_PATH.read_text(encoding="utf-8").splitlines():
        match = _DOC_HEADING_RE.match(line)
        if match:
            sections.add(match.group("section"))
    return frozenset(sections)


def citation_resolves(cited_to: str, extraction_ids: frozenset[str], doc_sections: frozenset[str]) -> bool:
    """A citation resolves when it names a known extraction_id or a real docs/taxonomy.md section."""
    if cited_to in extraction_ids:
        return True
    doc_match = _DOC_CITATION_RE.match(cited_to)
    return bool(doc_match) and doc_match.group("section") in doc_sections


def compile_concepts() -> list[CompiledConcept]:
    """The A2 pass: compile the registry's target model into the canonical concept list.

    Raises CompilationError if the enrichment table and the dependency graph have drifted, if
    a derived concept names an input that is not itself a concept, if a rule falls outside the
    closed vocabulary, or if any concept resolves to no registry citation.
    """
    nodes, inputs, categories = _load_graph()
    extraction_ids = _load_extraction_ids()
    doc_sections = _load_doc_sections()

    graph_ids = set(nodes)
    enriched_ids = set(_ENRICHMENT)
    if graph_ids != enriched_ids:
        missing = sorted(graph_ids - enriched_ids)
        extra = sorted(enriched_ids - graph_ids)
        raise CompilationError(
            "enrichment table and dependency graph disagree on the concept set: "
            f"missing cardinality/rule/citation for {missing}; enrichment names unknown concepts {extra}"
        )

    concepts: list[CompiledConcept] = []
    for concept_id, node in nodes.items():
        enrichment = _ENRICHMENT[concept_id]
        is_derived = node["kind"] == "derived"
        has_rule = "rule" in enrichment or "rule_kind" in enrichment
        if is_derived != has_rule:
            raise CompilationError(
                f"{concept_id}: dependency graph says kind={node['kind']!r} but enrichment "
                f"{'names' if has_rule else 'omits'} a rule"
            )

        derived: Optional[DerivedRule] = None
        if is_derived:
            concept_inputs = inputs.get(concept_id)
            if not concept_inputs:
                raise CompilationError(f"{concept_id} is derived but the graph gives it no inputs")
            derived = DerivedRule(
                inputs=tuple(concept_inputs),
                rule=enrichment["rule"],
                rule_kind=RuleKind(enrichment["rule_kind"]),
            )

        concept = CompiledConcept(
            concept_id=concept_id,
            category=node["category"],
            assertion_type=AssertionType(node["kind"]),
            cardinality=Cardinality(enrichment["cardinality"]),
            derived=derived,
            cited_to=enrichment["cited_to"],
        )

        if concept.category not in categories:
            raise CompilationError(f"{concept_id} has category {concept.category!r} not in the registry")
        if not citation_resolves(concept.cited_to, extraction_ids, doc_sections):
            raise CompilationError(
                f"{concept_id} cites {concept.cited_to!r}, which is neither an extraction_id in "
                "extractions.csv nor a section of docs/taxonomy.md"
            )
        concepts.append(concept)

    # Input closure: every input of every derived concept is itself a compiled concept.
    known = {concept.concept_id for concept in concepts}
    for concept in concepts:
        if concept.derived is None:
            continue
        for source in concept.derived.inputs:
            if source not in known:
                raise CompilationError(
                    f"{concept.concept_id} derives from {source!r}, which is not a concept in the list"
                )
    return concepts


def concept_list() -> list[dict]:
    """The canonical concept list as plain JSON-ready dicts, one per concept."""
    return [concept.model_dump(mode="json") for concept in compile_concepts()]


def concept_list_json() -> str:
    """The canonical concept list as strict JSON, the #91 artifact."""
    return json.dumps(concept_list(), indent=2) + "\n"


def cap_checklist_coverage() -> dict[str, str]:
    """Which compiled concept each built-in CAP checklist variable maps to, validated against
    the compiled list so a renamed concept fails here rather than silently."""
    known = {concept.concept_id for concept in compile_concepts()}
    for field_name, concept_id in CAP_CHECKLIST_COVERAGE.items():
        if concept_id not in known:
            raise CompilationError(
                f"CAP field {field_name!r} maps to {concept_id!r}, which is not in the compiled list"
            )
    return dict(CAP_CHECKLIST_COVERAGE)


def main() -> None:
    print(concept_list_json(), end="")


if __name__ == "__main__":
    main()