qids 1.3.0__py3-none-any.whl

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 (69) hide show
  1. qids/__init__.py +38 -0
  2. qids/__main__.py +3 -0
  3. qids/analysis/__init__.py +50 -0
  4. qids/analysis/evasion.py +233 -0
  5. qids/analysis/metrics.py +263 -0
  6. qids/analysis/plots.py +358 -0
  7. qids/analysis/prometheus.py +140 -0
  8. qids/analysis/sizing.py +180 -0
  9. qids/analysis/sweeps.py +312 -0
  10. qids/analysis/tracing.py +201 -0
  11. qids/attacks/__init__.py +39 -0
  12. qids/attacks/base.py +116 -0
  13. qids/attacks/channel.py +205 -0
  14. qids/attacks/forgery.py +256 -0
  15. qids/attacks/impersonation.py +183 -0
  16. qids/attacks/otuh_attacks.py +232 -0
  17. qids/attacks/replay.py +138 -0
  18. qids/attacks/repudiation.py +116 -0
  19. qids/blockchain.py +243 -0
  20. qids/cli.py +672 -0
  21. qids/core_accel.py +119 -0
  22. qids/detect/__init__.py +46 -0
  23. qids/detect/baseline.py +502 -0
  24. qids/detect/dispute.py +247 -0
  25. qids/detect/engine.py +463 -0
  26. qids/detect/ledgers.py +197 -0
  27. qids/detect/monitors.py +660 -0
  28. qids/detect/signals.py +94 -0
  29. qids/detect/sprt.py +147 -0
  30. qids/detect/statistics.py +221 -0
  31. qids/errors.py +101 -0
  32. qids/hardware/__init__.py +36 -0
  33. qids/hardware/dwdm.py +256 -0
  34. qids/hardware/etsi_014.py +252 -0
  35. qids/hardware/etsi_mock_server.py +163 -0
  36. qids/hardware/optical_profiles.py +145 -0
  37. qids/hashed_session.py +246 -0
  38. qids/protocol/__init__.py +73 -0
  39. qids/protocol/auth.py +156 -0
  40. qids/protocol/channel_defaults.py +39 -0
  41. qids/protocol/document.py +265 -0
  42. qids/protocol/hardware.py +310 -0
  43. qids/protocol/hybrid.py +236 -0
  44. qids/protocol/keys.py +136 -0
  45. qids/protocol/kgp.py +507 -0
  46. qids/protocol/lfsr.py +322 -0
  47. qids/protocol/otuh.py +619 -0
  48. qids/protocol/params.py +285 -0
  49. qids/protocol/pkcs11.py +392 -0
  50. qids/protocol/qpk.py +203 -0
  51. qids/protocol/relay.py +290 -0
  52. qids/protocol/sign.py +187 -0
  53. qids/protocol/verify.py +272 -0
  54. qids/py.typed +0 -0
  55. qids/quantum/__init__.py +93 -0
  56. qids/quantum/channel.py +99 -0
  57. qids/quantum/gates.py +189 -0
  58. qids/quantum/measure.py +114 -0
  59. qids/quantum/state.py +117 -0
  60. qids/quantum/teleport.py +172 -0
  61. qids/scenarios.py +449 -0
  62. qids/server/__init__.py +5 -0
  63. qids/server/daemon.py +270 -0
  64. qids/session.py +280 -0
  65. qids-1.3.0.dist-info/METADATA +485 -0
  66. qids-1.3.0.dist-info/RECORD +69 -0
  67. qids-1.3.0.dist-info/WHEEL +5 -0
  68. qids-1.3.0.dist-info/entry_points.txt +2 -0
  69. qids-1.3.0.dist-info/top_level.txt +1 -0
qids/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """QIDS -- Quantum-Inspired Detection for Signatures.
2
+
3
+ A threat-detection framework for teleportation-based Quantum Digital
4
+ Signature protocols. Simulates Bell-state entanglement distribution and
5
+ quantum teleportation exactly, applies Pauli corrections and projective
6
+ measurements for verification, and detects forgery, impersonation, replay,
7
+ unauthorised verification and channel manipulation through statistical
8
+ threshold rules alone -- no machine learning anywhere in the decision path.
9
+ """
10
+
11
+ __version__ = "1.3.0"
12
+
13
+ import logging as _logging
14
+
15
+ # Library convention: attach a NullHandler so importing qids never configures
16
+ # logging for the host application. Operators opt in with
17
+ # ``logging.getLogger("qids").setLevel(logging.INFO)``.
18
+ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
19
+
20
+ from .errors import ( # noqa: F401
21
+ AuthenticationError,
22
+ ConfigurationError,
23
+ InsecureConfiguration,
24
+ KeyExhaustedError,
25
+ KeyMaterialError,
26
+ ProtocolError,
27
+ QidsError,
28
+ UnknownScenario,
29
+ )
30
+ from .protocol.params import ProtocolParams # noqa: F401
31
+ from .session import QdsSession, SessionResult # noqa: F401
32
+
33
+ __all__ = [
34
+ "ProtocolParams", "QdsSession", "SessionResult", "__version__",
35
+ "QidsError", "ConfigurationError", "InsecureConfiguration",
36
+ "ProtocolError", "KeyExhaustedError", "KeyMaterialError",
37
+ "AuthenticationError", "UnknownScenario",
38
+ ]
qids/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ """Security analysis, metrics and benchmarking."""
2
+
3
+ from .evasion import ( # noqa: F401
4
+ EvasionPoint,
5
+ Frontier,
6
+ acceptance_curve,
7
+ evaluate_strength,
8
+ find_frontier,
9
+ is_monotone,
10
+ threshold_only_frontier,
11
+ )
12
+ from .metrics import EvaluationReport, ScenarioStats, evaluate # noqa: F401
13
+ from .sizing import ( # noqa: F401
14
+ LinkSizingParameters,
15
+ LinkSizingResult,
16
+ calculate_optical_link_budget,
17
+ format_sizing_report,
18
+ )
19
+ from .sweeps import ( # noqa: F401
20
+ detection_power_curve,
21
+ forgery_probability_curve,
22
+ leakage_tolerance,
23
+ repudiation_probability_curve,
24
+ validate_forger_model,
25
+ )
26
+
27
+ #: Public surface of this subpackage. Declared explicitly because a
28
+ #: re-export is only 'used' from the outside, and ``# noqa`` does not
29
+ #: reach pyflakes -- ``__all__`` does.
30
+ __all__ = [
31
+ "EvaluationReport",
32
+ "EvasionPoint",
33
+ "Frontier",
34
+ "LinkSizingParameters",
35
+ "LinkSizingResult",
36
+ "ScenarioStats",
37
+ "acceptance_curve",
38
+ "calculate_optical_link_budget",
39
+ "detection_power_curve",
40
+ "evaluate",
41
+ "evaluate_strength",
42
+ "find_frontier",
43
+ "format_sizing_report",
44
+ "forgery_probability_curve",
45
+ "is_monotone",
46
+ "leakage_tolerance",
47
+ "repudiation_probability_curve",
48
+ "threshold_only_frontier",
49
+ "validate_forger_model",
50
+ ]
@@ -0,0 +1,233 @@
1
+ """The strongest attack that survives: mapping the evasion frontier.
2
+
3
+ Every scenario in the catalogue runs an attack at a *fixed* strength and asks
4
+ whether it is caught. That answers "does this attack work against the
5
+ detector", which is the easy question. The hard one is the adversary's:
6
+
7
+ given that this detector exists, and knowing its thresholds, what is the
8
+ most I can do and still be accepted?
9
+
10
+ An attacker who reads the source picks their strength deliberately. If the
11
+ only defence were the protocol's own threshold rule, they would tune the
12
+ attack to sit just under s_a and walk through. So the number that actually
13
+ characterises the framework is the *largest attack strength that still gets
14
+ ACCEPT*, and how much that concession costs the attacker.
15
+
16
+ Two frontiers are reported per family:
17
+
18
+ ``p50`` the strength at which acceptance falls to 50%
19
+ ``p05`` the strength at which acceptance falls to 5% -- the practical ceiling
20
+
21
+ Bisection rather than a grid, because the transition is sharp and a grid
22
+ either misses it or wastes runs far from it. Acceptance is monotone in
23
+ strength for every family here (more disturbance is never easier to hide),
24
+ which is what makes bisection valid; the sweep in ``acceptance_curve`` is
25
+ kept so that assumption stays checkable rather than assumed.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+
32
+ import numpy as np
33
+
34
+ from ..detect.engine import Decision
35
+ from ..protocol.verify import Level
36
+ from ..session import QdsSession
37
+
38
+
39
+ @dataclass
40
+ class EvasionPoint:
41
+ """Outcome of testing one attack strength."""
42
+
43
+ strength: float
44
+ trials: int
45
+ accepted: int
46
+ mean_mismatch: float
47
+ fired: dict = field(default_factory=dict)
48
+
49
+ @property
50
+ def acceptance(self) -> float:
51
+ return self.accepted / self.trials if self.trials else 0.0
52
+
53
+ def summary(self) -> dict:
54
+ return {
55
+ "strength": self.strength,
56
+ "acceptance": self.acceptance,
57
+ "mean_mismatch": self.mean_mismatch,
58
+ "fired": self.fired,
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class Frontier:
64
+ """Where a family of attacks stops getting through."""
65
+
66
+ family: str
67
+ p50: float
68
+ p05: float
69
+ detail: list = field(default_factory=list)
70
+ threshold_s_a: float = 0.0
71
+ mismatch_at_p50: float = 0.0
72
+ mismatch_at_p05: float = 0.0
73
+ first_monitor: str = ""
74
+
75
+ def summary(self) -> dict:
76
+ return {
77
+ "family": self.family,
78
+ "strength_at_50pct_acceptance": self.p50,
79
+ "strength_at_5pct_acceptance": self.p05,
80
+ "mismatch_at_p50": self.mismatch_at_p50,
81
+ "mismatch_at_p05": self.mismatch_at_p05,
82
+ "s_a": self.threshold_s_a,
83
+ "first_monitor_to_fire": self.first_monitor,
84
+ "points": [p.summary() for p in self.detail],
85
+ }
86
+
87
+
88
+ def evaluate_strength(
89
+ params,
90
+ adversary_factory,
91
+ strength: float,
92
+ trials: int = 10,
93
+ seed: int = 0,
94
+ holder: str = "bob",
95
+ ) -> EvasionPoint:
96
+ """Run the full pipeline ``trials`` times at one attack strength."""
97
+ accepted, mismatches = 0, []
98
+ fired: dict = {}
99
+ for t in range(trials):
100
+ rng = np.random.default_rng(seed * 7717 + int(strength * 1e6) * 31 + t)
101
+ sess = QdsSession(params, rng)
102
+ sess.distribute(adversary=adversary_factory(strength))
103
+ sess.symmetrise()
104
+ decoys = sess.check_decoys(0)
105
+ sig = sess.sign(b"EVASION", 0)
106
+ env = sess.transport(sig, holder)
107
+ ev, verdict = sess.deliver(sig, holder, Level.AUTHENTICATION,
108
+ envelope=env, decoy_evidence=decoys.get(holder))
109
+ mismatches.append(ev.mismatch_rate)
110
+ if verdict.decision == Decision.ACCEPT:
111
+ accepted += 1
112
+ for name in verdict.fired_names:
113
+ fired[name] = fired.get(name, 0) + 1
114
+ return EvasionPoint(
115
+ strength=strength, trials=trials, accepted=accepted,
116
+ mean_mismatch=float(np.mean(mismatches)),
117
+ fired={k: v / trials for k, v in sorted(fired.items())},
118
+ )
119
+
120
+
121
+ def find_frontier(
122
+ params,
123
+ adversary_factory,
124
+ family: str,
125
+ lo: float = 0.0,
126
+ hi: float = 1.0,
127
+ trials: int = 10,
128
+ iterations: int = 7,
129
+ seed: int = 0,
130
+ ) -> Frontier:
131
+ """Bisect for the strengths at which acceptance crosses 50% and 5%."""
132
+ points: list = []
133
+
134
+ def probe(x: float) -> EvasionPoint:
135
+ for p in points:
136
+ if abs(p.strength - x) < 1e-9:
137
+ return p
138
+ p = evaluate_strength(params, adversary_factory, x, trials, seed)
139
+ points.append(p)
140
+ return p
141
+
142
+ def bisect(target: float) -> float:
143
+ a, b = lo, hi
144
+ if probe(b).acceptance >= target: # never suppressed in range
145
+ return b
146
+ if probe(a).acceptance < target: # already suppressed at the floor
147
+ return a
148
+ for _ in range(iterations):
149
+ mid = (a + b) / 2
150
+ if probe(mid).acceptance >= target:
151
+ a = mid
152
+ else:
153
+ b = mid
154
+ return (a + b) / 2
155
+
156
+ p50 = bisect(0.5)
157
+ p05 = bisect(0.05)
158
+ points.sort(key=lambda p: p.strength)
159
+
160
+ def mismatch_near(x: float) -> float:
161
+ return min(points, key=lambda p: abs(p.strength - x)).mean_mismatch
162
+
163
+ first = first_monitor(points)
164
+
165
+ return Frontier(
166
+ family=family, p50=p50, p05=p05, detail=points,
167
+ threshold_s_a=params.thresholds[0],
168
+ mismatch_at_p50=mismatch_near(p50),
169
+ mismatch_at_p05=mismatch_near(p05),
170
+ first_monitor=first,
171
+ )
172
+
173
+
174
+ def first_monitor(points, min_rate: float = 0.25) -> str:
175
+ """Which monitor is the first to engage as attack strength rises?
176
+
177
+ ``min_rate`` matters more than it looks. Taking the lowest strength where
178
+ *any* monitor fires at all picks up single noise firings -- one trial in
179
+ twelve, while the attack is still being accepted 92% of the time -- and
180
+ labels that the "first" monitor, which is misleading. Requiring a monitor
181
+ to fire on a real fraction of trials before it counts gives a label that
182
+ reflects the ladder rather than the tail of the sampling distribution.
183
+ """
184
+ for p in sorted(points, key=lambda q: q.strength):
185
+ meaningful = {k: v for k, v in p.fired.items() if v >= min_rate}
186
+ if meaningful:
187
+ return max(meaningful, key=meaningful.get)
188
+ return ""
189
+
190
+
191
+ def acceptance_curve(
192
+ params,
193
+ adversary_factory,
194
+ strengths,
195
+ trials: int = 8,
196
+ seed: int = 0,
197
+ ) -> list:
198
+ """Straight sweep, used to check that acceptance really is monotone."""
199
+ return [evaluate_strength(params, adversary_factory, s, trials, seed)
200
+ for s in strengths]
201
+
202
+
203
+ def is_monotone(points, tolerance: float = 0.25) -> bool:
204
+ """Is acceptance non-increasing in strength, within sampling noise?
205
+
206
+ Bisection assumes it. With ``trials`` runs per point the estimate has a
207
+ standard error around 0.5/sqrt(trials), so a small rise is noise rather
208
+ than a violation; anything larger means the frontier is not well defined
209
+ and the number should not be quoted.
210
+ """
211
+ acc = [p.acceptance for p in sorted(points, key=lambda p: p.strength)]
212
+ return all(b <= a + tolerance for a, b in zip(acc, acc[1:]))
213
+
214
+
215
+ def threshold_only_frontier(params, induced_rate) -> float:
216
+ """Where the frontier would sit with *only* the protocol's threshold rule.
217
+
218
+ ``induced_rate(strength)`` gives the mismatch rate an attack produces.
219
+ Solving ``induced_rate(x) = s_a`` gives the strength a bare threshold
220
+ detector would tolerate -- the number the extra monitors have to beat to
221
+ be worth their cost.
222
+ """
223
+ s_a, _ = params.thresholds
224
+ lo, hi = 0.0, 1.0
225
+ if induced_rate(hi) <= s_a:
226
+ return hi
227
+ for _ in range(60):
228
+ mid = (lo + hi) / 2
229
+ if induced_rate(mid) <= s_a:
230
+ lo = mid
231
+ else:
232
+ hi = mid
233
+ return lo
@@ -0,0 +1,263 @@
1
+ """Scoring the detector: detection power, false alarms, attribution accuracy.
2
+
3
+ Three numbers matter, and they are not interchangeable:
4
+
5
+ ``undetected acceptance`` an attack that was ACCEPTed. This is the only
6
+ outcome that actually breaks the scheme, and the
7
+ target for it is zero.
8
+ ``detection rate`` an attack that was not ACCEPTed (REJECT or
9
+ QUARANTINE). Quarantine counts, because holding a
10
+ signature for arbitration is a safe outcome.
11
+ ``false alarm rate`` a benign session that was not ACCEPTed. This is the
12
+ cost of the detector, and it is what stops anyone
13
+ from trivially scoring 100% by rejecting everything.
14
+
15
+ Attribution accuracy is reported separately. Detecting an attack and naming
16
+ it correctly are different capabilities, and conflating them hides the cases
17
+ where the framework is right for the wrong reason.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import time
24
+ from collections import Counter, defaultdict
25
+ from dataclasses import dataclass, field
26
+
27
+ import numpy as np
28
+
29
+ from ..detect.engine import Decision
30
+ from ..scenarios import SCENARIOS, run_scenario
31
+
32
+ #: Threats that are the same physical phenomenon seen at different strengths.
33
+ #: Scored as a family as well as exactly, because "weak attack" and "degraded
34
+ #: link" are genuinely indistinguishable from one session's observables.
35
+ THREAT_FAMILY = {
36
+ "benign": "benign",
37
+ "forgery": "fabrication",
38
+ "impersonation": "fabrication",
39
+ "replay": "replay",
40
+ "unauthorized_verification": "access",
41
+ "channel_manipulation": "channel",
42
+ "link_degradation": "channel",
43
+ "repudiation": "repudiation",
44
+ "evasion": "fabrication",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class ScenarioStats:
50
+ """Aggregate outcome of repeating one scenario."""
51
+
52
+ name: str
53
+ truth: str
54
+ n_runs: int = 0
55
+ n_detected: int = 0
56
+ n_accepted: int = 0
57
+ n_exact: int = 0
58
+ n_family: int = 0
59
+ mismatch_rates: list = field(default_factory=list)
60
+ latencies_ms: list = field(default_factory=list)
61
+ sprt_stops: list = field(default_factory=list)
62
+ decisions: Counter = field(default_factory=Counter)
63
+ attributed: Counter = field(default_factory=Counter)
64
+
65
+ @property
66
+ def is_benign(self) -> bool:
67
+ return self.truth == "benign"
68
+
69
+ @property
70
+ def detection_rate(self) -> float:
71
+ return self.n_detected / self.n_runs if self.n_runs else 0.0
72
+
73
+ @property
74
+ def false_alarm_rate(self) -> float:
75
+ return self.detection_rate if self.is_benign else 0.0
76
+
77
+ @property
78
+ def undetected_acceptance_rate(self) -> float:
79
+ return 0.0 if self.is_benign else self.n_accepted / max(1, self.n_runs)
80
+
81
+ @property
82
+ def exact_attribution(self) -> float:
83
+ return self.n_exact / self.n_runs if self.n_runs else 0.0
84
+
85
+ @property
86
+ def family_attribution(self) -> float:
87
+ return self.n_family / self.n_runs if self.n_runs else 0.0
88
+
89
+ def row(self) -> dict:
90
+ return {
91
+ "scenario": self.name,
92
+ "truth": self.truth,
93
+ "runs": self.n_runs,
94
+ "detection_rate": self.detection_rate,
95
+ "undetected_acceptance": self.undetected_acceptance_rate,
96
+ "exact_attribution": self.exact_attribution,
97
+ "family_attribution": self.family_attribution,
98
+ "mean_mismatch": float(np.mean(self.mismatch_rates)) if self.mismatch_rates else 0.0,
99
+ "mean_latency_ms": float(np.mean(self.latencies_ms)) if self.latencies_ms else 0.0,
100
+ "median_sprt_stop": (
101
+ float(np.median([s for s in self.sprt_stops if s])) if any(self.sprt_stops) else None
102
+ ),
103
+ "decisions": dict(self.decisions),
104
+ "attributed": dict(self.attributed),
105
+ }
106
+
107
+
108
+ @dataclass
109
+ class EvaluationReport:
110
+ per_scenario: dict = field(default_factory=dict)
111
+ confusion: dict = field(default_factory=lambda: defaultdict(Counter))
112
+ params_summary: dict = field(default_factory=dict)
113
+ wall_clock_s: float = 0.0
114
+
115
+ # -- aggregate figures -------------------------------------------------
116
+ def _bucket(self, benign: bool):
117
+ return [s for s in self.per_scenario.values() if s.is_benign == benign]
118
+
119
+ @property
120
+ def attack_scenarios(self) -> list:
121
+ return self._bucket(False)
122
+
123
+ @property
124
+ def benign_scenarios(self) -> list:
125
+ return self._bucket(True)
126
+
127
+ def _weighted(self, items, attr) -> float:
128
+ runs = sum(s.n_runs for s in items)
129
+ if not runs:
130
+ return 0.0
131
+ return sum(getattr(s, attr) * s.n_runs for s in items) / runs
132
+
133
+ @property
134
+ def detection_rate(self) -> float:
135
+ return self._weighted(self.attack_scenarios, "detection_rate")
136
+
137
+ @property
138
+ def false_alarm_rate(self) -> float:
139
+ return self._weighted(self.benign_scenarios, "detection_rate")
140
+
141
+ @property
142
+ def undetected_acceptance_rate(self) -> float:
143
+ return self._weighted(self.attack_scenarios, "undetected_acceptance_rate")
144
+
145
+ @property
146
+ def exact_attribution(self) -> float:
147
+ return self._weighted(self.attack_scenarios, "exact_attribution")
148
+
149
+ @property
150
+ def family_attribution(self) -> float:
151
+ return self._weighted(self.attack_scenarios, "family_attribution")
152
+
153
+ def summary(self) -> dict:
154
+ return {
155
+ "scenarios": len(self.per_scenario),
156
+ "total_runs": sum(s.n_runs for s in self.per_scenario.values()),
157
+ "detection_rate": self.detection_rate,
158
+ "false_alarm_rate": self.false_alarm_rate,
159
+ "undetected_acceptance_rate": self.undetected_acceptance_rate,
160
+ "exact_attribution": self.exact_attribution,
161
+ "family_attribution": self.family_attribution,
162
+ "wall_clock_s": self.wall_clock_s,
163
+ }
164
+
165
+ def confusion_matrix(self) -> dict:
166
+ return {t: dict(c) for t, c in self.confusion.items()}
167
+
168
+ def table(self) -> str:
169
+ hdr = (
170
+ f"{'scenario':36s} {'truth':26s} {'runs':>5s} {'detect':>7s} "
171
+ f"{'undet.acc':>10s} {'attrib':>7s} {'family':>7s} {'ms':>7s}"
172
+ )
173
+ lines = [hdr, "-" * len(hdr)]
174
+ for s in self.per_scenario.values():
175
+ r = s.row()
176
+ lines.append(
177
+ f"{r['scenario']:36s} {r['truth']:26s} {r['runs']:5d} "
178
+ f"{r['detection_rate']:7.3f} {r['undetected_acceptance']:10.3f} "
179
+ f"{r['exact_attribution']:7.3f} {r['family_attribution']:7.3f} "
180
+ f"{r['mean_latency_ms']:7.2f}"
181
+ )
182
+ return "\n".join(lines)
183
+
184
+
185
+ def _outcome(result):
186
+ """Extract (decision, attributed_threat) from a scenario result.
187
+
188
+ Repudiation is arbitrated across verifiers, so for those scenarios the
189
+ dispute outcome -- not a single verifier's local verdict -- is the
190
+ framework's answer.
191
+ """
192
+ if result.dispute is not None and result.truth == "repudiation":
193
+ return result.dispute.decision, result.dispute.threat.value
194
+ v = result.target_verdict
195
+ if v is None:
196
+ return Decision.REJECT, "benign"
197
+ return v.decision, v.threat.value
198
+
199
+
200
+ def _name_offset(name: str) -> int:
201
+ """Stable per-scenario seed offset.
202
+
203
+ This used ``abs(hash(name))``, which is not reproducible: CPython
204
+ randomises string hashing per process unless ``PYTHONHASHSEED`` is set, so
205
+ two runs of the same evaluation at the same ``seed`` drew different streams
206
+ and reported different numbers. A cryptographic digest is stable across
207
+ processes, platforms and Python versions, which is what "seed 11" has to
208
+ mean for a reported figure to be checkable.
209
+ """
210
+ digest = hashlib.blake2b(name.encode("utf-8"), digest_size=8).digest()
211
+ return int.from_bytes(digest, "big") % 99_991
212
+
213
+
214
+ def evaluate(
215
+ params,
216
+ seed: int = 0,
217
+ repeats: int = 5,
218
+ only: tuple = (),
219
+ progress: bool = False,
220
+ ) -> EvaluationReport:
221
+ """Run every scenario ``repeats`` times and score the detector."""
222
+ names = only or tuple(SCENARIOS)
223
+ report = EvaluationReport(params_summary=params.security_summary())
224
+ t0 = time.perf_counter()
225
+
226
+ for name in names:
227
+ sc = SCENARIOS[name]
228
+ stats = ScenarioStats(name=name, truth=sc.truth)
229
+ for r_i in range(repeats):
230
+ rng = np.random.default_rng(seed * 100_003 + _name_offset(name) + r_i)
231
+ result = run_scenario(name, params, rng)
232
+ decision, threat = _outcome(result)
233
+
234
+ stats.n_runs += 1
235
+ stats.decisions[decision] += 1
236
+ stats.attributed[threat] += 1
237
+ if decision != Decision.ACCEPT:
238
+ stats.n_detected += 1
239
+ else:
240
+ stats.n_accepted += 1
241
+ if threat == sc.truth or (sc.truth == "benign" and decision == Decision.ACCEPT):
242
+ stats.n_exact += 1
243
+ if THREAT_FAMILY.get(threat) == THREAT_FAMILY.get(sc.truth) or (
244
+ sc.truth == "benign" and decision == Decision.ACCEPT
245
+ ):
246
+ stats.n_family += 1
247
+
248
+ v = result.target_verdict
249
+ if v is not None:
250
+ stats.mismatch_rates.append(v.mismatch_rate)
251
+ stats.latencies_ms.append(v.elapsed_s * 1000.0)
252
+ sprt = next((s for s in v.signals if s.name == "sprt"), None)
253
+ stats.sprt_stops.append(
254
+ (sprt.detail or {}).get("stopped_at") if sprt else None
255
+ )
256
+ report.confusion[sc.truth][threat] += 1
257
+ report.per_scenario[name] = stats
258
+ if progress:
259
+ print(f" {name:36s} detect={stats.detection_rate:.2f} "
260
+ f"attrib={stats.exact_attribution:.2f}", flush=True)
261
+
262
+ report.wall_clock_s = time.perf_counter() - t0
263
+ return report