@dzhechkov/p-replicator 1.5.13 → 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.
- package/.dz-manifest.json +677 -0
- package/CHANGELOG.md +874 -0
- package/KNOWN_LIMITATIONS.md +327 -0
- package/MULTIPLATFORM_ROADMAP.md +239 -0
- package/README/eng/06_troubleshooting.md +1 -1
- package/README/eng/07_changelog.md +57 -0
- package/README/eng/README.md +2 -2
- package/README/ru/06_troubleshooting.md +1 -1
- package/README/ru/07_changelog.md +58 -0
- package/README/ru/README.md +2 -2
- package/README/ru/html/build.js +7 -7
- package/README/ru/html/index.html +31 -12
- package/README.md +59 -10
- package/package.json +10 -3
- package/sbom.json +1683 -0
- package/templates/.claude/skills/explore/SKILL.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +141 -11
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +363 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +297 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +277 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v1_fact.json +21 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +462 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py +170 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +784 -0
- package/templates/.claude/skills/problem-solver-enhanced/SKILL.md +1 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/SKILL.md +1 -1
- package/tests/e2e/lifecycle.test.js +973 -0
- package/tests/snapshot/baseline.json +125 -0
- package/tests/snapshot/templates.test.js +89 -0
- package/tests/snapshot/update-baseline.js +68 -0
- package/tests/unit/utils.test.js +636 -0
|
@@ -10,7 +10,7 @@ description: >
|
|
|
10
10
|
provide solutions until task is fully explored.
|
|
11
11
|
trust_tier: 1
|
|
12
12
|
trust_tier_label: "Structured"
|
|
13
|
-
trust_tier_path: "Run
|
|
13
|
+
trust_tier_path: "Run a BTO evaluation (see the skills-bto package) to promote to Tier 2"
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# Explore: Adaptive Task Clarification
|
|
@@ -3,7 +3,7 @@ name: goap-research-ed25519
|
|
|
3
3
|
description: GOAP research system with Ed25519 provenance and tamper-evidence under pinned trusted-issuer keys. Use for high-stakes research that needs cited sources, explicit confidence, signed audit trails, or cryptographic proof of who signed a fact. Ed25519 does not prove truthfulness or prevent hallucination.
|
|
4
4
|
trust_tier: 1
|
|
5
5
|
trust_tier_label: "Structured"
|
|
6
|
-
trust_tier_path: "Run
|
|
6
|
+
trust_tier_path: "Run a BTO evaluation (see the skills-bto package) to promote to Tier 2"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# GOAP Research with Ed25519 Provenance
|
|
@@ -28,6 +28,104 @@ Ed25519 provides cryptographic provenance and tamper-evidence under pinned trust
|
|
|
28
28
|
| `SELF_ATTESTED` | Researcher signature verifies against the embedded researcher key | The research record was not altered after self-signing | up to `0.60` |
|
|
29
29
|
| `UNVERIFIED` | Unknown issuer, missing pin, revoked key, key mismatch, malformed key, or invalid signature | No cryptographic provenance | `0.0` |
|
|
30
30
|
|
|
31
|
+
## Evidence Classes — the second, independent axis
|
|
32
|
+
|
|
33
|
+
Trust classes answer **"was this record altered after signing?"**. They say nothing about whether
|
|
34
|
+
anyone opened the source. That second question caused every content error in real operation, so it
|
|
35
|
+
gets its own axis. A fact can be `ISSUER_SIGNED` **and** `ASSERTED` — a cryptographically perfect
|
|
36
|
+
record of something recited from memory. That combination is legal, expressible, and the dangerous one.
|
|
37
|
+
|
|
38
|
+
| Evidence class | Requirement | What it proves | Ceiling |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `FETCH_VERIFIED` | `evidence_fetch.fetch_source()` performed the request and got a 2xx body | This script issued an HTTP request and received a body with this byte hash on this date | `1.0` |
|
|
41
|
+
| `LISTING_ONLY` | URL known from a listing, or a body supplied by hand, or the fetch failed | The URL is known; nobody opened it through this tool | `0.50` |
|
|
42
|
+
| `ASSERTED` | Stated from model memory, source never opened | Nothing about the source | `0.0` |
|
|
43
|
+
| *(absent)* | Fact predates this axis | Evidence is **unknown** — neither asserted nor verified | no ceiling of its own |
|
|
44
|
+
|
|
45
|
+
**`FETCH_VERIFIED` cannot be self-declared.** It is minted only by `create_fetched_fact()`, which
|
|
46
|
+
requires a `FetchRecord` — the byte hash, HTTP status and date of a request that actually happened.
|
|
47
|
+
The manual constructors (`create_listing_fact`, `create_asserted_fact`) have no way to produce it.
|
|
48
|
+
The restriction lives in the API shape, not in the author's discipline.
|
|
49
|
+
|
|
50
|
+
**Honest scope of `FETCH_VERIFIED`:** it means the bytes were received. It does **not** mean the
|
|
51
|
+
source is authoritative, that the claim follows from it, or that the reader understood it.
|
|
52
|
+
Provenance, not truth.
|
|
53
|
+
|
|
54
|
+
### The report gate
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python3 scripts/check_report_evidence.py --report report.md --facts facts.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`ASSERTED` claims must not appear in a report at all. `LISTING_ONLY` claims may appear only with a
|
|
61
|
+
visible marker next to the claim. This is an exit code, not advice — exit `1` on violation, exit `2`
|
|
62
|
+
when the inputs cannot be read (a gate that could not evaluate has cleared nothing).
|
|
63
|
+
|
|
64
|
+
## Self-learning (optional — needs `dz` on PATH)
|
|
65
|
+
|
|
66
|
+
The most valuable signal in research is the moment a conclusion turned out to be
|
|
67
|
+
WRONG. `scripts/learning_bridge.py` records those as METHOD lessons in the shared
|
|
68
|
+
`dz` store and replays them at the start of the next investigation.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python3 scripts/learning_bridge.py status # is the loop on at all?
|
|
72
|
+
python3 scripts/learning_bridge.py recall "transferrin saturation" # traps already caught
|
|
73
|
+
python3 scripts/learning_bridge.py teach "total testosterone is uninterpretable without SHBG"
|
|
74
|
+
python3 scripts/learning_bridge.py check "<candidate>" # privacy guard alone
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Recall at the START of an investigation** — before interpreting anything, ask what
|
|
78
|
+
this analyte has already fooled us with. **Teach at four moments**, not at random:
|
|
79
|
+
|
|
80
|
+
| Moment | Why it is the signal |
|
|
81
|
+
|---|---|
|
|
82
|
+
| a conclusion was RETRACTED | the single most valuable lesson available |
|
|
83
|
+
| a population check flipped a conclusion | the effect did not transfer, and now we know the shape |
|
|
84
|
+
| a preanalytical finding explained an alarming value | the value was an artifact; the rule generalises |
|
|
85
|
+
| an open question was closed | the answer, not the waiting |
|
|
86
|
+
|
|
87
|
+
**PRIVACY IS AN INVARIANT, NOT A PREFERENCE.** A lesson describes a METHOD, never a
|
|
88
|
+
person. `teach` REFUSES the wrong shape before any store is touched:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
good "a fast lowers total testosterone by roughly a third, LH down, FSH normal"
|
|
92
|
+
bad "testosterone 8.04 nmol/l in this patient turned out to be a fasting artifact"
|
|
93
|
+
bad "John Smith has HIV" — a capitalised word after the first is refused
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The accepted form has three properties, all checkable: **no digits** (spelled-out
|
|
97
|
+
values count — `eight point zero four` is a value), **no identifiers**, and **lower
|
|
98
|
+
case** after the first word. The last one replaced an attempt to detect names: a name
|
|
99
|
+
is any word, no list of them exists, and the attempt let `John Smith has HIV` through.
|
|
100
|
+
Lower case costs a keystroke and closes that hole. ALL-CAPS and mixed-case acronyms
|
|
101
|
+
(`SHBG`, `TSH`, `apoB`) are unaffected — they are how method lessons speak.
|
|
102
|
+
|
|
103
|
+
Pass `--allow-numbers` when the number IS the knowledge (a guideline threshold) and
|
|
104
|
+
you take responsibility for it. It never waves through a person beside a number.
|
|
105
|
+
|
|
106
|
+
The refusal is not bureaucracy — the method form is the more useful lesson: a reading
|
|
107
|
+
helps once, a rule helps every time. Honest scope: this is a SHAPE detector, not a
|
|
108
|
+
de-identifier. A lower-case sentence that names nobody and quotes no figure can still
|
|
109
|
+
describe one person, and no regular expression can see that. The shape rule plus the
|
|
110
|
+
four METHOD moments is what keeps the store clean; the guard alone is not a guarantee.
|
|
111
|
+
|
|
112
|
+
Without `dz` installed the package behaves exactly as before and says so once. An
|
|
113
|
+
older `dz` does **not** reject `--domain` — it ignores the flag and exits 0 — so recall
|
|
114
|
+
makes ONE call and detects the older CLI by the ABSENCE of the boost note in the
|
|
115
|
+
output, then states that results may mix other domains. A degraded loop that says so
|
|
116
|
+
beats a silent one. A real failure (non-zero exit) is reported as itself: proceeding
|
|
117
|
+
without prior lessons, never as "your CLI is old".
|
|
118
|
+
|
|
119
|
+
## Source Tiers
|
|
120
|
+
|
|
121
|
+
`source_tiers.classify_source(url)` assigns a class ceiling: A `0.90` (guideline bodies, WHO,
|
|
122
|
+
registries) · B `0.80` (peer-reviewed literature) · C `0.60` (preprints, trial registries) ·
|
|
123
|
+
D `0.40` (everything else, including unknown domains). A tier is a claim about the CLASS a domain
|
|
124
|
+
belongs to — it is **not** a cryptographic statement and must never be confused with issuer pinning.
|
|
125
|
+
|
|
126
|
+
`source_tiers.is_stale(source_date, kind)` flags sources past their TTL. A **missing** `source_date`
|
|
127
|
+
is flagged too: freshness that cannot be established is not freshness.
|
|
128
|
+
|
|
31
129
|
## Signed Message
|
|
32
130
|
|
|
33
131
|
`sign_fact()` and `verify_fact()` use the same deterministic JSON message containing:
|
|
@@ -39,6 +137,16 @@ Ed25519 provides cryptographic provenance and tamper-evidence under pinned trust
|
|
|
39
137
|
- `timestamp`
|
|
40
138
|
- optional `research_context` / nonce
|
|
41
139
|
|
|
140
|
+
**Schema v2** additionally covers `evidence_class`, `fetch_date` and `source_date`, plus a
|
|
141
|
+
`"schema": "fact-v2"` self-description marker. Version is chosen by the presence of
|
|
142
|
+
`evidence_class`: legacy facts keep verifying against the six-field v1 message forever.
|
|
143
|
+
|
|
144
|
+
Because the three evidence fields are part of the signed text, **both** tamper directions fail:
|
|
145
|
+
stripping `evidence_class` makes the verifier build the v1 text, which no longer matches the signed
|
|
146
|
+
v2 text; adding it to a legacy fact makes it build the v2 text, which does not match the signed v1
|
|
147
|
+
text. (The marker is self-description and the growth point for a future v3 — it is not itself the
|
|
148
|
+
protection; a discrimination run proved that.)
|
|
149
|
+
|
|
42
150
|
Changing the issuer or moving the source URL after signing invalidates the signature. The code signs the raw canonical message bytes; Ed25519 performs its own internal hashing. Do not pre-hash with SHA-512 before signing.
|
|
43
151
|
|
|
44
152
|
## Pinned Issuers
|
|
@@ -77,25 +185,43 @@ Reordering, substituting, editing, relabeling, or moving a signed fact fails ver
|
|
|
77
185
|
## Confidence Formula
|
|
78
186
|
|
|
79
187
|
```
|
|
80
|
-
confidence =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
188
|
+
confidence = min(
|
|
189
|
+
trust_ceiling, # UNVERIFIED 0.0 · SELF_ATTESTED 0.60 · ISSUER_SIGNED 0.95
|
|
190
|
+
evidence_ceiling, # FETCH_VERIFIED 1.0 · LISTING_ONLY 0.50 · ASSERTED 0.0 · absent → no cap
|
|
191
|
+
tier_ceiling, # A 0.90 · B 0.80 · C 0.60 · D 0.40
|
|
192
|
+
)
|
|
84
193
|
```
|
|
85
194
|
|
|
195
|
+
The **weakest link decides**, never the average. An `ISSUER_SIGNED` fact that nobody read is capped
|
|
196
|
+
at `0.0` by its evidence class — which is the whole point of the second axis.
|
|
197
|
+
|
|
86
198
|
Invalid signatures are rejected with confidence `0.0`; they are never a recoverable `0.5` penalty.
|
|
87
199
|
|
|
88
200
|
## Research Workflow
|
|
89
201
|
|
|
90
202
|
1. Define the research goal and required evidence threshold.
|
|
91
203
|
2. Configure pinned issuer keys when issuer-grade provenance is required.
|
|
92
|
-
3. Search and
|
|
204
|
+
3. Search and locate candidate sources.
|
|
93
205
|
4. Extract claims and source URLs.
|
|
94
|
-
5.
|
|
95
|
-
|
|
206
|
+
5. **Fetch each source through the tool, not by hand:**
|
|
207
|
+
```python
|
|
208
|
+
from evidence_fetch import fetch_source, FetchRecord
|
|
209
|
+
record = fetch_source(url) # real HTTP, stdlib only
|
|
210
|
+
if isinstance(record, FetchRecord):
|
|
211
|
+
fact = verifier.create_fetched_fact(claim, record, issuer, source_date="2024-03-01")
|
|
212
|
+
else: # offline, 404, oversize, refused scheme…
|
|
213
|
+
fact = verifier.create_listing_fact(claim, url, reason=record.reason)
|
|
214
|
+
```
|
|
215
|
+
A claim you never opened a source for is `verifier.create_asserted_fact(claim)` — record it
|
|
216
|
+
honestly and let the gate refuse it. Never hand-label a class you did not earn.
|
|
217
|
+
6. Add facts to the research ledger; issuer-signed facts only when a pinned issuer key actually
|
|
218
|
+
signed the message.
|
|
96
219
|
7. Verify facts and citation chains.
|
|
97
220
|
8. Cross-check claims through ordinary source evaluation.
|
|
98
|
-
9.
|
|
221
|
+
9. **Run the report gate before delivering:**
|
|
222
|
+
`python3 scripts/check_report_evidence.py --report <report.md> --facts <facts.json>` — exit 0 required.
|
|
223
|
+
10. Report confidence, evidence-class mix, unsigned claims, rejected signatures, and limitations
|
|
224
|
+
explicitly.
|
|
99
225
|
|
|
100
226
|
## Output Expectations
|
|
101
227
|
|
|
@@ -103,10 +229,14 @@ Reports should include:
|
|
|
103
229
|
|
|
104
230
|
- Research objective and GOAP plan executed.
|
|
105
231
|
- Findings with source URLs.
|
|
106
|
-
- Verification status per claim (`ISSUER_SIGNED
|
|
232
|
+
- Verification status per claim on BOTH axes: trust (`ISSUER_SIGNED` / `SELF_ATTESTED` /
|
|
233
|
+
`UNVERIFIED`) and evidence (`FETCH_VERIFIED` / `LISTING_ONLY` / `ASSERTED` / unknown-legacy).
|
|
234
|
+
- The evidence-class mix as a count — the share of `ASSERTED` should be zero.
|
|
235
|
+
- A visible marker next to every `LISTING_ONLY` claim ("source not opened directly — verify").
|
|
107
236
|
- Chain integrity result when citation chains are used.
|
|
108
237
|
- Unsigned and rejected claims.
|
|
109
|
-
- Explicit caveat that cryptographic provenance is not truth verification
|
|
238
|
+
- Explicit caveat that cryptographic provenance is not truth verification, and that
|
|
239
|
+
`FETCH_VERIFIED` means the bytes arrived — not that the source is right.
|
|
110
240
|
|
|
111
241
|
## Implementation
|
|
112
242
|
|
|
@@ -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())
|