@dzhechkov/p-replicator 1.5.17 → 1.6.0

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 (85) hide show
  1. package/.dz-manifest.json +173 -65
  2. package/CHANGELOG.md +135 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +41 -18
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +347 -77
  19. package/src/utils.js +2 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +126 -8
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  45. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  46. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  47. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  48. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  49. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  50. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  51. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  52. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  53. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  54. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  55. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  56. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  57. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  58. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  59. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  60. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  61. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  62. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  63. package/tests/e2e/lifecycle.test.js +21 -10
  64. package/tests/snapshot/baseline.json +51 -34
  65. package/tests/snapshot/update-baseline.js +2 -1
  66. package/tests/unit/adr-decision-coverage.test.js +137 -0
  67. package/tests/unit/adr-scanner-contract.test.js +108 -0
  68. package/tests/unit/autocommit-deletion.test.js +242 -0
  69. package/tests/unit/check-ports.test.js +184 -0
  70. package/tests/unit/db-port-rule.test.js +216 -0
  71. package/tests/unit/detect-parse-anchor.test.js +109 -0
  72. package/tests/unit/external-dependency-check.test.js +209 -0
  73. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  74. package/tests/unit/hooks-project-anchored.test.js +223 -0
  75. package/tests/unit/hooks-report-failures.test.js +207 -0
  76. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  77. package/tests/unit/roadmap-one-schema.test.js +179 -0
  78. package/tests/unit/sparc-reconciliation.test.js +117 -0
  79. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  80. package/tests/unit/statusline-honest-labels.test.js +178 -0
  81. package/tests/unit/statusline-two-roots.test.js +237 -0
  82. package/tests/unit/sync-templates-guard.test.js +209 -0
  83. package/tests/unit/utils.test.js +2 -2
  84. package/tests/unit/validation-gate-teeth.test.js +158 -0
  85. package/LICENSE +0 -21
@@ -21,7 +21,9 @@ Exit codes:
21
21
  HONEST SCOPE: this gate proves the report does not LEAN ON unread sources. It
22
22
  does not prove the cited sources support the claims, and it cannot judge legacy
23
23
  facts that predate the evidence axis — those are counted and NAMED separately,
24
- never silently folded into "clean".
24
+ never silently folded into "clean". A report resting ENTIRELY on facts this gate
25
+ cannot judge exits 1, not 0: "I could not check this" and "this is fine" must not
26
+ share an exit code.
25
27
  """
26
28
 
27
29
  from __future__ import annotations
@@ -31,7 +33,7 @@ import json
31
33
  import re
32
34
  import sys
33
35
  from dataclasses import dataclass
34
- from typing import Any, Dict, List, Optional, Sequence, Tuple
36
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
35
37
 
36
38
  EVIDENCE_FETCH_VERIFIED = "FETCH_VERIFIED"
37
39
  EVIDENCE_LISTING_ONLY = "LISTING_ONLY"
@@ -54,6 +56,95 @@ LISTING_MARKERS = (
54
56
  # does not warn the reader of a sentence on page 2.
55
57
  MARKER_WINDOW_CHARS = 400
56
58
 
59
+ # ---------------------------------------------------------------------------
60
+ # SLICE C ADDITIONS (ADR-001 §5, ADR-002 §3) — ADDITIVE ONLY.
61
+ # No existing constant, rule, threshold or exit path above or below is modified:
62
+ # the new judgements live in their own functions (`evaluate_population`,
63
+ # `scan_relative_risk`) and are merged into the same findings list by `main()`,
64
+ # exactly as `verify_ledger_signatures()` already is.
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # A claim whose population does not match this patient is admissible only if the
68
+ # report SAYS SO near the claim — same discipline as LISTING_MARKERS, and the same
69
+ # 400-char window. Both languages, because the report may be written in either.
70
+ POPULATION_MARKERS = (
71
+ "POPULATION_MATCH",
72
+ "популяция исследования",
73
+ "не изучалась",
74
+ "популяция не совпадает",
75
+ "исследование проводилось",
76
+ "study population",
77
+ "population mismatch",
78
+ "not studied in",
79
+ "measured in",
80
+ )
81
+
82
+ # When the source states NO population at all there is no axis to name, so the
83
+ # axis-naming rule above cannot be satisfied by any honest sentence (QE G5): the
84
+ # discrepancy's field is the literal sentinel `(study population)`, and only that
85
+ # parenthesised token cleared the window. A report that SAYS the population is
86
+ # unknown is the sanctioned escape hatch — `StudyPopulation.unstated(reason)` — and
87
+ # must not be the one path the gate punishes. These are the ways of saying "not
88
+ # stated"; a generic "results may not generalise" contains none of them, so the
89
+ # anti-boilerplate property the axis rule exists for is preserved.
90
+ POPULATION_UNSTATED_MARKERS = (
91
+ "POPULATION_MATCH unknown",
92
+ "не указан",
93
+ "не описан",
94
+ "не сообщ",
95
+ "не назван",
96
+ "не приводит",
97
+ "не раскры",
98
+ "не стат",
99
+ "неизвестн",
100
+ "not stated",
101
+ "not described",
102
+ "not reported",
103
+ "not specified",
104
+ "does not state",
105
+ "unstated",
106
+ )
107
+
108
+ # The lower bound at which `study_population` is inside the signed message (ADR-003).
109
+ # Below it the field is UNSIGNED JSON sitting next to a valid signature.
110
+ POPULATION_ATTESTED_MIN_SCHEMA = 3
111
+
112
+ # FORMAT patterns, not meaning (D-19). This is a BELT: `risk_statement.py`'s
113
+ # constructor is the guarantee, and a list of phrasings can never be one. A novel
114
+ # phrasing is missed — which is precisely why the gate's own output says so.
115
+ RELATIVE_RISK_PATTERNS = (
116
+ r"в\s+\d+(?:[.,]\d+)?\s*раз",
117
+ r"удваивает|удвоение|утраивает|утроение|риск\s+удваивается",
118
+ r"\bdoubl(?:es|ing)\b|\btripl(?:es|ing)\b",
119
+ r"\b\d+(?:[.,]\d+)?\s*-?\s*fold\b",
120
+ r"\b(?:RR|HR|OR|IRR)\s*[=:]?\s*\d+(?:[.,]\d+)?",
121
+ r"\b\d+(?:[.,]\d+)?\s*%\s*(?:relative\s+risk|RRR|relative)",
122
+ r"\d+(?:[.,]\d+)?\s*%\s*относительн",
123
+ r"[×x]\s*\d+(?:[.,]\d+)?\s*(?:риск|risk)",
124
+ )
125
+
126
+ # What must sit within the window for a relative figure to be admissible: a real
127
+ # absolute figure, an NNT, or the explicit "we do not know the baseline" sentence.
128
+ ABSOLUTE_COMPANION_PATTERNS = (
129
+ r"на\s+\d+(?:\s?\d+)*\s+(?:челов|пациент)",
130
+ r"\d+(?:[.,]\d+)?\s+из\s+\d+",
131
+ r"\bper\s+\d+",
132
+ r"\b\d+\s+in\s+\d+\b",
133
+ r"\bNNT\b|\bNNH\b",
134
+ # NOT a bare `абсолютн` (QE G7): that stem also matches the ADVERB «абсолютно»,
135
+ # so "Риск удваивается, это абсолютно доказано" — a filler word, not a number —
136
+ # cleared the belt. The negative lookahead keeps every ADJECTIVAL form
137
+ # («абсолютный риск», «в абсолютных числах», «абсолютное снижение»), whose stem
138
+ # is followed by a declension letter, and rejects the adverb, whose «о» ends the
139
+ # word. A declension list would be an enumeration wearing an allowlist's clothes.
140
+ r"абсолютн(?!о\b)",
141
+ r"absolute\s+risk",
142
+ r"BASELINE RISK NOT ESTABLISHED",
143
+ r"базовый\s+риск\s+неизвест",
144
+ )
145
+
146
+ POPULATION_UNCHECKED_LINE = "population applicability: NOT CHECKED — no --profile supplied"
147
+
57
148
 
58
149
  @dataclass
59
150
  class Finding:
@@ -279,6 +370,255 @@ def evaluate(report_text: str, facts: Sequence[Dict[str, Any]]) -> Tuple[List[Fi
279
370
  return findings, counts
280
371
 
281
372
 
373
+ # ---------------------------------------------------------------------------
374
+ # SLICE C — population applicability (ADR-001 §5) and the relative-risk belt
375
+ # (ADR-002 §3). New functions; nothing above is rewritten.
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def has_population_marker_near(report_text: str, position: int, field: Optional[str] = None,
379
+ window: int = MARKER_WINDOW_CHARS) -> bool:
380
+ """A population caveat counts only if it NAMES the diverging axis.
381
+
382
+ A generic "results may not generalise" boilerplate next to every claim would
383
+ satisfy a marker-only rule while telling the reader nothing — so the discrepancy
384
+ `field` must appear in the window too.
385
+
386
+ EXCEPTION, and the reason it exists (QE G5): when the source states no population
387
+ at all, the discrepancy's field is the sentinel `population_match.UNSTATED_
388
+ POPULATION_FIELD` — not an axis, and no honest sentence names it. Requiring that
389
+ literal token punished the one path the design SANCTIONS,
390
+ `StudyPopulation.unstated(reason)`. For that sentinel the axis rule is replaced by
391
+ a different, equally specific one: the window must SAY the population is unknown.
392
+ """
393
+ start = max(0, position - window)
394
+ end = min(len(report_text), position + window)
395
+ neighbourhood = report_text[start:end].lower()
396
+ if not any(marker.lower() in neighbourhood for marker in POPULATION_MARKERS):
397
+ return False
398
+ if field and _is_unstated_population_field(field):
399
+ return any(marker.lower() in neighbourhood for marker in POPULATION_UNSTATED_MARKERS)
400
+ if field and field.lower() not in neighbourhood:
401
+ return False
402
+ return True
403
+
404
+
405
+ def _is_unstated_population_field(field: str) -> bool:
406
+ """True for the wholly-unstated-population sentinel.
407
+
408
+ The sentinel's spelling has ONE home — `population_match.UNSTATED_POPULATION_FIELD`
409
+ — and is imported rather than restated, so the gate and the matcher cannot drift
410
+ into disagreeing about which string means "the paper never said".
411
+ """
412
+ try:
413
+ from population_match import UNSTATED_POPULATION_FIELD
414
+ except Exception: # pragma: no cover - import guard
415
+ return False
416
+ return str(field).strip().lower() == UNSTATED_POPULATION_FIELD.lower()
417
+
418
+
419
+ def ledger_schema_version(fact: Mapping[str, Any]) -> Optional[int]:
420
+ """The schema version of a RAW ledger record, or None when it cannot be told.
421
+
422
+ Delegates to `ed25519_verifier.schema_version_of_mapping` — the SAME dispatch the
423
+ signature path uses — rather than restating it here, because two definitions of
424
+ "which schema is this" would be free to disagree exactly where it matters.
425
+
426
+ None means UNKNOWN, and unknown is treated as unattested by every caller: a gate
427
+ that cannot tell whether a field was signed must not report it as signed.
428
+
429
+ HONEST SCOPE — this is a SCHEMA check, not a signature check. It catches the
430
+ injection that pins `schema_version` to a pre-v3 value (which is what let a
431
+ genuinely-signed v2 fact carry a fabricated population and still verify). An
432
+ injection that leaves `schema_version` absent or set to 3 reads as attested HERE
433
+ and is caught THERE: the verifier rebuilds the v3 text, the signature does not
434
+ match, and `verify_ledger_signatures()` reports TAMPERED_FACT. The two checks
435
+ compose; neither is complete alone, and this docstring says which is which.
436
+ """
437
+ try:
438
+ import ed25519_verifier as ev
439
+ return ev.schema_version_of_mapping(dict(fact))
440
+ except Exception:
441
+ return None
442
+
443
+
444
+ def evaluate_population(report_text: str, facts: Sequence[Dict[str, Any]],
445
+ patient_profile: Optional[Dict[str, Any]] = None,
446
+ ) -> Tuple[List[Finding], Dict[str, int]]:
447
+ """Merge the applicability judgement in WITHOUT losing any other one.
448
+
449
+ Five rules, all exit 1:
450
+ UNATTESTED_STUDY_POPULATION a used fact whose schema predates v3 but which
451
+ carries a study_population anyway — the field is
452
+ OUTSIDE its signed message, so it is unsigned
453
+ JSON sitting next to a valid signature;
454
+ LEGACY_POPULATION_UNJUDGEABLE a used pre-v3 fact carrying no study_population;
455
+ MISSING_STUDY_POPULATION a used v3 fact carrying an empty study_population —
456
+ needs no patient profile to be wrong;
457
+ UNMARKED_POPULATION_MISMATCH verdict partial/none, no marker naming the
458
+ diverging field within 400 chars of EACH mention;
459
+ POPULATION_UNKNOWN_UNMARKED verdict unknown, same proximity rule.
460
+
461
+ THE FIRST TWO ARE THE POINT (QE G1 / G4). ADR-003 declares pre-v3 facts still
462
+ vulnerable by construction — but a declared vulnerability that produces
463
+ `POPULATION_MATCH full` with zero findings is not visible, it is LAUNDERED. A
464
+ legitimately-signed v2 fact with an INJECTED population and `schema_version` pinned
465
+ to its own schema verified, matched `full` and exited 0 (MEASURED). Both cases now
466
+ say what they are, and both are findings — because "counted in a line nobody has to
467
+ read" is the same shape as "inconclusive reads as pass", the defect this package
468
+ has already paid for twice. Neither rule needs a patient profile: the fact is
469
+ unjudgeable no matter WHO the patient is.
470
+
471
+ Without a patient profile the two proximity rules cannot run — and the gate SAYS SO
472
+ (`POPULATION_UNCHECKED_LINE`) instead of passing silently.
473
+ """
474
+ findings: List[Finding] = []
475
+ counts = {"population-checked": 0, "legacy-population-unknown": 0,
476
+ "population-unattested": 0,
477
+ "population-full": 0, "population-partial": 0,
478
+ "population-none": 0, "population-unknown": 0}
479
+
480
+ try:
481
+ import population_match as pmatch
482
+ except Exception as exc: # pragma: no cover - import guard
483
+ return ([Finding(kind="POPULATION_UNCHECKED", claim="(whole ledger)", source_url="",
484
+ detail=f"cannot import population_match ({exc}) — applicability was NOT judged")],
485
+ counts)
486
+
487
+ for fact in facts:
488
+ claim = str(fact.get("claim", ""))
489
+ url = str(fact.get("source_url", ""))
490
+ population = fact.get("study_population")
491
+ positions = claim_positions(report_text, claim)
492
+
493
+ if not positions:
494
+ continue # recorded but not used in this report — not this gate's business
495
+
496
+ schema = ledger_schema_version(fact)
497
+ attested = schema is not None and schema >= POPULATION_ATTESTED_MIN_SCHEMA
498
+
499
+ if population is None:
500
+ counts["legacy-population-unknown"] += 1
501
+ findings.append(Finding(
502
+ kind="LEGACY_POPULATION_UNJUDGEABLE", claim=claim, source_url=url,
503
+ detail=("this used fact predates study_population (schema {s}), so its applicability "
504
+ "to this patient cannot be judged at all. Unjudgeable is not clean: re-record "
505
+ "the claim through a v{v} constructor, or state the limitation in the report "
506
+ "next to the claim.".format(s="unknown" if schema is None else schema,
507
+ v=POPULATION_ATTESTED_MIN_SCHEMA))))
508
+ continue
509
+
510
+ if not attested:
511
+ counts["population-unattested"] += 1
512
+ findings.append(Finding(
513
+ kind="UNATTESTED_STUDY_POPULATION", claim=claim, source_url=url,
514
+ detail=("the fact reports schema {s}, whose signed message does not cover "
515
+ "study_population — the population here is UNSIGNED JSON next to a valid "
516
+ "signature and anyone could have written it. It is NOT matched against the "
517
+ "patient, because a match computed from unattested data reads exactly like "
518
+ "a match computed from signed data.".format(
519
+ s="unknown" if schema is None else schema))))
520
+ continue
521
+
522
+ if not isinstance(population, dict) or not population:
523
+ findings.append(Finding(kind="MISSING_STUDY_POPULATION", claim=claim, source_url=url,
524
+ detail="the fact carries an empty study_population — a claim that "
525
+ "does not say who it was measured in cannot be applied to anyone"))
526
+ continue
527
+
528
+ if patient_profile is None:
529
+ continue # reported as NOT CHECKED by render_population_and_risk()
530
+
531
+ try:
532
+ match = pmatch.match_from_fact(population, patient_profile)
533
+ except Exception as exc:
534
+ findings.append(Finding(kind="POPULATION_UNEVALUABLE", claim=claim, source_url=url,
535
+ detail=f"study_population could not be evaluated ({exc}) — "
536
+ f"unevaluable is never clean"))
537
+ continue
538
+
539
+ counts["population-checked"] += 1
540
+ counts["population-" + match.verdict] += 1
541
+ if match.verdict == "full":
542
+ continue
543
+
544
+ fields = [d.field for d in match.discrepancies]
545
+ # EVERY occurrence must be marked, not just the first — the rule already
546
+ # established for LISTING_ONLY at the existing gate.
547
+ unmarked = [pos for pos in positions
548
+ if not any(has_population_marker_near(report_text, pos, field) for field in fields)]
549
+ if not unmarked:
550
+ continue
551
+ kind = "POPULATION_UNKNOWN_UNMARKED" if match.verdict == "unknown" else "UNMARKED_POPULATION_MISMATCH"
552
+ findings.append(Finding(
553
+ kind=kind, claim=claim, source_url=url,
554
+ detail=("POPULATION_MATCH {verdict}: {names}. {n} of {total} occurrence(s) carry no marker "
555
+ "naming the diverging axis — or, where the source states no population at all, "
556
+ "saying THAT — within {w} chars".format(
557
+ verdict=match.verdict,
558
+ names="; ".join(f"{d.field} — patient {d.patient_value}, study requires "
559
+ f"{d.study_requirement} ({d.kind})" for d in match.discrepancies),
560
+ n=len(unmarked), total=len(positions), w=MARKER_WINDOW_CHARS))))
561
+ return findings, counts
562
+
563
+
564
+ def scan_relative_risk(report_text: str) -> List[Finding]:
565
+ """BELT (D-19): refuse a relative figure with no absolute companion in the window.
566
+
567
+ HONEST SCOPE, stated here and printed in the gate's own output: this scan matches
568
+ FORMATS, not meaning. It is a secondary belt over prose that the typed path in
569
+ `risk_statement.py` never sees; `test_risk_absolute.py` is the proof of the
570
+ property, and this scan may never be cited as one.
571
+ """
572
+ findings: List[Finding] = []
573
+ for pattern in RELATIVE_RISK_PATTERNS:
574
+ for hit in re.finditer(pattern, report_text, re.IGNORECASE | re.UNICODE):
575
+ start = max(0, hit.start() - MARKER_WINDOW_CHARS)
576
+ end = min(len(report_text), hit.end() + MARKER_WINDOW_CHARS)
577
+ window = report_text[start:end]
578
+ if any(re.search(companion, window, re.IGNORECASE | re.UNICODE)
579
+ for companion in ABSOLUTE_COMPANION_PATTERNS):
580
+ continue
581
+ findings.append(Finding(
582
+ kind="RELATIVE_RISK_WITHOUT_ABSOLUTE",
583
+ claim=report_text[max(0, hit.start() - 60):hit.end() + 60].strip(),
584
+ source_url="",
585
+ detail=("a relative effect ({matched!r}) with no absolute figure, NNT, or explicit "
586
+ "'BASELINE RISK NOT ESTABLISHED' within {w} chars. '21x higher' is 1 excess "
587
+ "case per 1394 people; only one of those two sentences is interpretable"
588
+ .format(matched=hit.group(0), w=MARKER_WINDOW_CHARS))))
589
+ return findings
590
+
591
+
592
+ def render_population_and_risk(counts: Dict[str, int], profile_supplied: bool) -> str:
593
+ """Printed BESIDE the existing gate output; `render()` itself is untouched."""
594
+ lines: List[str] = []
595
+ if not profile_supplied:
596
+ lines.append(" " + POPULATION_UNCHECKED_LINE)
597
+ else:
598
+ lines.append(
599
+ " population: {c} checked — full {f} / partial {p} / none {n} / unknown {u}".format(
600
+ c=counts.get("population-checked", 0), f=counts.get("population-full", 0),
601
+ p=counts.get("population-partial", 0), n=counts.get("population-none", 0),
602
+ u=counts.get("population-unknown", 0)))
603
+ if counts.get("legacy-population-unknown"):
604
+ lines.append(
605
+ " legacy-population-unknown: {n} used fact(s) predate study_population — this gate "
606
+ "cannot judge their applicability, and does not pretend to. Each one is a FINDING "
607
+ "above, not a footnote: a report resting entirely on them is unjudged, not clean.".format(
608
+ n=counts["legacy-population-unknown"]))
609
+ if counts.get("population-unattested"):
610
+ lines.append(
611
+ " population-unattested: {n} used fact(s) carry a study_population that their own "
612
+ "schema does not sign — unsigned JSON beside a valid signature. NOT matched against "
613
+ "the patient.".format(n=counts["population-unattested"]))
614
+ lines.append(
615
+ " belt scope: the relative-risk scan matches FORMATS, not meaning. It is a secondary belt; "
616
+ "the guarantee is risk_statement.RiskStatement's constructor, proven by "
617
+ "test_risk_absolute.py — never by this scan."
618
+ )
619
+ return "\n".join(lines)
620
+
621
+
282
622
  def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
283
623
  lines: List[str] = []
284
624
  total = sum(counts.values())
@@ -320,6 +660,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
320
660
  parser.add_argument("--facts", required=True, help="path to facts.json")
321
661
  parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
322
662
  parser.add_argument("--pins", help="JSON file of {issuer: pubkey_b64} so ISSUER_SIGNED facts can be checked")
663
+ # SLICE C: OPTIONAL on purpose. Making it mandatory would break every documented
664
+ # invocation for a check that cannot always apply; letting its absence pass
665
+ # silently would be "inconclusive reads as pass". Its absence is PRINTED instead.
666
+ parser.add_argument("--profile", help="JSON file of patient values, so population applicability can be judged")
323
667
  args = parser.parse_args(argv)
324
668
 
325
669
  try:
@@ -332,6 +676,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
332
676
  pins = json.load(handle)
333
677
  if not isinstance(pins, dict):
334
678
  raise ValueError("--pins must be a JSON object of {issuer: pubkey_b64}")
679
+ profile = None
680
+ if args.profile:
681
+ with open(args.profile, "r", encoding="utf-8") as handle:
682
+ profile = json.load(handle)
683
+ if not isinstance(profile, dict):
684
+ raise ValueError("--profile must be a JSON object of patient values")
335
685
  except (OSError, ValueError, json.JSONDecodeError) as exc:
336
686
  # Exit 2, never 0: a gate that could not read its inputs has not cleared anything.
337
687
  message = f"check_report_evidence: cannot evaluate — {exc}"
@@ -342,12 +692,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
342
692
  # Signature check FIRST in severity terms: a tampered record's evidence_class
343
693
  # is not evidence of anything, so its verdict above cannot be trusted either.
344
694
  findings = verify_ledger_signatures(facts, pins) + findings
695
+ # SLICE C: three independent judgements merged into the SAME findings list — the
696
+ # merge must not lose one, and the existing exit path below is unchanged.
697
+ population_findings, population_counts = evaluate_population(report_text, facts, profile)
698
+ findings = findings + population_findings + scan_relative_risk(report_text)
345
699
  if args.json:
346
700
  print(
347
701
  json.dumps(
348
702
  {
349
703
  "ok": not findings,
350
- "counts": counts,
704
+ "counts": dict(counts, **population_counts),
705
+ "populationChecked": profile is not None,
351
706
  "findings": [f.__dict__ for f in findings],
352
707
  "exitCode": 1 if findings else 0,
353
708
  },
@@ -356,6 +711,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
356
711
  )
357
712
  else:
358
713
  print(render(findings, counts))
714
+ print(render_population_and_risk(population_counts, profile is not None))
359
715
  return 1 if findings else 0
360
716
 
361
717