@dzhechkov/p-replicator 1.5.17 → 1.5.18
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.
- package/.dz-manifest.json +51 -23
- package/README.md +5 -3
- package/package.json +1 -1
- package/sbom.json +106 -36
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- package/tests/snapshot/baseline.json +24 -9
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Population applicability — *was this number obtained in people like this patient?*
|
|
4
|
+
|
|
5
|
+
A signed, correctly-sourced fact can still mislead the one reader who matters. Four
|
|
6
|
+
real reversals motivated this module (ADR-001 §"Context"): a testosterone/weight-loss
|
|
7
|
+
finding measured in men with obesity applied to a patient at BMI 25; an
|
|
8
|
+
erectile-dysfunction RCT that enrolled only BMI >= 30; TRAVERSE's safety reassurance
|
|
9
|
+
established in high-cardiovascular-risk men; a +44.5% LDL effect observed at baseline
|
|
10
|
+
triglycerides >= 800 mg/dL quoted at 236 mg/dL.
|
|
11
|
+
|
|
12
|
+
Two criterion KINDS, because the four reversals are two different failures:
|
|
13
|
+
|
|
14
|
+
eligibility — the patient would NOT have been enrolled → verdict 'none'
|
|
15
|
+
baseline — enrollable, but his starting value is outside the
|
|
16
|
+
range the effect was measured FROM → verdict 'partial'
|
|
17
|
+
|
|
18
|
+
An inclusion-criteria-only model reproduces two of the four and calls it done.
|
|
19
|
+
|
|
20
|
+
WHAT THIS MODULE DOES NOT CLAIM
|
|
21
|
+
* that `verbatim` was transcribed truthfully from the source — nothing here can
|
|
22
|
+
check that; it is printed next to every discrepancy so a human can;
|
|
23
|
+
* that the criterion the source used is the criterion that matters clinically;
|
|
24
|
+
* that a `full` verdict means the claim is true. It means the patient is inside
|
|
25
|
+
the population the number came from. Nothing more.
|
|
26
|
+
|
|
27
|
+
Stdlib only. No network, no global state. THE EVALUATION PATH IS I/O-FREE —
|
|
28
|
+
`parse_study_population`, `evaluate`, `derive_verdict`, `match`, `match_from_fact` and
|
|
29
|
+
`render_population_match` never touch the filesystem. The single exception is
|
|
30
|
+
`load_field_cases()`, which reads the committed fixture so the tests and the README
|
|
31
|
+
example replay the SAME artifact; it is not on any evaluation path.
|
|
32
|
+
|
|
33
|
+
This paragraph used to promise blanket purity while `load_field_cases()` opened a file
|
|
34
|
+
two screens below (QE G8). The header is narrowed to the guarantee the code actually
|
|
35
|
+
keeps, rather than the function being deleted to fit the header — and
|
|
36
|
+
`test_population_match.py` asserts the exception stays the only one.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from dataclasses import dataclass, field as dc_field
|
|
43
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
44
|
+
|
|
45
|
+
# --- Grammar (a new study never forces an edit here) --------------------------
|
|
46
|
+
CRITERION_OPS = (">=", "<=", ">", "<", "==", "in", "range")
|
|
47
|
+
CRITERION_KINDS = ("eligibility", "baseline")
|
|
48
|
+
|
|
49
|
+
DISCREPANCY_KINDS = (
|
|
50
|
+
"eligibility-excluded", # the patient would NOT have been enrolled → 'none'
|
|
51
|
+
"baseline-out-of-range", # enrollable, but his starting value is outside → 'partial'
|
|
52
|
+
"criterion-unstated", # the paper does not state this axis → 'unknown'
|
|
53
|
+
"patient-value-missing", # the profile does not carry this axis → 'unknown'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
DISCREPANCY_DIRECTIONS = ("below", "above", "outside-set", "absent")
|
|
57
|
+
|
|
58
|
+
POPULATION_MATCH_VERDICTS = ("full", "partial", "none", "unknown")
|
|
59
|
+
|
|
60
|
+
# The `field` a wholly-unstated population reports. It is a SENTINEL, not an axis:
|
|
61
|
+
# there is no criterion to name because the source named none. Exported so the report
|
|
62
|
+
# gate can recognise it instead of demanding this literal token appear in the prose —
|
|
63
|
+
# the bug that punished the sanctioned `StudyPopulation.unstated(reason)` path (QE G5).
|
|
64
|
+
UNSTATED_POPULATION_FIELD = "(study population)"
|
|
65
|
+
|
|
66
|
+
# ALLOWLIST OVER EMITTED STRUCTURE (ADR-001 §1, D-16's counterpart for output).
|
|
67
|
+
# The renderer iterates this tuple, so a field added to Discrepancy without being
|
|
68
|
+
# added here is simply NOT PRINTED — the safe direction, and the opposite of a
|
|
69
|
+
# blocklist that must anticipate every bad word.
|
|
70
|
+
DISCREPANCY_KEYS = (
|
|
71
|
+
"field", "kind", "patient_value", "study_requirement", "verbatim", "locator", "direction",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# --- Content wearing grammar's clothes (ADR-001 §1 accepted this knowingly) ----
|
|
75
|
+
# A closed list of clinical axes. A thirteenth axis is a reviewed code change.
|
|
76
|
+
# The cost is made LOUD, never silent: an unlisted field RAISES at construction
|
|
77
|
+
# (D-16). A silently-dropped criterion is exactly how a patient gets told a number
|
|
78
|
+
# applies to him — the one outcome this module exists to prevent.
|
|
79
|
+
CRITERION_FIELDS = (
|
|
80
|
+
"age", "sex", "bmi", "weight", "baseline_condition", "cv_risk",
|
|
81
|
+
"triglycerides", "ldl", "hba1c", "egfr", "testosterone", "smoking_status",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Unit spellings a source (or a patient profile) may append to a field name.
|
|
85
|
+
# `triglycerides_mg_dl_min` and `triglycerides` are the same AXIS; the unit is not
|
|
86
|
+
# a new axis. Stripping is exact-suffix only — no fuzzy matching, so an unknown
|
|
87
|
+
# spelling still reaches D-16's refusal rather than being guessed at.
|
|
88
|
+
UNIT_SUFFIXES = ("mg_dl", "mmol_l", "ng_dl", "nmol_l", "pct", "percent", "kg", "years", "ml_min")
|
|
89
|
+
|
|
90
|
+
# Comparator spellings a flat criteria dict may use as a key suffix.
|
|
91
|
+
_KEY_OP_SUFFIXES = (("_min", ">="), ("_max", "<="), ("_gt", ">"), ("_lt", "<"),
|
|
92
|
+
("_in", "in"), ("_range", "range"))
|
|
93
|
+
|
|
94
|
+
_ORDERED_OPS = (">=", "<=", ">", "<")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PopulationError(ValueError):
|
|
98
|
+
"""Every refusal in this module. A subclass of ValueError so callers that
|
|
99
|
+
already handle malformed populations keep working."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalize_field(name: str) -> str:
|
|
103
|
+
"""Map a wire key onto a CRITERION_FIELDS axis, or RAISE (D-16).
|
|
104
|
+
|
|
105
|
+
`bmi_min` → `bmi`; `triglycerides_mg_dl_min` → `triglycerides`.
|
|
106
|
+
An unlisted axis is never dropped, never coerced, never ignored.
|
|
107
|
+
"""
|
|
108
|
+
if not isinstance(name, str) or not name.strip():
|
|
109
|
+
raise PopulationError("criterion key must be a non-empty string")
|
|
110
|
+
key = name.strip().lower()
|
|
111
|
+
for suffix, _op in _KEY_OP_SUFFIXES:
|
|
112
|
+
if key.endswith(suffix) and len(key) > len(suffix):
|
|
113
|
+
key = key[: -len(suffix)]
|
|
114
|
+
break
|
|
115
|
+
if key in CRITERION_FIELDS:
|
|
116
|
+
return key
|
|
117
|
+
for unit in UNIT_SUFFIXES:
|
|
118
|
+
tail = "_" + unit
|
|
119
|
+
if key.endswith(tail) and key[: -len(tail)] in CRITERION_FIELDS:
|
|
120
|
+
return key[: -len(tail)]
|
|
121
|
+
raise PopulationError(
|
|
122
|
+
f"criterion field {name!r} normalises to {key!r}, which is not in CRITERION_FIELDS "
|
|
123
|
+
f"{CRITERION_FIELDS}. Growing the vocabulary is a reviewed code change; a criterion "
|
|
124
|
+
f"the matcher cannot name must NOT be silently dropped (D-16)."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _op_for_key(name: str) -> str:
|
|
129
|
+
key = name.strip().lower()
|
|
130
|
+
for suffix, op in _KEY_OP_SUFFIXES:
|
|
131
|
+
if key.endswith(suffix) and len(key) > len(suffix):
|
|
132
|
+
return op
|
|
133
|
+
return "=="
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Criterion:
|
|
138
|
+
"""One comparable statement drawn from the source's own population description.
|
|
139
|
+
|
|
140
|
+
`verbatim` is MANDATORY and non-blank: a criterion nobody transcribed is a
|
|
141
|
+
criterion nobody can check. `unstated_reason` marks an axis the paper does not
|
|
142
|
+
state — the honest third state, never a guessed value.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
field: str
|
|
146
|
+
op: str
|
|
147
|
+
value: Any
|
|
148
|
+
kind: str
|
|
149
|
+
verbatim: str
|
|
150
|
+
locator: Optional[str] = None
|
|
151
|
+
unstated_reason: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
object.__setattr__(self, "field", normalize_field(self.field))
|
|
155
|
+
if self.kind not in CRITERION_KINDS:
|
|
156
|
+
raise PopulationError(
|
|
157
|
+
f"criterion kind {self.kind!r} must be one of {CRITERION_KINDS}. The kind decides "
|
|
158
|
+
f"whether a mismatch is an EXCLUSION (verdict 'none') or an out-of-range BASELINE "
|
|
159
|
+
f"(verdict 'partial'); defaulting it would silently pick the verdict."
|
|
160
|
+
)
|
|
161
|
+
if not isinstance(self.verbatim, str) or not self.verbatim.strip():
|
|
162
|
+
raise PopulationError(
|
|
163
|
+
f"criterion {self.field!r} needs a non-empty `verbatim` — the source's own words, "
|
|
164
|
+
f"so a reader can check and overrule the machine"
|
|
165
|
+
)
|
|
166
|
+
if self.unstated_reason is not None:
|
|
167
|
+
if not str(self.unstated_reason).strip():
|
|
168
|
+
raise PopulationError(
|
|
169
|
+
f"criterion {self.field!r}: unstated_reason must say WHY the axis is unstated"
|
|
170
|
+
)
|
|
171
|
+
return # an unstated axis carries no op/value to validate
|
|
172
|
+
if self.op not in CRITERION_OPS:
|
|
173
|
+
raise PopulationError(f"criterion op {self.op!r} must be one of {CRITERION_OPS}")
|
|
174
|
+
if self.value is None:
|
|
175
|
+
raise PopulationError(
|
|
176
|
+
f"criterion {self.field!r} has no value and no unstated_reason — one of the two is "
|
|
177
|
+
f"required; an absent value is not the same as an axis the paper never stated"
|
|
178
|
+
)
|
|
179
|
+
if self.op == "in" and not isinstance(self.value, (list, tuple)):
|
|
180
|
+
raise PopulationError(f"criterion {self.field!r} with op 'in' needs a list of values")
|
|
181
|
+
if self.op == "range" and (not isinstance(self.value, (list, tuple)) or len(self.value) != 2):
|
|
182
|
+
raise PopulationError(f"criterion {self.field!r} with op 'range' needs [low, high]")
|
|
183
|
+
|
|
184
|
+
def requirement_text(self) -> str:
|
|
185
|
+
if self.unstated_reason is not None:
|
|
186
|
+
return f"{self.field}: not stated by the source ({self.unstated_reason})"
|
|
187
|
+
if self.op == "in":
|
|
188
|
+
return f"{self.field} in {list(self.value)}"
|
|
189
|
+
if self.op == "range":
|
|
190
|
+
return f"{self.field} between {self.value[0]} and {self.value[1]}"
|
|
191
|
+
return f"{self.field} {self.op} {self.value}"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class StudyPopulation:
|
|
196
|
+
"""Who the finding was obtained IN, as the source states it.
|
|
197
|
+
|
|
198
|
+
Valid only when `description` is non-blank AND (`criteria` non-empty OR
|
|
199
|
+
`unstated_reason` non-empty). `StudyPopulation(description="adults", criteria=())`
|
|
200
|
+
is NOT a shape — that sentinel is what stops `study_population` from becoming
|
|
201
|
+
the next field that is present and meaningless.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
description: str
|
|
205
|
+
criteria: Tuple[Criterion, ...] = ()
|
|
206
|
+
unstated_reason: Optional[str] = None
|
|
207
|
+
locator: Optional[str] = None
|
|
208
|
+
|
|
209
|
+
def __post_init__(self) -> None:
|
|
210
|
+
if not isinstance(self.description, str) or not self.description.strip():
|
|
211
|
+
raise PopulationError("StudyPopulation.description must be a non-empty string")
|
|
212
|
+
object.__setattr__(self, "criteria", tuple(self.criteria or ()))
|
|
213
|
+
for criterion in self.criteria:
|
|
214
|
+
if not isinstance(criterion, Criterion):
|
|
215
|
+
raise PopulationError("StudyPopulation.criteria must contain Criterion objects")
|
|
216
|
+
blank_reason = self.unstated_reason is None or not str(self.unstated_reason).strip()
|
|
217
|
+
if not self.criteria and blank_reason:
|
|
218
|
+
raise PopulationError(
|
|
219
|
+
"StudyPopulation needs at least one criterion, or an explicit unstated_reason. "
|
|
220
|
+
"An empty criteria list with no stated reason is indistinguishable from a bug "
|
|
221
|
+
"(the same discipline as create_listing_fact(reason=…))."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def unstated(cls, reason: str, description: str = "study population not stated by the source",
|
|
226
|
+
locator: Optional[str] = None) -> "StudyPopulation":
|
|
227
|
+
"""The single constructor for 'the paper does not say'. `reason` is MANDATORY
|
|
228
|
+
and stored verbatim — mirroring `create_listing_fact(reason=…)`."""
|
|
229
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
230
|
+
raise PopulationError("StudyPopulation.unstated() requires a non-empty reason")
|
|
231
|
+
return cls(description=description, criteria=(), unstated_reason=reason.strip(), locator=locator)
|
|
232
|
+
|
|
233
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
234
|
+
"""The opaque JSON value that crosses into `ed25519_verifier` (never a type)."""
|
|
235
|
+
out: Dict[str, Any] = {"description": self.description}
|
|
236
|
+
if self.locator:
|
|
237
|
+
out["locator"] = self.locator
|
|
238
|
+
if self.unstated_reason:
|
|
239
|
+
out["unstated_reason"] = self.unstated_reason
|
|
240
|
+
criteria: Dict[str, Any] = {}
|
|
241
|
+
for criterion in self.criteria:
|
|
242
|
+
spec: Dict[str, Any] = {"kind": criterion.kind, "verbatim": criterion.verbatim}
|
|
243
|
+
if criterion.unstated_reason is not None:
|
|
244
|
+
spec["unstated_reason"] = criterion.unstated_reason
|
|
245
|
+
else:
|
|
246
|
+
spec["op"] = criterion.op
|
|
247
|
+
spec["value"] = list(criterion.value) if isinstance(criterion.value, tuple) else criterion.value
|
|
248
|
+
if criterion.locator:
|
|
249
|
+
spec["locator"] = criterion.locator
|
|
250
|
+
criteria[criterion.field] = spec
|
|
251
|
+
if criteria:
|
|
252
|
+
out["criteria"] = criteria
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class PatientProfile:
|
|
258
|
+
"""The one person the number is being applied to. A query key, not a record."""
|
|
259
|
+
|
|
260
|
+
values: Mapping[str, Any] = dc_field(default_factory=dict)
|
|
261
|
+
|
|
262
|
+
def __post_init__(self) -> None:
|
|
263
|
+
if not isinstance(self.values, Mapping):
|
|
264
|
+
raise PopulationError("PatientProfile.values must be a mapping")
|
|
265
|
+
normalized: Dict[str, Any] = {}
|
|
266
|
+
for key, value in self.values.items():
|
|
267
|
+
# ASYMMETRY, ON PURPOSE. A *criterion* naming an unlisted axis RAISES
|
|
268
|
+
# (D-16) — it is a claim the matcher cannot check. A *patient* value
|
|
269
|
+
# naming one is kept under its raw key instead: refusing a whole profile
|
|
270
|
+
# because it carries one extra lab would be a loud failure in a
|
|
271
|
+
# patient-facing flow, and the unsafe direction is closed anyway — an
|
|
272
|
+
# unrecognised patient key can never satisfy a criterion, so the axis
|
|
273
|
+
# reads `patient-value-missing` → verdict `unknown`, never `full`.
|
|
274
|
+
try:
|
|
275
|
+
name = normalize_field(key)
|
|
276
|
+
except PopulationError:
|
|
277
|
+
name = str(key).strip().lower()
|
|
278
|
+
if name in normalized:
|
|
279
|
+
raise PopulationError(
|
|
280
|
+
f"patient profile has two keys that mean the same axis {name!r} — refusing "
|
|
281
|
+
f"rather than silently keeping one of them"
|
|
282
|
+
)
|
|
283
|
+
normalized[name] = value
|
|
284
|
+
object.__setattr__(self, "values", normalized)
|
|
285
|
+
|
|
286
|
+
def has(self, field: str) -> bool:
|
|
287
|
+
return field in self.values and self.values[field] is not None
|
|
288
|
+
|
|
289
|
+
def get(self, field: str) -> Any:
|
|
290
|
+
return self.values.get(field)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True)
|
|
294
|
+
class Discrepancy:
|
|
295
|
+
"""A NAMED difference. `«не просто вердикт, а перечисление того, чем именно
|
|
296
|
+
пациент отличается»` — enforced as a construction precondition, not a rendering habit."""
|
|
297
|
+
|
|
298
|
+
field: str
|
|
299
|
+
kind: str
|
|
300
|
+
patient_value: str
|
|
301
|
+
study_requirement: str
|
|
302
|
+
verbatim: str
|
|
303
|
+
locator: Optional[str] = None
|
|
304
|
+
direction: str = "outside-set"
|
|
305
|
+
|
|
306
|
+
def __post_init__(self) -> None:
|
|
307
|
+
if self.kind not in DISCREPANCY_KINDS:
|
|
308
|
+
raise PopulationError(f"discrepancy kind {self.kind!r} must be one of {DISCREPANCY_KINDS}")
|
|
309
|
+
if self.direction not in DISCREPANCY_DIRECTIONS:
|
|
310
|
+
raise PopulationError(
|
|
311
|
+
f"discrepancy direction {self.direction!r} must be one of {DISCREPANCY_DIRECTIONS}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def derive_verdict(discrepancies: Sequence[Discrepancy]) -> str:
|
|
316
|
+
"""THE ONLY producer of a verdict (D-9), imported by the runtime and the tests alike.
|
|
317
|
+
|
|
318
|
+
Load-bearing invariant (D-10): `verdict == "full"` IFF `discrepancies == []`.
|
|
319
|
+
`unknown` outranks `partial` but not `none` — an established exclusion is
|
|
320
|
+
knowledge; an unestablished criterion is not, and must never be reported as a
|
|
321
|
+
milder kind of match.
|
|
322
|
+
"""
|
|
323
|
+
kinds = {d.kind for d in discrepancies}
|
|
324
|
+
if "eligibility-excluded" in kinds:
|
|
325
|
+
return "none"
|
|
326
|
+
if kinds & {"criterion-unstated", "patient-value-missing"}:
|
|
327
|
+
return "unknown"
|
|
328
|
+
if discrepancies:
|
|
329
|
+
return "partial"
|
|
330
|
+
return "full"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class PopulationMatch:
|
|
335
|
+
"""Derived, never asserted. There is no settable verdict."""
|
|
336
|
+
|
|
337
|
+
verdict: str
|
|
338
|
+
discrepancies: Tuple[Discrepancy, ...] = ()
|
|
339
|
+
study_description: str = ""
|
|
340
|
+
|
|
341
|
+
def __post_init__(self) -> None:
|
|
342
|
+
object.__setattr__(self, "discrepancies", tuple(self.discrepancies or ()))
|
|
343
|
+
expected = derive_verdict(self.discrepancies)
|
|
344
|
+
if self.verdict != expected:
|
|
345
|
+
raise PopulationError(
|
|
346
|
+
f"PopulationMatch verdict {self.verdict!r} was not derived from its discrepancies "
|
|
347
|
+
f"(derive_verdict says {expected!r}). The verdict has exactly one home (D-9)."
|
|
348
|
+
)
|
|
349
|
+
if self.verdict not in POPULATION_MATCH_VERDICTS:
|
|
350
|
+
raise PopulationError(f"verdict {self.verdict!r} must be one of {POPULATION_MATCH_VERDICTS}")
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_discrepancies(cls, discrepancies: Sequence[Discrepancy],
|
|
354
|
+
study_description: str = "") -> "PopulationMatch":
|
|
355
|
+
return cls(verdict=derive_verdict(discrepancies), discrepancies=tuple(discrepancies),
|
|
356
|
+
study_description=study_description)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --------------------------------------------------------------------- parsing
|
|
360
|
+
def parse_criterion(key: str, spec: Any) -> Criterion:
|
|
361
|
+
"""Parse ONE entry of the flat `criteria` dict.
|
|
362
|
+
|
|
363
|
+
WIRE FORM — deliberate deviation from FR-2's bare-scalar example, recorded here
|
|
364
|
+
because it is a contract: the value is a SPEC OBJECT, not a scalar. A scalar
|
|
365
|
+
cannot state the criterion's `kind`, and the kind is what decides 'none' (you
|
|
366
|
+
would have been excluded) versus 'partial' (the effect was simply not measured
|
|
367
|
+
from where you stand). Defaulting it would let the wire format silently pick the
|
|
368
|
+
verdict — the exact class of failure this slice exists to prevent.
|
|
369
|
+
|
|
370
|
+
{"bmi_min": {"value": 30, "kind": "baseline",
|
|
371
|
+
"verbatim": "men with obesity (BMI >= 30 kg/m2)",
|
|
372
|
+
"locator": "[Methods, Participants]"}}
|
|
373
|
+
"""
|
|
374
|
+
if not isinstance(spec, Mapping):
|
|
375
|
+
raise PopulationError(
|
|
376
|
+
f"criterion {key!r} must be an object carrying at least `kind` and `verbatim` "
|
|
377
|
+
f"(got {type(spec).__name__}). A bare scalar cannot state the criterion kind, and "
|
|
378
|
+
f"the kind decides the verdict — see parse_criterion's docstring."
|
|
379
|
+
)
|
|
380
|
+
unknown_keys = set(spec) - {"value", "op", "kind", "verbatim", "locator", "unstated_reason"}
|
|
381
|
+
if unknown_keys:
|
|
382
|
+
raise PopulationError(f"criterion {key!r} carries unknown keys {sorted(unknown_keys)}")
|
|
383
|
+
return Criterion(
|
|
384
|
+
field=key,
|
|
385
|
+
op=str(spec.get("op") or _op_for_key(key)),
|
|
386
|
+
value=spec.get("value"),
|
|
387
|
+
kind=str(spec.get("kind", "")),
|
|
388
|
+
verbatim=str(spec.get("verbatim", "")),
|
|
389
|
+
locator=spec.get("locator"),
|
|
390
|
+
unstated_reason=spec.get("unstated_reason"),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def parse_study_population(data: Any) -> StudyPopulation:
|
|
395
|
+
"""Parse the opaque JSON dict that `ed25519_verifier` carries but never interprets.
|
|
396
|
+
|
|
397
|
+
The direction of the dependency matters: the verifier NEVER imports this module
|
|
398
|
+
(a crypto module whose correctness depends on a semantics module is the
|
|
399
|
+
fail-open shape `source_tier_ceiling` was already bitten by). It validates the
|
|
400
|
+
SHAPE; this function is the only place that reads the MEANING.
|
|
401
|
+
"""
|
|
402
|
+
if not isinstance(data, Mapping):
|
|
403
|
+
raise PopulationError("study_population must be a JSON object")
|
|
404
|
+
criteria_raw = data.get("criteria") or {}
|
|
405
|
+
if not isinstance(criteria_raw, Mapping):
|
|
406
|
+
raise PopulationError("study_population.criteria must be an object keyed by field name")
|
|
407
|
+
criteria = tuple(parse_criterion(key, spec) for key, spec in criteria_raw.items())
|
|
408
|
+
return StudyPopulation(
|
|
409
|
+
description=str(data.get("description", "")),
|
|
410
|
+
criteria=criteria,
|
|
411
|
+
unstated_reason=data.get("unstated_reason"),
|
|
412
|
+
locator=data.get("locator"),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------ evaluation
|
|
417
|
+
def _as_number(field: str, who: str, value: Any) -> float:
|
|
418
|
+
try:
|
|
419
|
+
return float(value)
|
|
420
|
+
except (TypeError, ValueError):
|
|
421
|
+
raise PopulationError(
|
|
422
|
+
f"{who} value {value!r} for {field!r} is not comparable as a number, but the criterion "
|
|
423
|
+
f"uses an ordered operator. Refusing loudly rather than guessing which side it falls on."
|
|
424
|
+
) from None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _satisfied(criterion: Criterion, patient_value: Any) -> Tuple[bool, str]:
|
|
428
|
+
"""(satisfied, direction-when-violated). One generic evaluator, no per-case branch (FR-5)."""
|
|
429
|
+
op = criterion.op
|
|
430
|
+
if op in _ORDERED_OPS:
|
|
431
|
+
patient = _as_number(criterion.field, "patient", patient_value)
|
|
432
|
+
required = _as_number(criterion.field, "study", criterion.value)
|
|
433
|
+
if op == ">=":
|
|
434
|
+
return patient >= required, "below"
|
|
435
|
+
if op == "<=":
|
|
436
|
+
return patient <= required, "above"
|
|
437
|
+
if op == ">":
|
|
438
|
+
return patient > required, "below"
|
|
439
|
+
return patient < required, "above"
|
|
440
|
+
if op == "range":
|
|
441
|
+
patient = _as_number(criterion.field, "patient", patient_value)
|
|
442
|
+
low = _as_number(criterion.field, "study", criterion.value[0])
|
|
443
|
+
high = _as_number(criterion.field, "study", criterion.value[1])
|
|
444
|
+
if patient < low:
|
|
445
|
+
return False, "below"
|
|
446
|
+
if patient > high:
|
|
447
|
+
return False, "above"
|
|
448
|
+
return True, "outside-set"
|
|
449
|
+
if op == "in":
|
|
450
|
+
allowed = [str(v).strip().lower() for v in criterion.value]
|
|
451
|
+
return str(patient_value).strip().lower() in allowed, "outside-set"
|
|
452
|
+
return str(patient_value).strip().lower() == str(criterion.value).strip().lower(), "outside-set"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def evaluate(study_population: StudyPopulation, patient: PatientProfile) -> List[Discrepancy]:
|
|
456
|
+
"""ONE generic evaluator over (op, value) pairs — the four real reversals and the
|
|
457
|
+
synthetic cases all flow through here with different DATA (FR-5)."""
|
|
458
|
+
if not isinstance(study_population, StudyPopulation):
|
|
459
|
+
raise PopulationError("evaluate() needs a StudyPopulation (use parse_study_population first)")
|
|
460
|
+
if not isinstance(patient, PatientProfile):
|
|
461
|
+
raise PopulationError("evaluate() needs a PatientProfile")
|
|
462
|
+
|
|
463
|
+
discrepancies: List[Discrepancy] = []
|
|
464
|
+
|
|
465
|
+
if study_population.unstated_reason and not study_population.criteria:
|
|
466
|
+
return [
|
|
467
|
+
Discrepancy(
|
|
468
|
+
field=UNSTATED_POPULATION_FIELD,
|
|
469
|
+
kind="criterion-unstated",
|
|
470
|
+
patient_value="n/a",
|
|
471
|
+
study_requirement=f"not stated: {study_population.unstated_reason}",
|
|
472
|
+
verbatim=study_population.description,
|
|
473
|
+
locator=study_population.locator,
|
|
474
|
+
direction="absent",
|
|
475
|
+
)
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
for criterion in study_population.criteria:
|
|
479
|
+
if criterion.unstated_reason is not None:
|
|
480
|
+
discrepancies.append(
|
|
481
|
+
Discrepancy(
|
|
482
|
+
field=criterion.field,
|
|
483
|
+
kind="criterion-unstated",
|
|
484
|
+
patient_value=_render_value(patient.get(criterion.field)),
|
|
485
|
+
study_requirement=criterion.requirement_text(),
|
|
486
|
+
verbatim=criterion.verbatim,
|
|
487
|
+
locator=criterion.locator,
|
|
488
|
+
direction="absent",
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
continue
|
|
492
|
+
if not patient.has(criterion.field):
|
|
493
|
+
discrepancies.append(
|
|
494
|
+
Discrepancy(
|
|
495
|
+
field=criterion.field,
|
|
496
|
+
kind="patient-value-missing",
|
|
497
|
+
patient_value="не указан / not recorded",
|
|
498
|
+
study_requirement=criterion.requirement_text(),
|
|
499
|
+
verbatim=criterion.verbatim,
|
|
500
|
+
locator=criterion.locator,
|
|
501
|
+
direction="absent",
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
continue
|
|
505
|
+
patient_value = patient.get(criterion.field)
|
|
506
|
+
ok, direction = _satisfied(criterion, patient_value)
|
|
507
|
+
if ok:
|
|
508
|
+
continue
|
|
509
|
+
discrepancies.append(
|
|
510
|
+
Discrepancy(
|
|
511
|
+
field=criterion.field,
|
|
512
|
+
kind="eligibility-excluded" if criterion.kind == "eligibility" else "baseline-out-of-range",
|
|
513
|
+
patient_value=_render_value(patient_value),
|
|
514
|
+
study_requirement=criterion.requirement_text(),
|
|
515
|
+
verbatim=criterion.verbatim,
|
|
516
|
+
locator=criterion.locator,
|
|
517
|
+
direction=direction,
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
return discrepancies
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _render_value(value: Any) -> str:
|
|
524
|
+
if value is None:
|
|
525
|
+
return "не указан / not recorded"
|
|
526
|
+
return str(value)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def match(study_population: StudyPopulation, patient: PatientProfile) -> PopulationMatch:
|
|
530
|
+
"""The one entry point a caller needs: evaluate, then derive."""
|
|
531
|
+
discrepancies = evaluate(study_population, patient)
|
|
532
|
+
return PopulationMatch.from_discrepancies(discrepancies, study_population.description)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ------------------------------------------------------------------- rendering
|
|
536
|
+
_KIND_PROSE = {
|
|
537
|
+
"eligibility-excluded": "this patient would NOT have been enrolled",
|
|
538
|
+
"baseline-out-of-range": "enrollable, but the effect was not measured from this starting value",
|
|
539
|
+
"criterion-unstated": "the source does not state this axis",
|
|
540
|
+
"patient-value-missing": "the patient profile does not carry this axis",
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def render_population_match(match_result: PopulationMatch) -> str:
|
|
545
|
+
"""THE ONLY renderer. Iterates DISCREPANCY_KEYS — an allowlist over emitted
|
|
546
|
+
structure — so a field added to Discrepancy without being added to the tuple is
|
|
547
|
+
simply not printed. There is no path that prints a verdict without its
|
|
548
|
+
discrepancies (D-10)."""
|
|
549
|
+
if not isinstance(match_result, PopulationMatch):
|
|
550
|
+
raise PopulationError("render_population_match() needs a PopulationMatch")
|
|
551
|
+
lines = [f"POPULATION_MATCH: {match_result.verdict}"]
|
|
552
|
+
if match_result.study_description:
|
|
553
|
+
lines.append(f" study population: {match_result.study_description}")
|
|
554
|
+
if not match_result.discrepancies:
|
|
555
|
+
lines.append(" no discrepancies — every stated criterion is satisfied by a known patient value")
|
|
556
|
+
return "\n".join(lines)
|
|
557
|
+
for discrepancy in match_result.discrepancies:
|
|
558
|
+
row = {key: getattr(discrepancy, key) for key in DISCREPANCY_KEYS}
|
|
559
|
+
lines.append(
|
|
560
|
+
" {field} — patient {patient_value}; study requires {study_requirement} "
|
|
561
|
+
"({kind}, {direction})".format(**row)
|
|
562
|
+
)
|
|
563
|
+
lines.append(" {prose}".format(prose=_KIND_PROSE[row["kind"]]))
|
|
564
|
+
quote = ' "{verbatim}"'.format(verbatim=row["verbatim"])
|
|
565
|
+
if row["locator"]:
|
|
566
|
+
quote += " {locator}".format(locator=row["locator"])
|
|
567
|
+
lines.append(quote)
|
|
568
|
+
return "\n".join(lines)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def match_from_fact(fact_study_population: Any, patient_values: Mapping[str, Any]) -> PopulationMatch:
|
|
572
|
+
"""Convenience for the report gate: opaque dict + raw patient dict → verdict."""
|
|
573
|
+
return match(parse_study_population(fact_study_population), PatientProfile(patient_values))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def load_field_cases(path: str) -> Dict[str, Any]:
|
|
577
|
+
"""Read the committed fixture file. Kept here so tests and the README example
|
|
578
|
+
load the SAME artifact rather than each restating the four cases."""
|
|
579
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
580
|
+
return json.load(handle)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
__all__ = [
|
|
584
|
+
"CRITERION_FIELDS", "CRITERION_KINDS", "CRITERION_OPS", "DISCREPANCY_KINDS",
|
|
585
|
+
"DISCREPANCY_KEYS", "DISCREPANCY_DIRECTIONS", "POPULATION_MATCH_VERDICTS", "UNIT_SUFFIXES",
|
|
586
|
+
"UNSTATED_POPULATION_FIELD",
|
|
587
|
+
"PopulationError", "Criterion", "StudyPopulation", "PatientProfile", "Discrepancy",
|
|
588
|
+
"PopulationMatch", "derive_verdict", "evaluate", "match", "match_from_fact",
|
|
589
|
+
"normalize_field", "parse_criterion", "parse_study_population", "render_population_match",
|
|
590
|
+
"load_field_cases",
|
|
591
|
+
]
|