File size: 21,180 Bytes
eea689d
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
e163a2c
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24a1dea
0657bc9
24a1dea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24a1dea
 
 
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
0657bc9
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0657bc9
 
 
 
 
 
 
 
 
 
eea689d
 
 
 
 
 
 
 
 
 
0657bc9
 
 
 
 
 
 
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
415
416
417
418
419
420
"""The cohort dashboard aggregate (#100, docs/prd.md sections 8.1, 4.3, 4.5, 4.7).

Section 8.1's Cohort dashboard reports, per checklist field, the distribution of confirmed values and of
coded absence reasons, and whether an absence reason tracks the other variables captured for the same
case. An absence that concentrates in high-grade tumours signals that the missingness follows the disease
rather than chance (section 4.5), which a single completion percentage cannot hold. It also lists, per
case, which absent variables would require ordering slides (section 4.7), grounded in the FIGO 2023
determinability verdict (`taxonomy.derive_stage` / `taxonomy.determinability_matrix`, #35).

This module is the read side. `storage.dashboard_summary` keeps the cohort completion rate and the
worst-field-first summary; this module reads each stored case back through `storage.get_case` (the same
path the export takes) and classifies every checklist field into the section 4.3 field-state vocabulary,
so a rule-suppressed field counts as a coded absence rather than a confirmed value (#43): the code reads
the field state, never a blank.

The two invariants this aggregate serves:

1. Numbers stay numbers. A confirmed value is counted as the primitive the report stated; the association
   strata are derived from a covariate (the FIGO grade reduced to low/high by the registry's `grade_binary`
   rule), never stored in its place.
2. No value without its evidence. A field is never simply present or blank: it is a confirmed value, one
   of the five coded absence reasons, or a pending draft a reviewer has not yet settled. Each of the three
   is counted on its own, so silence is never the response to a miss.
"""

from __future__ import annotations

from typing import Any, Optional

from sqlalchemy import Connection

from endopath import fields, storage, taxonomy
from endopath.schema import Case, ChecklistField, FieldStatus
from endopath.validation import FieldState

# The closed section-4.3 absence vocabulary, in the order the screen names it. Every field holds this
# whole set as its legend even where a code has a zero count, so the distribution reads against a fixed
# vocabulary rather than only the codes that happen to appear.
ABSENCE_VOCABULARY: tuple[str, ...] = (
    FieldState.NOT_APPLICABLE.value,
    FieldState.NOT_STATED.value,
    FieldState.INDETERMINATE.value,
    FieldState.DETERMINED_BY_JOIN.value,
    FieldState.CONSTRAINED.value,
)

# A confirmed value that names its own unassessability is the `indeterminate` state (the report says the
# feature cannot be assessed, which is a value), not a plain confirmed value. Held as a defensive set: a
# CAP value set that holds a "cannot be assessed" member reads as indeterminate, never as a plain value.
_INDETERMINATE_VALUES = frozenset({"cannot_be_assessed", "cannot_be_determined", "indeterminate"})

# How a missing variable is recovered, so the per-case list separates the absences that demand glass
# slides from the ones a data join fills (docs/prd.md sections 4.7, 8.3). A molecular class absent from a
# report of this era arrives from the clinical record, so it is a join, never a slide order; every other
# pathology variable is re-read from the slides. `pathologic_stage` is a derived summary, recovered by
# recovering its inputs rather than ordered on its own.
_RECOVERY_METHOD: dict[str, str] = {
    "histologic_type": "slides",
    "histologic_grade": "slides",
    "myometrial_invasion_percent": "slides",
    "cervical_stromal_invasion": "slides",
    "lymphovascular_space_invasion": "slides",
    "regional_lymph_node_status": "slides",
    "pathologic_stage": "derived",
    "molecular_classification": "join",
}

# The stratifier the absence-association is measured against: the FIGO grade reduced to low/high by the
# registry's `grade_binary` membership rule (#35), plus `unknown` for a case whose grade is not stated.
# Grade is the covariate docs/prd.md section 4.5 names ("concentrates in high-grade tumours").
_STRATIFIER_FIELD = "histologic_grade"
_STRATA: tuple[str, ...] = ("low", "high", "unknown")

# An association fires only with support on both known strata and a wide enough gap, so a one-case
# coincidence does not read as a signal.
_MIN_STRATUM_SUPPORT = 2
_SIGNAL_MARGIN = 0.20


def _primitive(value: object) -> object:
    """An enum to its value, everything else as-is, so a value read back as a raw string and one still
    with its enum both compare against the string vocabulary."""
    return value.value if hasattr(value, "value") else value


def field_disposition(field: ChecklistField) -> tuple[str, Optional[Any]]:
    """Reduce one stored checklist field to a dashboard disposition, reading the field state rather than a
    blank (#43, CLAUDE.md field-state corollary):

    - `("present", value)` a confirmed, applicable field with a value: it counts in the value
      distribution.
    - `("<absence_code>", None)` a coded absence from the section 4.3 vocabulary.
    - `("pending", value_or_None)` an applicable field a reviewer has not yet settled (still needs review).

    A gate-suppressed field is `not_applicable`; a flagged field was searched for and confirmed absent, so
    it is `not_stated`; a confirmed value naming its own unassessability is `indeterminate`.
    """
    if not field.applicable:
        return FieldState.NOT_APPLICABLE.value, None
    if field.status is FieldStatus.FLAGGED:
        return FieldState.NOT_STATED.value, None

    value = _primitive(field.value)
    if field.status is FieldStatus.CONFIRMED:
        if value is None:
            # A reviewer confirmed the field has no value: searched and absent.
            return FieldState.NOT_STATED.value, None
        if isinstance(value, str) and value in _INDETERMINATE_VALUES:
            return FieldState.INDETERMINATE.value, None
        return FieldState.PRESENT.value, value
    # Needs review: a drafted value a reviewer has not confirmed, or an empty draft. Neither a confirmed
    # value nor a coded absence yet.
    return "pending", value


def _stratum_for_case(case: Case) -> str:
    """The FIGO-grade stratum a case falls in: `low` or `high` by the registry `grade_binary` rule, or
    `unknown` when the grade is not stated."""
    grade = _primitive(case.checklist.histologic_grade.value)
    band = taxonomy.grade_binary(grade)
    return band if band in ("low", "high") else "unknown"


def _association(per_case: list[tuple[str, tuple[str, Optional[Any]]]]) -> dict:
    """Whether a field's absence tracks the FIGO-grade stratum (docs/prd.md section 4.5). Builds a
    present-vs-absent contingency across the strata and fires a signal when the absent rate on one known
    stratum exceeds the other by a wide margin with support on both, the shape of missingness that follows
    the disease rather than chance.

    `per_case` is `(stratum, disposition)` for every case with the field, where a disposition of
    `pending` is neither present nor a coded absence and so sits outside the contingency.
    """
    counts: dict[str, dict[str, int]] = {s: {"present": 0, "absent": 0} for s in _STRATA}
    for stratum, (kind, _value) in per_case:
        bucket = counts[stratum]
        if kind == FieldState.PRESENT.value:
            bucket["present"] += 1
        elif kind in ABSENCE_VOCABULARY:
            bucket["absent"] += 1
        # a pending field counts toward neither series

    rows: list[dict] = []
    for stratum in _STRATA:
        present = counts[stratum]["present"]
        absent = counts[stratum]["absent"]
        total = present + absent
        rows.append(
            {
                "stratum": stratum,
                "present": present,
                "absent": absent,
                "total": total,
                "absent_rate": (absent / total) if total else 0.0,
            }
        )

    known = [r for r in rows if r["stratum"] != "unknown" and r["total"] >= _MIN_STRATUM_SUPPORT]
    signal = False
    detail: Optional[str] = None
    if len(known) >= 2:
        high = max(known, key=lambda r: r["absent_rate"])
        low = min(known, key=lambda r: r["absent_rate"])
        if high["absent"] > 0 and high["absent_rate"] - low["absent_rate"] >= _SIGNAL_MARGIN:
            signal = True
            detail = (
                f"absence concentrates in {high['stratum']}-grade cases "
                f"({high['absent_rate'] * 100:.0f}% absent, against "
                f"{low['absent_rate'] * 100:.0f}% in {low['stratum']}-grade)"
            )
    return {"stratifier": _STRATIFIER_FIELD, "rows": rows, "signal": signal, "detail": detail}


def _case_assertions(case: Case) -> dict[str, Any]:
    """Canonical assertions for the FIGO projection, from the stored checklist. Shared with the export
    projection (#40), so the dashboard verdict and the exported stage read the same assertions."""
    from endopath import projection

    return projection.assertions_from_checklist(case.checklist)


def _case_verdict(case: Case) -> tuple[Optional[str], Optional[str]]:
    """The FIGO 2023 determinability verdict for one case (`report_determinability.case_verdict`'s logic,
    #35): `determined`, `determined_by_join`, `constrained`, or `unmeasurable`, with the stage or candidate
    set. Best-effort: a projection error leaves the verdict unnamed rather than failing the dashboard."""
    try:
        result = taxonomy.derive_stage(_case_assertions(case), "figo_endo/2023")
    except Exception:
        return None, None
    if isinstance(result, taxonomy.Constrained):
        return "constrained", "|".join(result.candidates)
    if isinstance(result, taxonomy.Indeterminate):
        return "unmeasurable", None
    if getattr(result, "modifier", None):
        return "determined_by_join", f"{result.code} ({result.modifier})"
    return "determined", getattr(result, "code", None)


# The checklist fields whose values feed the FIGO 2023 projection (`projection.assertions_from_checklist`).
# A verdict rests on an unreviewed draft when any of these that holds a value has not been confirmed.
_FIGO_INPUT_FIELDS: tuple[str, ...] = (
    "histologic_type",
    "histologic_grade",
    "myometrial_invasion_percent",
    "cervical_stromal_invasion",
    "lymphovascular_space_invasion",
    "molecular_classification",
)


def _verdict_basis(case: Case) -> str:
    """Whether the FIGO verdict rests only on confirmed values (`confirmed`) or draws on at least one
    unreviewed model draft (`draft`). The projection reads a field's value regardless of review state, so a
    determined stage can rest on drafts a reviewer has not settled; the dashboard marks those provisional
    rather than presenting them as established (#224)."""
    for name in _FIGO_INPUT_FIELDS:
        field = getattr(case.checklist, name)
        if _primitive(field.value) is None:
            continue
        if field.status is not FieldStatus.CONFIRMED:
            return "draft"
    return "confirmed"


def _requires_slides_for_case(
    case: Case, dispositions: dict[str, tuple[str, Optional[Any]]]
) -> Optional[dict]:
    """The absent variables one case would order slides to recover (docs/prd.md section 4.7). A variable
    counts when it is `not_stated` and its recovery method is the slides (`_RECOVERY_METHOD`): a molecular
    class arrives from a join and a gated field is genuinely inapplicable, so neither orders a slide.
    Returns None when the case needs no slides.
    """
    variables: list[dict] = []
    join_variables: list[dict] = []
    for field_name, (kind, _value) in dispositions.items():
        if kind != FieldState.NOT_STATED.value:
            continue
        method = _RECOVERY_METHOD.get(field_name, "slides")
        label = fields.BY_NAME[field_name].label
        if method == "slides":
            variables.append({"field_name": field_name, "label": label})
        elif method == "join":
            join_variables.append({"field_name": field_name, "label": label})

    if not variables:
        return None
    verdict, stage = _case_verdict(case)
    return {
        "case_barcode": case.case_barcode,
        "verdict": verdict,
        # Whether the verdict rests only on confirmed values or on at least one unreviewed draft, so the
        # dashboard can mark a draft-based projection provisional rather than stating it as fact (#224).
        "verdict_basis": _verdict_basis(case) if verdict is not None else None,
        "stage": stage,
        "variables": variables,
        "count": len(variables),
        "join_variables": join_variables,
    }


def _distribution_key(field_name: str, value: Any) -> str:
    """The category a confirmed value counts under in the distribution. Two free-text fields store raw
    strings that normalize onto a bounded set (#165): a reported `pathologic_stage` folds to its FIGO
    stage, a reported `regional_lymph_node_status` to its pn_category, so formatting variants and combined
    TNM tokens stop reading as distinct categories. A value the parser cannot resolve keeps its verbatim
    string, so it stays visible in the distribution rather than vanishing into a wrong bucket."""
    if field_name == "pathologic_stage":
        return taxonomy.parse_reported_stage(value).figo_stage or str(value)
    if field_name == "regional_lymph_node_status":
        return taxonomy.parse_reported_nodal(value) or str(value)
    return str(value)


def _stage_component_distributions(components: dict[str, dict[str, int]]) -> list[dict]:
    """The reported stage's parsed T/N/M components as separate distributions (#165 follow-on), so the
    dashboard shows the separated tabs beside the folded FIGO stage. Each is sorted by count; a dimension
    no case stated is dropped."""
    out: list[dict] = []
    for dimension, counts in components.items():
        if not counts:
            continue
        values = sorted(
            ({"value": v, "count": c} for v, c in counts.items()),
            key=lambda item: (-item["count"], item["value"]),
        )
        out.append({"dimension": dimension, "values": values})
    return out


def cohort_dashboard(conn: Connection, project: Optional[str] = None) -> dict:
    """The cohort dashboard payload (#100, docs/prd.md sections 8.1, 4.3, 4.5, 4.7).

    Holds the completion rate and worst-field-first summary from `storage.dashboard_summary`, then, per
    checklist field, the confirmed-value distribution, the coded-absence-reason distribution over the
    section-4.3 vocabulary, and the absence-versus-grade association; and, per case, the absent variables
    that would require ordering slides.

    `project` scopes the whole payload to one project's cases (issue #149), passed through to the summary
    and the batched cohort read. Omitted, the whole cohort is summarized.
    """
    summary = storage.dashboard_summary(conn, project)

    # value_counts[field] maps a confirmed value to its count; absence_counts[field] maps an absence code
    # to its count; per_case_strata[field] holds (stratum, disposition) for the association.
    value_counts: dict[str, dict[str, int]] = {name: {} for name in fields.FIELD_NAMES}
    absence_counts: dict[str, dict[str, int]] = {name: {} for name in fields.FIELD_NAMES}
    pending_counts: dict[str, int] = {name: 0 for name in fields.FIELD_NAMES}
    totals: dict[str, int] = {name: 0 for name in fields.FIELD_NAMES}
    per_case_strata: dict[str, list[tuple[str, tuple[str, Optional[Any]]]]] = {
        name: [] for name in fields.FIELD_NAMES
    }
    requires_slides: list[dict] = []
    # The reported pathologic stage's parsed T/N/M components, kept as their own distributions so the
    # dashboard can separate the tabs beside the folded FIGO stage (#165 follow-on).
    stage_components: dict[str, dict[str, int]] = {"T category": {}, "N category": {}, "M category": {}}

    # One batched read of the whole cohort rather than a get_case per barcode (issue #131), scoped to the
    # project when one is named (issue #149).
    for case, _ in storage.load_all_cases(conn, project):
        stratum = _stratum_for_case(case)
        dispositions: dict[str, tuple[str, Optional[Any]]] = {}

        for field_name, field in case.checklist.all_fields().items():
            if field_name not in value_counts:
                continue
            disposition = field_disposition(field)
            dispositions[field_name] = disposition
            totals[field_name] += 1
            per_case_strata[field_name].append((stratum, disposition))

            kind, value = disposition
            if kind == FieldState.PRESENT.value:
                key = _distribution_key(field_name, value)
                value_counts[field_name][key] = value_counts[field_name].get(key, 0) + 1
                if field_name == "pathologic_stage":
                    parsed = taxonomy.parse_reported_stage(value)
                    for dimension, component in (
                        ("T category", parsed.t_category),
                        ("N category", parsed.n_category),
                        ("M category", parsed.m_category),
                    ):
                        if component:
                            stage_components[dimension][component] = (
                                stage_components[dimension].get(component, 0) + 1
                            )
            elif kind == "pending":
                pending_counts[field_name] += 1
            else:  # a coded absence
                absence_counts[field_name][kind] = absence_counts[field_name].get(kind, 0) + 1

        slides = _requires_slides_for_case(case, dispositions)
        if slides is not None:
            requires_slides.append(slides)

    field_distributions: list[dict] = []
    for field_name in fields.FIELD_NAMES:
        values = sorted(
            ({"value": v, "count": c} for v, c in value_counts[field_name].items()),
            key=lambda item: (-item["count"], item["value"]),
        )
        absence = [
            {"reason": reason, "count": absence_counts[field_name][reason]}
            for reason in ABSENCE_VOCABULARY
            if absence_counts[field_name].get(reason)
        ]
        confirmed = sum(value_counts[field_name].values())
        absent = sum(absence_counts[field_name].values())
        not_stated = absence_counts[field_name].get(FieldState.NOT_STATED.value, 0)
        not_applicable = absence_counts[field_name].get(FieldState.NOT_APPLICABLE.value, 0)
        # A field a gate removed has no meaning for that case, so it leaves the denominator rather than
        # counting against completion.
        applicable = totals[field_name] - not_applicable
        # Resolved: a confirmed value, or an absence code that records an answer. `indeterminate` is the
        # report reporting that the feature cannot be assessed, `determined_by_join` is a value the
        # clinical record supplies, and `constrained` is a value a source narrowed. Only `not_stated`
        # means a source was searched and is silent, so only it counts as missing.
        resolved = confirmed + (absent - not_stated - not_applicable)
        field_distributions.append(
            {
                "field_name": field_name,
                "label": fields.BY_NAME[field_name].label,
                # The JSON-schema type of the field's value (number/integer/string), so the frontend picks
                # a histogram for a numeric distribution and a categorical bar chart otherwise (#100).
                "value_type": fields.BY_NAME[field_name].value_type,
                "confirmed": confirmed,
                "pending": pending_counts[field_name],
                "absent": absent,
                # The absence codes split by what they mean, so a reader never sums five different
                # statements into one missing-data figure (#108). `resolved + pending + not_stated`
                # equals `applicable`.
                "not_stated": not_stated,
                "not_applicable": not_applicable,
                "resolved": resolved,
                "applicable": applicable,
                "total": totals[field_name],
                "values": values,
                "absence": absence,
                "association": _association(per_case_strata[field_name]),
                # The reported stage's parsed T/N/M sub-distributions, so the tile can separate the tabs
                # (#165 follow-on). Empty for every other field.
                "components": _stage_component_distributions(stage_components)
                if field_name == "pathologic_stage"
                else [],
            }
        )

    requires_slides.sort(key=lambda entry: (-entry["count"], entry["case_barcode"]))

    return {
        **summary,
        "absence_vocabulary": list(ABSENCE_VOCABULARY),
        "stratifier": {
            "field_name": _STRATIFIER_FIELD,
            "label": fields.BY_NAME[_STRATIFIER_FIELD].label,
            "strata": list(_STRATA),
        },
        "field_distributions": field_distributions,
        "requires_slides": requires_slides,
        "requires_slides_case_count": len(requires_slides),
    }