| """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" |
|
|
| |
| |
| |
| _DOC_CITATION_RE = re.compile(r"^docs/taxonomy\.md#(?P<section>\d+(?:\.\d+)*)$") |
| |
| |
| _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" |
|
|
|
|
| |
| |
| |
| |
| _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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _ENRICHMENT: dict[str, dict] = { |
| |
| "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", |
| }, |
| |
| |
| |
| "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", |
| }, |
| |
| "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", |
| }, |
| |
| "pn_category": {"cardinality": "exactly_one", "cited_to": "docs/taxonomy.md#1.6"}, |
| |
| "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"}, |
| |
| "figo_stage": { |
| "cardinality": "exactly_one", |
| "rule": "derive_stage", |
| "rule_kind": "algorithm", |
| "cited_to": "ex_cap_figo2009_element", |
| }, |
| } |
|
|
|
|
| |
| |
| |
| |
| 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", |
| } |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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() |
|
|