@dzhechkov/p-replicator 1.5.17 → 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.
Files changed (21) hide show
  1. package/.dz-manifest.json +51 -23
  2. package/README.md +5 -3
  3. package/package.json +1 -1
  4. package/sbom.json +106 -36
  5. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  6. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  7. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  8. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  9. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  10. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  11. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  12. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  13. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  14. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  15. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  16. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  17. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  18. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  19. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  20. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  21. 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(result.confidence, 0.95)
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