@dzhechkov/p-replicator 1.5.16 → 1.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +51 -23
- package/README.md +5 -3
- package/package.json +1 -1
- package/sbom.json +106 -36
- 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 +891 -280
- 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 +970 -306
- 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/tests/snapshot/baseline.json +24 -9
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Relative risk never travels alone.
|
|
4
|
+
|
|
5
|
+
"21× higher risk of heart attack" is 1 excess case per 1394 people.
|
|
6
|
+
"the risk doubles" is 4 per 1000 over 25 years.
|
|
7
|
+
|
|
8
|
+
Both sentences are true. Only one of each pair is interpretable, and the
|
|
9
|
+
uninterpretable one is the one that gets quoted. This module makes the
|
|
10
|
+
interpretable half STRUCTURALLY non-optional: `RiskStatement(relative, absolute)`
|
|
11
|
+
has two required positional arguments, and `absolute` is either a real
|
|
12
|
+
`AbsoluteEffect` or an explicit `UnknownBaseline(reason=…)`. A caller with no
|
|
13
|
+
baseline data cannot omit the absolute half — it must SAY the baseline is unknown,
|
|
14
|
+
and that sentence is printed in the slot where the number would have been:
|
|
15
|
+
|
|
16
|
+
BASELINE RISK NOT ESTABLISHED
|
|
17
|
+
|
|
18
|
+
WHAT THIS MODULE DOES NOT CLAIM (D-19, ADR-002 §3)
|
|
19
|
+
It cannot stop an agent from writing «в 21 раз выше» in its own free prose. The
|
|
20
|
+
typed path here is the guarantee (`test_risk_absolute.py::
|
|
21
|
+
test_relative_risk_cannot_be_emitted_by_any_path`); the report-gate scan in
|
|
22
|
+
`check_report_evidence.py` is a BELT over prose the constructor never sees, it
|
|
23
|
+
matches FORMATS not meaning, and it may never be cited as evidence that the
|
|
24
|
+
property holds.
|
|
25
|
+
|
|
26
|
+
Stdlib only. Pure: no I/O, no network, no global state.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any, Optional, Union
|
|
34
|
+
|
|
35
|
+
# Grammar: the shapes a source may report an effect in. A new study reports a new
|
|
36
|
+
# NUMBER, not a new shape — so this list is grammar, not content.
|
|
37
|
+
RELATIVE_KINDS = ("RR", "HR", "OR", "IRR", "fold-change", "percent-change")
|
|
38
|
+
|
|
39
|
+
# ALLOWLIST OVER EMITTED STRUCTURE. `render_risk()` builds its output solely by
|
|
40
|
+
# iterating this tuple; a field added to RiskStatement without being added here is
|
|
41
|
+
# simply not printed — the safe direction.
|
|
42
|
+
RISK_KEYS = ("relative", "absolute", "nnt", "horizon", "population_match")
|
|
43
|
+
|
|
44
|
+
BASELINE_UNKNOWN_TEXT = "BASELINE RISK NOT ESTABLISHED"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RiskError(ValueError):
|
|
48
|
+
"""Every refusal in this module (a ValueError subclass, so existing handlers work)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RelativeEffect:
|
|
53
|
+
"""A RATIO of two risks — and therefore not a risk.
|
|
54
|
+
|
|
55
|
+
Deliberately has NO `__str__`, NO `__format__` and no accessor returning
|
|
56
|
+
user-facing prose. The value is reachable in output only through a
|
|
57
|
+
`RiskStatement` that already carries its absolute counterpart (ADR-002 §2).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
kind: str
|
|
61
|
+
value: float
|
|
62
|
+
ci: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if self.kind not in RELATIVE_KINDS:
|
|
66
|
+
raise RiskError(f"relative kind {self.kind!r} must be one of {RELATIVE_KINDS}")
|
|
67
|
+
try:
|
|
68
|
+
value = float(self.value)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
raise RiskError(f"relative value {self.value!r} is not a number") from None
|
|
71
|
+
if not math.isfinite(value):
|
|
72
|
+
raise RiskError("relative value must be finite")
|
|
73
|
+
object.__setattr__(self, "value", value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class AbsoluteEffect:
|
|
78
|
+
"""Events over a stated denominator, over a stated horizon. NOT a percentage
|
|
79
|
+
with no denominator."""
|
|
80
|
+
|
|
81
|
+
control_events: float
|
|
82
|
+
control_denominator: float
|
|
83
|
+
treated_events: Optional[float] = None
|
|
84
|
+
treated_denominator: Optional[float] = None
|
|
85
|
+
horizon: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
for name in ("control_events", "control_denominator", "treated_events", "treated_denominator"):
|
|
89
|
+
value = getattr(self, name)
|
|
90
|
+
if value is None:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
number = float(value)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
raise RiskError(f"{name} must be a number, got {value!r}") from None
|
|
96
|
+
if not math.isfinite(number) or number < 0:
|
|
97
|
+
raise RiskError(f"{name} must be a finite, non-negative number, got {value!r}")
|
|
98
|
+
object.__setattr__(self, name, number)
|
|
99
|
+
if self.control_denominator <= 0:
|
|
100
|
+
raise RiskError("control_denominator must be > 0 — a rate needs a denominator")
|
|
101
|
+
if (self.treated_events is None) != (self.treated_denominator is None):
|
|
102
|
+
raise RiskError(
|
|
103
|
+
"treated_events and treated_denominator must be supplied together — half a treated "
|
|
104
|
+
"arm cannot produce an absolute risk difference"
|
|
105
|
+
)
|
|
106
|
+
if self.treated_denominator is not None and self.treated_denominator <= 0:
|
|
107
|
+
raise RiskError("treated_denominator must be > 0")
|
|
108
|
+
|
|
109
|
+
def control_rate(self) -> float:
|
|
110
|
+
return self.control_events / self.control_denominator
|
|
111
|
+
|
|
112
|
+
def treated_rate(self) -> Optional[float]:
|
|
113
|
+
if self.treated_events is None or self.treated_denominator is None:
|
|
114
|
+
return None
|
|
115
|
+
return self.treated_events / self.treated_denominator
|
|
116
|
+
|
|
117
|
+
def render(self) -> str:
|
|
118
|
+
if self.treated_rate() is None:
|
|
119
|
+
body = f"{_num(self.control_events)} per {_num(self.control_denominator)}"
|
|
120
|
+
else:
|
|
121
|
+
body = (f"{_num(self.control_events)} per {_num(self.control_denominator)} (control) vs "
|
|
122
|
+
f"{_num(self.treated_events)} per {_num(self.treated_denominator)} (treated)")
|
|
123
|
+
return body + (f" over {self.horizon}" if self.horizon else "")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class UnknownBaseline:
|
|
128
|
+
"""The source reports no control-arm event rate — AND WE SAY SO, with a reason.
|
|
129
|
+
|
|
130
|
+
`reason=""` raises. A degradation whose cause is not recorded is
|
|
131
|
+
indistinguishable from a bug (the same rule as `create_listing_fact(reason=…)`).
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
reason: str
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
if not isinstance(self.reason, str) or not self.reason.strip():
|
|
138
|
+
raise RiskError(
|
|
139
|
+
"UnknownBaseline requires a non-empty reason — 'how many times' is known, "
|
|
140
|
+
"'how many people' is not, and the reader must be told which"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def render(self) -> str:
|
|
144
|
+
return f"{BASELINE_UNKNOWN_TEXT} — {self.reason.strip()}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class NNT:
|
|
149
|
+
"""Number Needed to Treat/Harm. Exactly one of `value` / `not_applicable_reason`."""
|
|
150
|
+
|
|
151
|
+
value: Optional[float] = None
|
|
152
|
+
not_applicable_reason: Optional[str] = None
|
|
153
|
+
horizon: Optional[str] = None
|
|
154
|
+
|
|
155
|
+
def __post_init__(self) -> None:
|
|
156
|
+
has_value = self.value is not None
|
|
157
|
+
has_reason = self.not_applicable_reason is not None and str(self.not_applicable_reason).strip() != ""
|
|
158
|
+
if has_value == has_reason:
|
|
159
|
+
raise RiskError(
|
|
160
|
+
"NNT carries exactly one of value / not_applicable_reason — "
|
|
161
|
+
"an unnamed missing NNT is the thing this class exists to prevent"
|
|
162
|
+
)
|
|
163
|
+
if has_value:
|
|
164
|
+
try:
|
|
165
|
+
number = float(self.value)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
raise RiskError(f"NNT value {self.value!r} is not a number") from None
|
|
168
|
+
if not math.isfinite(number) or number <= 0:
|
|
169
|
+
raise RiskError(
|
|
170
|
+
f"NNT value must be finite and > 0, got {self.value!r}. An infinite NNT is a "
|
|
171
|
+
f"NAMED not_applicable_reason, never a number"
|
|
172
|
+
)
|
|
173
|
+
object.__setattr__(self, "value", number)
|
|
174
|
+
|
|
175
|
+
def render(self) -> str:
|
|
176
|
+
if self.value is None:
|
|
177
|
+
return f"n/a — {str(self.not_applicable_reason).strip()}"
|
|
178
|
+
text = _num(self.value)
|
|
179
|
+
return text + (f" over {self.horizon}" if self.horizon else "")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
AbsoluteHalf = Union[AbsoluteEffect, UnknownBaseline]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class RiskStatement:
|
|
187
|
+
"""`relative` and `absolute` are REQUIRED POSITIONAL arguments. There is no
|
|
188
|
+
default, and no `None` path (ADR-002 §1, D-11)."""
|
|
189
|
+
|
|
190
|
+
relative: RelativeEffect
|
|
191
|
+
absolute: AbsoluteHalf
|
|
192
|
+
intervention: bool = False
|
|
193
|
+
nnt: Optional[NNT] = None
|
|
194
|
+
horizon: Optional[str] = None
|
|
195
|
+
population_match: Optional[str] = None
|
|
196
|
+
|
|
197
|
+
def __post_init__(self) -> None:
|
|
198
|
+
if not isinstance(self.relative, RelativeEffect):
|
|
199
|
+
raise TypeError(f"relative must be a RelativeEffect, got {type(self.relative).__name__}")
|
|
200
|
+
if not isinstance(self.absolute, (AbsoluteEffect, UnknownBaseline)):
|
|
201
|
+
raise TypeError(
|
|
202
|
+
f"absolute must be an AbsoluteEffect or an explicit UnknownBaseline(reason=…), got "
|
|
203
|
+
f"{type(self.absolute).__name__}. A relative effect may not travel alone (DC-2)."
|
|
204
|
+
)
|
|
205
|
+
if self.nnt is not None and not isinstance(self.nnt, NNT):
|
|
206
|
+
raise TypeError(f"nnt must be an NNT, got {type(self.nnt).__name__}")
|
|
207
|
+
if self.intervention and self.nnt is None:
|
|
208
|
+
raise RiskError(
|
|
209
|
+
"intervention=True requires an NNT — computed from the absolute figures, or a "
|
|
210
|
+
"named not_applicable_reason (D-13)"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _num(value: float) -> str:
|
|
215
|
+
number = float(value)
|
|
216
|
+
if abs(number - round(number)) < 1e-9:
|
|
217
|
+
return str(int(round(number)))
|
|
218
|
+
return f"{number:.4g}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def nnt_from_excess(excess_events: float, denominator: float, horizon: Optional[str] = None) -> NNT:
|
|
222
|
+
"""`NNT = M / N` from an "N excess cases per M people" figure (FR-7, FR-8).
|
|
223
|
+
|
|
224
|
+
"1 excess case per 1394" → 1394. "4 per 1000 over 25 years" → 250 over 25 years.
|
|
225
|
+
Never raises for an unusable input, never returns Infinity or 0: an NNT that
|
|
226
|
+
cannot be computed comes back as a NAMED reason (D-15).
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
excess = float(excess_events)
|
|
230
|
+
total = float(denominator)
|
|
231
|
+
except (TypeError, ValueError):
|
|
232
|
+
return NNT(not_applicable_reason=f"non-numeric input ({excess_events!r} per {denominator!r})",
|
|
233
|
+
horizon=horizon)
|
|
234
|
+
if not math.isfinite(excess) or not math.isfinite(total):
|
|
235
|
+
return NNT(not_applicable_reason="non-finite input", horizon=horizon)
|
|
236
|
+
if total <= 0:
|
|
237
|
+
return NNT(not_applicable_reason="denominator is not positive — no population to count over",
|
|
238
|
+
horizon=horizon)
|
|
239
|
+
if excess == 0:
|
|
240
|
+
return NNT(not_applicable_reason="no excess cases reported — the absolute difference is zero, "
|
|
241
|
+
"so no finite NNT exists", horizon=horizon)
|
|
242
|
+
return NNT(value=abs(total / excess), horizon=horizon)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def nnt_from_absolute(absolute: AbsoluteHalf, horizon: Optional[str] = None) -> NNT:
|
|
246
|
+
"""`NNT = 1 / |ARC − ART|` — COMPUTED from the two arms, never supplied as prose (D-14)."""
|
|
247
|
+
if isinstance(absolute, UnknownBaseline):
|
|
248
|
+
return NNT(not_applicable_reason=f"cannot be computed without a baseline — {absolute.reason.strip()}",
|
|
249
|
+
horizon=horizon)
|
|
250
|
+
if not isinstance(absolute, AbsoluteEffect):
|
|
251
|
+
raise TypeError(f"nnt_from_absolute needs an AbsoluteEffect or UnknownBaseline, got "
|
|
252
|
+
f"{type(absolute).__name__}")
|
|
253
|
+
treated = absolute.treated_rate()
|
|
254
|
+
if treated is None:
|
|
255
|
+
return NNT(not_applicable_reason="observational association, no intervention arm compared",
|
|
256
|
+
horizon=horizon or absolute.horizon)
|
|
257
|
+
difference = abs(absolute.control_rate() - treated)
|
|
258
|
+
if difference == 0:
|
|
259
|
+
return NNT(not_applicable_reason="the two arms have the same event rate — no finite NNT exists",
|
|
260
|
+
horizon=horizon or absolute.horizon)
|
|
261
|
+
return NNT(value=1.0 / difference, horizon=horizon or absolute.horizon)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def render_risk(statement: RiskStatement) -> str:
|
|
265
|
+
"""THE ONLY exit. Builds output solely by iterating RISK_KEYS; there is no
|
|
266
|
+
`__str__`, no `format()`, and no accessor on RelativeEffect returning prose."""
|
|
267
|
+
if not isinstance(statement, RiskStatement):
|
|
268
|
+
raise TypeError(f"render_risk needs a RiskStatement, got {type(statement).__name__}")
|
|
269
|
+
slots = {key: getattr(statement, key, None) for key in RISK_KEYS}
|
|
270
|
+
relative = slots["relative"]
|
|
271
|
+
lines = [f"risk: {relative.kind} {_num(relative.value)} (relative)"
|
|
272
|
+
+ (f", CI {relative.ci}" if relative.ci else "")]
|
|
273
|
+
lines.append(f" absolute: {slots['absolute'].render()}")
|
|
274
|
+
if slots["nnt"] is not None:
|
|
275
|
+
lines.append(f" NNT: {slots['nnt'].render()}")
|
|
276
|
+
elif statement.intervention: # unreachable: __post_init__ requires nnt (D-13)
|
|
277
|
+
lines.append(" NNT: n/a — not supplied")
|
|
278
|
+
if slots["horizon"]:
|
|
279
|
+
lines.append(f" horizon: {slots['horizon']}")
|
|
280
|
+
if slots["population_match"]:
|
|
281
|
+
lines.append(f" {slots['population_match']}")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
__all__ = [
|
|
286
|
+
"RELATIVE_KINDS", "RISK_KEYS", "BASELINE_UNKNOWN_TEXT", "RiskError",
|
|
287
|
+
"RelativeEffect", "AbsoluteEffect", "UnknownBaseline", "NNT", "RiskStatement",
|
|
288
|
+
"nnt_from_excess", "nnt_from_absolute", "render_risk",
|
|
289
|
+
]
|
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Load-bearing security tests for ed25519_verifier.py.
|
|
2
|
+
"""Load-bearing security tests for ed25519_verifier.py.
|
|
3
|
+
|
|
4
|
+
RUN COMMAND (ADR-006 / D-21) — this module, like every other `test_*.py` beside it,
|
|
5
|
+
is collected by the ONE canonical command, never by a hand-kept enumeration:
|
|
6
|
+
|
|
7
|
+
cd .../goap-research-ed25519/scripts && python3 -m unittest discover -s . -p 'test_*.py' -v
|
|
8
|
+
|
|
9
|
+
That is not a formatting preference. Before slice C this file — the security suite of
|
|
10
|
+
the very module the slice edits — was named by NO run command anywhere, so a "full
|
|
11
|
+
suite green" claim could have been true of the command and false of the code
|
|
12
|
+
(AM-14). `test_suite_completeness.py` now asserts the collected set equals `ls
|
|
13
|
+
test_*.py`, so a module that stops being collected fails BY NAME.
|
|
14
|
+
"""
|
|
3
15
|
|
|
4
16
|
import copy
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
# Import NOTHING local before this line: a stray __pycache__ inside this vendored
|
|
20
|
+
# skill reads as canonical drift and turns an unrelated repo test red.
|
|
21
|
+
sys.dont_write_bytecode = True
|
|
22
|
+
|
|
5
23
|
import unittest
|
|
6
24
|
|
|
7
25
|
import ed25519_verifier as ev
|
|
8
26
|
|
|
9
27
|
|
|
28
|
+
def _pop():
|
|
29
|
+
"""A minimal VALID study population (FR-1 / D-1 made it mandatory, no default).
|
|
30
|
+
|
|
31
|
+
These four call sites are the reason AM-14 is a P0: they are the security suite of
|
|
32
|
+
the module being edited, and they were in no run command, so this migration could
|
|
33
|
+
have silently broken them while every report still said "green".
|
|
34
|
+
"""
|
|
35
|
+
return {
|
|
36
|
+
"description": "adults enrolled in the cited cohort",
|
|
37
|
+
"criteria": {
|
|
38
|
+
"age": {"op": "range", "value": [18, 80], "kind": "eligibility",
|
|
39
|
+
"verbatim": "adults aged 18-80", "locator": "[Methods]"},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
10
44
|
@unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
|
|
11
45
|
class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
12
46
|
def make_pinned_pair(self):
|
|
@@ -30,6 +64,7 @@ class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
|
30
64
|
source_url="https://nature.com/articles/example",
|
|
31
65
|
source_content="attacker-controlled content",
|
|
32
66
|
issuer="nature.com",
|
|
67
|
+
study_population=_pop(),
|
|
33
68
|
)
|
|
34
69
|
|
|
35
70
|
result = verifier.verify_fact(fact)
|
|
@@ -39,6 +74,20 @@ class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
|
39
74
|
self.assertNotEqual(result.confidence, 0.95)
|
|
40
75
|
|
|
41
76
|
def test_fact_signed_by_pinned_trusted_key_verifies(self):
|
|
77
|
+
"""A THIRD assertion whose meaning changed in slice C — re-derived, not nudged.
|
|
78
|
+
|
|
79
|
+
The architecture named two such assertions (`test_evidence_provenance.py:97`
|
|
80
|
+
and `:123`). This is a third, found only by RUNNING this file — which is
|
|
81
|
+
exactly AM-14's point, since before slice C no run command named it.
|
|
82
|
+
|
|
83
|
+
What changed: `create_issuer_signed_fact` used to mint a **v1** fact, and v1 is
|
|
84
|
+
exempt from the source-tier ceiling (D-20's lower edge). It now mints **v3**,
|
|
85
|
+
so the third ceiling applies and the documented chain
|
|
86
|
+
`min(trust, evidence, tier)` is finally computed in full for this factory.
|
|
87
|
+
`nature.com` is tier B (0.80), so 0.80 — not 0.95 — is the formula being
|
|
88
|
+
HONOURED, not a downgrade bug. The assertion is written against
|
|
89
|
+
`source_tier_ceiling(fact)` so it states the reason, not just the number.
|
|
90
|
+
"""
|
|
42
91
|
signer, verifier = self.make_pinned_pair()
|
|
43
92
|
|
|
44
93
|
fact = signer.create_issuer_signed_fact(
|
|
@@ -46,13 +95,17 @@ class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
|
46
95
|
source_url="https://nature.com/articles/example",
|
|
47
96
|
source_content="source content",
|
|
48
97
|
issuer="nature.com",
|
|
98
|
+
study_population=_pop(),
|
|
49
99
|
)
|
|
50
100
|
|
|
51
101
|
result = verifier.verify_fact(fact)
|
|
52
102
|
|
|
53
103
|
self.assertTrue(result.verified)
|
|
54
104
|
self.assertEqual(result.trust_class, ev.TRUST_CLASS_ISSUER_SIGNED)
|
|
55
|
-
self.assertEqual(
|
|
105
|
+
self.assertEqual(ev.fact_schema_version(fact), 3, "every newly created fact is v3")
|
|
106
|
+
self.assertEqual(ev.source_tier_ceiling(fact), 0.80, "nature.com is a tier-B source")
|
|
107
|
+
self.assertEqual(result.confidence, 0.80,
|
|
108
|
+
"min(trust 0.95, evidence 1.0, tier 0.80) — the tier term is the binding one")
|
|
56
109
|
|
|
57
110
|
def test_relabelled_or_moved_fact_fails(self):
|
|
58
111
|
signer, verifier = self.make_pinned_pair()
|
|
@@ -61,6 +114,7 @@ class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
|
61
114
|
source_url="https://nature.com/articles/example",
|
|
62
115
|
source_content="source content",
|
|
63
116
|
issuer="nature.com",
|
|
117
|
+
study_population=_pop(),
|
|
64
118
|
)
|
|
65
119
|
|
|
66
120
|
relabelled = copy.deepcopy(fact)
|
|
@@ -87,6 +141,7 @@ class Ed25519VerifierSecurityTests(unittest.TestCase):
|
|
|
87
141
|
source_url=f"https://nature.com/articles/{index}",
|
|
88
142
|
source_content=f"source content {index}",
|
|
89
143
|
issuer="nature.com",
|
|
144
|
+
study_population=_pop(),
|
|
90
145
|
)
|
|
91
146
|
)
|
|
92
147
|
|