@dzhechkov/p-replicator 1.5.16 → 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.
Files changed (21) hide show
  1. package/.dz-manifest.json +51 -23
  2. package/README.md +5 -3
  3. package/package.json +1 -1
  4. package/sbom.json +106 -36
  5. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  6. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  7. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  8. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  9. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  10. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  11. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +891 -280
  12. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  13. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  14. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  15. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +970 -306
  16. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  17. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  18. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  19. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  20. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  21. 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
+ ]