@dzhechkov/p-replicator 1.5.14 → 1.5.15

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.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Report evidence gate (FR-4) — the rule that makes the evidence axis load-bearing.
4
+
5
+ ASSERTED facts do not reach a report at all.
6
+ LISTING_ONLY facts reach it only with an explicit marker next to the claim.
7
+
8
+ This is an exit code, not advice. The lesson it encodes: a checklist item is
9
+ satisfied by writing it; only an executable check refuses. The whole point of the
10
+ evidence axis is lost if "don't include unread claims" stays a sentence in
11
+ SKILL.md that a tired model skips at 2am.
12
+
13
+ Usage:
14
+ python3 check_report_evidence.py --report report.md --facts facts.json [--json]
15
+
16
+ Exit codes:
17
+ 0 clean — no ASSERTED used, every used LISTING_ONLY carries a marker
18
+ 1 violation — named facts printed
19
+ 2 usage/IO error (a gate that cannot read its inputs must not report "clean")
20
+
21
+ HONEST SCOPE: this gate proves the report does not LEAN ON unread sources. It
22
+ does not prove the cited sources support the claims, and it cannot judge legacy
23
+ facts that predate the evidence axis — those are counted and NAMED separately,
24
+ never silently folded into "clean".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import re
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
35
+
36
+ EVIDENCE_FETCH_VERIFIED = "FETCH_VERIFIED"
37
+ EVIDENCE_LISTING_ONLY = "LISTING_ONLY"
38
+ EVIDENCE_ASSERTED = "ASSERTED"
39
+
40
+ # A LISTING_ONLY claim is admissible only if the report says so near the claim.
41
+ # Both spellings are accepted because the report may be written in either
42
+ # language; the marker must be VISIBLE to a reader, not a hidden attribute.
43
+ LISTING_MARKERS = (
44
+ "LISTING_ONLY",
45
+ "listing-only",
46
+ "не открывалась",
47
+ "карточка не открывалась",
48
+ "из поисковой выдачи",
49
+ "not opened directly",
50
+ "from a search listing",
51
+ )
52
+
53
+ # How far from the claim's mention the marker may sit. A marker in the appendix
54
+ # does not warn the reader of a sentence on page 2.
55
+ MARKER_WINDOW_CHARS = 400
56
+
57
+
58
+ @dataclass
59
+ class Finding:
60
+ kind: str
61
+ claim: str
62
+ source_url: str
63
+ detail: str
64
+
65
+
66
+ def _load_facts(path: str) -> List[Dict[str, Any]]:
67
+ """Load the ledger. Malformed entries RAISE rather than being skipped: a
68
+ silently-dropped record is a record the gate did not judge, and this gate must
69
+ never clear a report it could not fully read (Codex QE #9)."""
70
+ with open(path, "r", encoding="utf-8") as handle:
71
+ data = json.load(handle)
72
+ if isinstance(data, dict):
73
+ for key in ("facts", "claims", "items"):
74
+ if isinstance(data.get(key), list):
75
+ data = data[key]
76
+ break
77
+ else:
78
+ raise ValueError("facts JSON object has no 'facts'/'claims'/'items' array")
79
+ if not isinstance(data, list):
80
+ raise ValueError("facts JSON must be a list or an object containing one")
81
+ bad = [i for i, item in enumerate(data) if not isinstance(item, dict)]
82
+ if bad:
83
+ raise ValueError(f"facts ledger has non-object entries at index/indices {bad} — refusing to judge a partial ledger")
84
+ if not data:
85
+ # An EMPTY ledger against a non-empty report is the total bypass: pass `[]`
86
+ # and any medical report exits 0 (Codex QE #2). Emptiness is unevaluable,
87
+ # not clean.
88
+ raise ValueError("facts ledger is empty — a report with no recorded facts cannot be cleared, only unevaluated")
89
+ return data
90
+
91
+
92
+ # Tokens that carry a claim's identity. NUMBERS COUNT (Codex QE #4): "LDL rose
93
+ # 40%" vs "LDL increased 40%" shares almost no long words but shares the number,
94
+ # and dosage/threshold claims are precisely where a silent miss is dangerous.
95
+ _TOKEN_RE = re.compile(r"[\wЀ-ӿ]{4,}|\d+(?:[.,]\d+)?", re.UNICODE)
96
+ _STOPWORDS = {
97
+ "that", "this", "with", "from", "these", "those", "have", "been", "were", "which", "their",
98
+ "there", "about", "would", "could", "should", "than", "then", "when", "what", "into",
99
+ "что", "этот", "этого", "было", "были", "который", "которая", "если", "также", "более",
100
+ }
101
+
102
+
103
+ def _significant_words(text: str, limit: int = 12) -> List[str]:
104
+ """Identity tokens of a claim: numerals plus content words, stopwords dropped.
105
+
106
+ The first draft required 5+ letters, ignored digits and kept only 8 tokens, so
107
+ ordinary paraphrases walked through. Detection here is deliberately GENEROUS —
108
+ an over-detection is a visible, arguable false alarm; a miss lets an unread
109
+ claim into a medical document silently.
110
+ """
111
+ tokens = [t for t in _TOKEN_RE.findall((text or "").lower()) if t not in _STOPWORDS]
112
+ seen: List[str] = []
113
+ for token in tokens:
114
+ if token not in seen:
115
+ seen.append(token)
116
+ if len(seen) >= limit:
117
+ break
118
+ return seen
119
+
120
+
121
+ def claim_positions(report_text: str, claim: str) -> List[int]:
122
+ """Offsets where the claim appears to be used.
123
+
124
+ Exact substring first; otherwise a word-overlap probe, because a report
125
+ paraphrases claims rather than pasting them. Deliberately generous: a MISSED
126
+ usage would let an ASSERTED claim through, which is the failure this gate
127
+ exists to prevent, so we prefer over-detection (a false alarm is visible and
128
+ arguable; a miss is silent).
129
+ """
130
+ text_lower = report_text.lower()
131
+ claim_lower = (claim or "").strip().lower()
132
+ if not claim_lower:
133
+ return []
134
+ positions = [m.start() for m in re.finditer(re.escape(claim_lower), text_lower)]
135
+ if positions:
136
+ return positions
137
+ words = _significant_words(claim_lower)
138
+ if len(words) < 3:
139
+ return []
140
+ # Scan a SLIDING WINDOW over the whole text, not per physical line: markdown
141
+ # wrapping split a claim across lines and hid it from the line-based scan.
142
+ hits: List[int] = []
143
+ window, step = 360, 120
144
+ threshold = max(3, int(len(words) * 0.5))
145
+ position = 0
146
+ while position < max(len(text_lower), 1):
147
+ chunk = text_lower[position:position + window]
148
+ present = [w for w in words if w in chunk]
149
+ if len(present) >= threshold:
150
+ # Report where the claim ACTUALLY sits, not where the window started:
151
+ # marker proximity is measured from this offset, and using the window
152
+ # start put the measurement up to a full window away from the text it
153
+ # was supposed to be next to (Codex QE r2).
154
+ earliest = min(chunk.find(w) for w in present)
155
+ hits.append(position + max(earliest, 0))
156
+ position += window # one hit per window; neighbours would double-count
157
+ else:
158
+ position += step
159
+ return hits
160
+
161
+
162
+ def has_marker_near(report_text: str, position: int, window: int = MARKER_WINDOW_CHARS) -> bool:
163
+ start = max(0, position - window)
164
+ end = min(len(report_text), position + window)
165
+ neighbourhood = report_text[start:end].lower()
166
+ return any(marker.lower() in neighbourhood for marker in LISTING_MARKERS)
167
+
168
+
169
+ def verify_ledger_signatures(facts: Sequence[Dict[str, Any]], pins: Optional[Dict[str, str]] = None) -> List[Finding]:
170
+ """Reject records whose signature does not cover their own contents.
171
+
172
+ Without this the gate reads `evidence_class` as a plain unsigned JSON string:
173
+ flipping a signed ASSERTED record to FETCH_VERIFIED in a text editor made the
174
+ gate pass while `verify_fact()` would have rejected it (Codex QE #3). The
175
+ signature protection and the report protection were not composed — each was
176
+ sound alone and the pair had a hole between them.
177
+
178
+ Verification is best-effort by design: if the verifier module or its crypto
179
+ backend is unavailable, that is reported as a NAMED limitation, never as a
180
+ pass — a gate that could not check must not imply it did.
181
+ """
182
+ try:
183
+ import ed25519_verifier as ev
184
+ except Exception as exc:
185
+ return [Finding(kind="SIGNATURES_UNCHECKED", claim="(whole ledger)", source_url="",
186
+ detail=f"cannot import ed25519_verifier ({exc}) — evidence classes were read UNVERIFIED")]
187
+ if getattr(ev, "CRYPTO_BACKEND", None) is None:
188
+ return [Finding(kind="SIGNATURES_UNCHECKED", claim="(whole ledger)", source_url="",
189
+ detail="no Ed25519 backend installed — evidence classes were read UNVERIFIED")]
190
+
191
+ verifier = ev.Ed25519Verifier()
192
+ for issuer, pubkey in (pins or {}).items():
193
+ try:
194
+ verifier.registry.add(issuer, pubkey)
195
+ except Exception:
196
+ pass
197
+ out: List[Finding] = []
198
+ for fact in facts:
199
+ if not fact.get("signature"):
200
+ continue # unsigned ledgers predate signing; the class fields are still judged
201
+ trust = fact.get("trust_class")
202
+ if trust == "ISSUER_SIGNED" and fact.get("issuer") not in (pins or {}):
203
+ # REGRESSION GUARD (Codex QE r2, critical): the first version built an
204
+ # EMPTY registry, so every legitimate ISSUER_SIGNED fact failed as
205
+ # "unknown issuer" and was reported as TAMPERED. Accusing a sound record
206
+ # of forgery is worse than not checking it: it teaches the reader to
207
+ # ignore the gate. Without pins we say what we could not check.
208
+ out.append(Finding(kind="SIGNATURES_UNCHECKED", claim=str(fact.get("claim", ""))[:120],
209
+ source_url=str(fact.get("source_url", "")),
210
+ detail=("ISSUER_SIGNED fact but no pinned key was supplied (--pins) — "
211
+ "its signature was NOT checked; this is not an accusation")))
212
+ continue
213
+ try:
214
+ result = verifier.verify_fact(ev.SignedFact.from_dict(fact))
215
+ except Exception as exc:
216
+ out.append(Finding(kind="UNVERIFIABLE_FACT", claim=str(fact.get("claim", ""))[:120],
217
+ source_url=str(fact.get("source_url", "")), detail=f"verification raised {exc}"))
218
+ continue
219
+ if not result.verified:
220
+ out.append(Finding(kind="TAMPERED_FACT", claim=str(fact.get("claim", ""))[:120],
221
+ source_url=str(fact.get("source_url", "")),
222
+ detail=f"signature does not verify ({result.error}) — its evidence_class cannot be trusted"))
223
+ return out
224
+
225
+
226
+ def evaluate(report_text: str, facts: Sequence[Dict[str, Any]]) -> Tuple[List[Finding], Dict[str, int]]:
227
+ findings: List[Finding] = []
228
+ counts = {EVIDENCE_FETCH_VERIFIED: 0, EVIDENCE_LISTING_ONLY: 0, EVIDENCE_ASSERTED: 0, "UNKNOWN_LEGACY": 0}
229
+
230
+ for fact in facts:
231
+ claim = str(fact.get("claim", ""))
232
+ url = str(fact.get("source_url", ""))
233
+ evidence = fact.get("evidence_class")
234
+ bucket = evidence if evidence in counts else ("UNKNOWN_LEGACY" if evidence is None else "UNRECOGNISED")
235
+ counts[bucket] = counts.get(bucket, 0) + 1
236
+
237
+ positions = claim_positions(report_text, claim)
238
+ if not positions:
239
+ continue # recorded but not used in this report — not this gate's business
240
+
241
+ if evidence == EVIDENCE_ASSERTED:
242
+ findings.append(
243
+ Finding(
244
+ kind="ASSERTED_IN_REPORT",
245
+ claim=claim,
246
+ source_url=url,
247
+ detail="stated from memory, source never opened — must not appear in a report at all",
248
+ )
249
+ )
250
+ elif evidence == EVIDENCE_LISTING_ONLY:
251
+ # EVERY occurrence must be marked, not just one (Codex QE #5): a claim
252
+ # marked on page 1 and repeated bare on page 4 warns nobody on page 4.
253
+ unmarked = [pos for pos in positions if not has_marker_near(report_text, pos)]
254
+ if unmarked:
255
+ findings.append(
256
+ Finding(
257
+ kind="UNMARKED_LISTING_ONLY",
258
+ claim=claim,
259
+ source_url=url,
260
+ detail=(
261
+ f"{len(unmarked)} of {len(positions)} occurrence(s) unmarked; the source was never "
262
+ f"opened directly and the report must say so next to EACH mention "
263
+ f"(within {MARKER_WINDOW_CHARS} chars)"
264
+ ),
265
+ )
266
+ )
267
+ elif evidence not in (EVIDENCE_FETCH_VERIFIED, None):
268
+ # An unrecognised or whitespace-damaged class ("ASSERTED ", "asserted",
269
+ # null) must NOT read as legacy-and-therefore-fine (Codex QE #9). A
270
+ # ledger we cannot interpret is a ledger we cannot clear.
271
+ findings.append(
272
+ Finding(
273
+ kind="UNRECOGNISED_EVIDENCE_CLASS",
274
+ claim=claim,
275
+ source_url=url,
276
+ detail=f"evidence_class {evidence!r} is not one of FETCH_VERIFIED/LISTING_ONLY/ASSERTED",
277
+ )
278
+ )
279
+ return findings, counts
280
+
281
+
282
+ def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
283
+ lines: List[str] = []
284
+ total = sum(counts.values())
285
+ lines.append("report evidence gate")
286
+ lines.append(
287
+ " facts: {total} — FETCH_VERIFIED {f} / LISTING_ONLY {l} / ASSERTED {a} / legacy-unknown {u}".format(
288
+ total=total,
289
+ f=counts[EVIDENCE_FETCH_VERIFIED],
290
+ l=counts[EVIDENCE_LISTING_ONLY],
291
+ a=counts[EVIDENCE_ASSERTED],
292
+ u=counts["UNKNOWN_LEGACY"],
293
+ )
294
+ )
295
+ if counts["UNKNOWN_LEGACY"]:
296
+ lines.append(
297
+ " NOTE: {n} fact(s) predate the evidence axis — this gate cannot judge them, "
298
+ "and does not pretend to.".format(n=counts["UNKNOWN_LEGACY"])
299
+ )
300
+ if not findings:
301
+ lines.append(" PASS — no ASSERTED claim used; every used LISTING_ONLY claim is marked.")
302
+ else:
303
+ lines.append(" FAIL — {n} violation(s):".format(n=len(findings)))
304
+ for finding in findings:
305
+ lines.append(" [{kind}] {claim}".format(kind=finding.kind, claim=finding.claim[:120]))
306
+ lines.append(" source: {url}".format(url=finding.source_url or "(none)"))
307
+ lines.append(" {detail}".format(detail=finding.detail))
308
+ lines.append(
309
+ " scope: proves the report does not lean on unread sources AND that the ledger's evidence "
310
+ "classes carry intact signatures. It does NOT prove the cited sources support the claims, "
311
+ "and it can only judge claims it recognises as used — paraphrase detection is deliberately "
312
+ "over-eager, because a missed unread claim is silent while a false alarm is arguable."
313
+ )
314
+ return "\n".join(lines)
315
+
316
+
317
+ def main(argv: Optional[Sequence[str]] = None) -> int:
318
+ parser = argparse.ArgumentParser(description="Refuse reports that lean on unread sources.")
319
+ parser.add_argument("--report", required=True, help="path to the report markdown")
320
+ parser.add_argument("--facts", required=True, help="path to facts.json")
321
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
322
+ parser.add_argument("--pins", help="JSON file of {issuer: pubkey_b64} so ISSUER_SIGNED facts can be checked")
323
+ args = parser.parse_args(argv)
324
+
325
+ try:
326
+ with open(args.report, "r", encoding="utf-8") as handle:
327
+ report_text = handle.read()
328
+ facts = _load_facts(args.facts)
329
+ pins = None
330
+ if args.pins:
331
+ with open(args.pins, "r", encoding="utf-8") as handle:
332
+ pins = json.load(handle)
333
+ if not isinstance(pins, dict):
334
+ raise ValueError("--pins must be a JSON object of {issuer: pubkey_b64}")
335
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
336
+ # Exit 2, never 0: a gate that could not read its inputs has not cleared anything.
337
+ message = f"check_report_evidence: cannot evaluate — {exc}"
338
+ print(json.dumps({"error": str(exc), "exitCode": 2}) if args.json else message, file=sys.stderr)
339
+ return 2
340
+
341
+ findings, counts = evaluate(report_text, facts)
342
+ # Signature check FIRST in severity terms: a tampered record's evidence_class
343
+ # is not evidence of anything, so its verdict above cannot be trusted either.
344
+ findings = verify_ledger_signatures(facts, pins) + findings
345
+ if args.json:
346
+ print(
347
+ json.dumps(
348
+ {
349
+ "ok": not findings,
350
+ "counts": counts,
351
+ "findings": [f.__dict__ for f in findings],
352
+ "exitCode": 1 if findings else 0,
353
+ },
354
+ ensure_ascii=False,
355
+ )
356
+ )
357
+ else:
358
+ print(render(findings, counts))
359
+ return 1 if findings else 0
360
+
361
+
362
+ if __name__ == "__main__":
363
+ raise SystemExit(main())