@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.
- package/.dz-manifest.json +173 -65
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +41 -18
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +347 -77
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- 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/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +51 -34
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- 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
|
|