sys1bench 0.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 (72) hide show
  1. sys1bench/__init__.py +3 -0
  2. sys1bench/adapters/__init__.py +19 -0
  3. sys1bench/adapters/base.py +159 -0
  4. sys1bench/adapters/embed_knn.py +61 -0
  5. sys1bench/adapters/encoder_finetuned.py +114 -0
  6. sys1bench/adapters/generic_http.py +161 -0
  7. sys1bench/adapters/hybrid_router.py +49 -0
  8. sys1bench/adapters/jev_openrouter.py +172 -0
  9. sys1bench/adapters/jev_typesafe.py +165 -0
  10. sys1bench/adapters/laya_local.py +192 -0
  11. sys1bench/adapters/llm_constrained.py +104 -0
  12. sys1bench/adapters/majority_prior.py +53 -0
  13. sys1bench/adapters/mock.py +77 -0
  14. sys1bench/adapters/nli_zeroshot.py +50 -0
  15. sys1bench/adapters/regex_keyword.py +70 -0
  16. sys1bench/analysis/__init__.py +4 -0
  17. sys1bench/analysis/arms.py +46 -0
  18. sys1bench/analysis/decision_value.py +43 -0
  19. sys1bench/analysis/decomposition.py +47 -0
  20. sys1bench/analysis/meta_eval.py +67 -0
  21. sys1bench/analysis/stats.py +76 -0
  22. sys1bench/cli.py +434 -0
  23. sys1bench/data/__init__.py +37 -0
  24. sys1bench/data/canary/canary.jsonl +200 -0
  25. sys1bench/data/framings/guardrail_intent.yaml +21 -0
  26. sys1bench/data/framings/log_triage.yaml +20 -0
  27. sys1bench/data/framings/phishing_email.yaml +25 -0
  28. sys1bench/data/framings/policy_compliance.yaml +18 -0
  29. sys1bench/data/framings/support_tickets.yaml +41 -0
  30. sys1bench/framing/__init__.py +10 -0
  31. sys1bench/framing/expand.py +214 -0
  32. sys1bench/framing/perturb.py +67 -0
  33. sys1bench/generators/__init__.py +10 -0
  34. sys1bench/generators/base.py +105 -0
  35. sys1bench/generators/guardrail_intent.py +95 -0
  36. sys1bench/generators/log_triage.py +100 -0
  37. sys1bench/generators/multilingual_tickets.py +84 -0
  38. sys1bench/generators/phishing_email.py +127 -0
  39. sys1bench/generators/policy_compliance.py +90 -0
  40. sys1bench/generators/rag_relevance.py +122 -0
  41. sys1bench/generators/support_tickets.py +212 -0
  42. sys1bench/metrics/__init__.py +9 -0
  43. sys1bench/metrics/calibration.py +164 -0
  44. sys1bench/metrics/consistency.py +71 -0
  45. sys1bench/metrics/decision_value.py +43 -0
  46. sys1bench/metrics/efficiency.py +35 -0
  47. sys1bench/metrics/ordinal.py +80 -0
  48. sys1bench/metrics/robustness.py +46 -0
  49. sys1bench/metrics/selective.py +67 -0
  50. sys1bench/report/__init__.py +1 -0
  51. sys1bench/report/dashboard.py +177 -0
  52. sys1bench/report/latex.py +40 -0
  53. sys1bench/report/plots.py +143 -0
  54. sys1bench/report/results_doc.py +237 -0
  55. sys1bench/report/scorecard.py +170 -0
  56. sys1bench/runners/__init__.py +2 -0
  57. sys1bench/runners/benchmark_runner.py +149 -0
  58. sys1bench/runners/cache.py +53 -0
  59. sys1bench/runners/canary.py +43 -0
  60. sys1bench/runners/hybrid_sweep.py +37 -0
  61. sys1bench/runners/noul_consistency.py +121 -0
  62. sys1bench/runners/ordinal_probes.py +90 -0
  63. sys1bench/runners/robustness.py +112 -0
  64. sys1bench/runners/suite.py +183 -0
  65. sys1bench/runners/sweeps.py +190 -0
  66. sys1bench/schemas.py +274 -0
  67. sys1bench-0.3.0.dist-info/METADATA +195 -0
  68. sys1bench-0.3.0.dist-info/RECORD +72 -0
  69. sys1bench-0.3.0.dist-info/WHEEL +5 -0
  70. sys1bench-0.3.0.dist-info/entry_points.txt +2 -0
  71. sys1bench-0.3.0.dist-info/licenses/LICENSE +202 -0
  72. sys1bench-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,53 @@
1
+ """Class-prior baseline. Priors are fitted from a manifest (or uniform if none given)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import Counter
7
+
8
+ from ..schemas import (
9
+ DecisionRequest,
10
+ DecisionResponse,
11
+ LatencyRecord,
12
+ ModelCapabilities,
13
+ ProviderRecord,
14
+ TaskItem,
15
+ )
16
+ from .base import BaseAdapter, finalize_answer, register
17
+
18
+
19
+ @register("majority_prior")
20
+ class MajorityPriorAdapter(BaseAdapter):
21
+ def __init__(self, model_id: str = "majority_prior", **tunables) -> None:
22
+ super().__init__(model_id, **tunables)
23
+ self.priors: dict[str, Counter] = {}
24
+
25
+ @property
26
+ def capabilities(self) -> ModelCapabilities:
27
+ return ModelCapabilities(name=self.model_id, deployment="local", supports_batching=True)
28
+
29
+ def fit(self, items: list[TaskItem]) -> None:
30
+ for it in items:
31
+ for k, q in it.questions.items():
32
+ if q.ground_truth is None:
33
+ continue
34
+ gt = str(q.ground_truth).lower() if q.type == "noul" else str(q.ground_truth)
35
+ self.priors.setdefault(q.framing_group or k, Counter())[gt] += 1
36
+
37
+ def decide(self, request: DecisionRequest) -> DecisionResponse:
38
+ t0 = self._now_ms()
39
+ answers = {}
40
+ for k, q in request.questions.items():
41
+ keys = q.option_keys
42
+ counts = self.priors.get(q.framing_group or k)
43
+ if counts:
44
+ total = sum(counts.get(x, 0) for x in keys) + len(keys) * 1e-3
45
+ probs = [(counts.get(x, 0) + 1e-3) / total for x in keys]
46
+ else:
47
+ probs = [1.0 / len(keys)] * len(keys)
48
+ answers[k] = finalize_answer(q, probs)
49
+ return DecisionResponse(
50
+ answers=answers,
51
+ latency=LatencyRecord(client_ms=self._now_ms() - t0, timestamp=time.time()),
52
+ provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0),
53
+ )
@@ -0,0 +1,77 @@
1
+ """Deterministic synthetic model for offline tests and CI.
2
+
3
+ Behaviour is controlled so metric kernels can be validated end to end:
4
+ `skill` sets P(argmax == truth); `temperature` scales logits (>1 under-confident,
5
+ <1 over-confident); `position_bias` adds mass to index 0; `quantise` rounds probs.
6
+ It reads `ground_truth` only through a hash of the state so it behaves like a real
7
+ model that is right some fraction of the time, not like an oracle.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import random
14
+ import time
15
+
16
+ import numpy as np
17
+
18
+ from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
19
+ from .base import BaseAdapter, finalize_answer, register
20
+
21
+
22
+ @register("mock")
23
+ class MockAdapter(BaseAdapter):
24
+ def __init__(self, model_id: str = "mock-v1", skill: float = 0.8, temperature: float = 1.0,
25
+ position_bias: float = 0.0, quantise: float | None = None, framing_sensitivity: float = 0.0,
26
+ latency_ms: float = 5.0, **tunables) -> None:
27
+ super().__init__(model_id, **tunables)
28
+ self.skill, self.temperature, self.position_bias = skill, temperature, position_bias
29
+ self.quantise, self.framing_sensitivity, self.latency_ms = quantise, framing_sensitivity, latency_ms
30
+
31
+ @property
32
+ def capabilities(self) -> ModelCapabilities:
33
+ return ModelCapabilities(name=self.model_id, deployment="local", max_options=255, emits_confidence=True,
34
+ supports_native_abstain=True, supports_batching=True)
35
+
36
+ def _rng(self, request: DecisionRequest, key: str) -> random.Random:
37
+ seed_src = f"{request.state_text()}|{key}|{self.model_id}"
38
+ if self.framing_sensitivity > 0:
39
+ seed_src += "|" + request.questions[key].instructions
40
+ return random.Random(int(hashlib.sha256(seed_src.encode()).hexdigest()[:16], 16))
41
+
42
+ def decide(self, request: DecisionRequest) -> DecisionResponse:
43
+ t0 = self._now_ms()
44
+ answers = {}
45
+ for key, q in request.questions.items():
46
+ rng = self._rng(request, key)
47
+ n = q.cardinality
48
+ keys = q.option_keys
49
+ truth = q.ground_truth
50
+ truth_key = None
51
+ if truth is not None:
52
+ truth_key = str(truth).lower() if q.type == "noul" else str(truth)
53
+ skill = self.skill
54
+ if self.framing_sensitivity > 0:
55
+ skill = max(0.0, min(1.0, skill + self.framing_sensitivity * (rng.random() - 0.5)))
56
+ if truth_key in keys and rng.random() < skill:
57
+ target = keys.index(truth_key)
58
+ else:
59
+ candidates = [i for i in range(n) if keys[i] != truth_key] or list(range(n))
60
+ target = rng.choice(candidates)
61
+ logits = np.array([rng.gauss(0, 1) for _ in range(n)])
62
+ logits[target] += 3.0
63
+ logits[0] += self.position_bias
64
+ logits = logits / self.temperature
65
+ probs = np.exp(logits - logits.max())
66
+ probs /= probs.sum()
67
+ if self.quantise:
68
+ probs = np.round(probs / self.quantise) * self.quantise
69
+ probs[int(probs.argmax())] += 1.0 - probs.sum() # keep sum exactly 1 like a vendor would
70
+ answers[key] = finalize_answer(q, list(probs), confidence=float(probs.max()))
71
+ return DecisionResponse(
72
+ answers=answers,
73
+ latency=LatencyRecord(client_ms=self.latency_ms + (self._now_ms() - t0), compute_ms=self.latency_ms,
74
+ timestamp=time.time()),
75
+ provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id,
76
+ model_id_returned=self.model_id, version_hash="mock", cost_usd=0.0),
77
+ )
@@ -0,0 +1,50 @@
1
+ """Zero-shot NLI baseline (BART-large-MNLI / DeBERTa-v3-large-MNLI). Hypothesis = label description."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import numpy as np
8
+
9
+ from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
10
+ from .base import BaseAdapter, finalize_answer, register
11
+
12
+
13
+ @register("nli_zeroshot")
14
+ class NLIZeroShotAdapter(BaseAdapter):
15
+ def __init__(self, model_id: str = "facebook/bart-large-mnli", device: int | str = -1,
16
+ hypothesis_template: str = "This text is about {}.", **tunables) -> None:
17
+ super().__init__(model_id, **tunables)
18
+ self.device, self.template = device, hypothesis_template
19
+ self._pipe = None
20
+
21
+ @property
22
+ def capabilities(self) -> ModelCapabilities:
23
+ return ModelCapabilities(name=f"nli:{self.model_id}", deployment="local", supports_batching=True)
24
+
25
+ def _p(self):
26
+ if self._pipe is None:
27
+ from transformers import pipeline # type: ignore
28
+
29
+ self._pipe = pipeline("zero-shot-classification", model=self.model_id, device=self.device)
30
+ return self._pipe
31
+
32
+ def decide(self, request: DecisionRequest) -> DecisionResponse:
33
+ t0 = time.perf_counter()
34
+ text = request.state_text()
35
+ answers = {}
36
+ for k, q in request.questions.items():
37
+ if q.type == "noul":
38
+ labels = [f"{q.instructions} yes", f"{q.instructions} no"]
39
+ elif q.type == "choice":
40
+ labels = [c.description or c.key for c in q.criteria] # type: ignore[union-attr]
41
+ else:
42
+ labels = [c.description or f"level {c.level}" for c in q.criteria] # type: ignore[union-attr]
43
+ out = self._p()(text, candidate_labels=labels, hypothesis_template=self.template, multi_label=False)
44
+ score = dict(zip(out["labels"], out["scores"]))
45
+ p = np.array([score[lab] for lab in labels])
46
+ p /= p.sum()
47
+ answers[k] = finalize_answer(q, [float(x) for x in p], confidence=float(p.max()))
48
+ ms = (time.perf_counter() - t0) * 1000
49
+ return DecisionResponse(answers=answers, latency=LatencyRecord(client_ms=ms, compute_ms=ms, timestamp=time.time()),
50
+ provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0))
@@ -0,0 +1,70 @@
1
+ """Hand-written rule baseline. Rules come from a YAML file per domain:
2
+
3
+ queue:
4
+ billing: ["refund", "charged", "invoice"]
5
+ shipping: ["deliver", "tracking", "package"]
6
+ is_angry: ["unacceptable", "furious", "ridiculous"] # noul: any match -> true
7
+ priority: {"3": ["urgent", "asap"], "4": ["outage", "down"]} # score: highest matching level wins
8
+
9
+ Confidence is a fixed soft score (match -> 0.9 on the winner) so the baseline gets calibration
10
+ metrics too, which shows what a rule system's probabilities are worth.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from ..schemas import DecisionRequest, DecisionResponse, LatencyRecord, ModelCapabilities, ProviderRecord
22
+ from .base import BaseAdapter, finalize_answer, register
23
+
24
+
25
+ @register("regex_keyword")
26
+ class RegexKeywordAdapter(BaseAdapter):
27
+ def __init__(self, model_id: str = "regex_keyword", rules_path: str | None = None, rules: dict | None = None,
28
+ hit_confidence: float = 0.9, **tunables) -> None:
29
+ super().__init__(model_id, **tunables)
30
+ if rules is None and rules_path:
31
+ rules = yaml.safe_load(Path(rules_path).read_text())
32
+ self.rules = rules or {}
33
+ self.hit_confidence = hit_confidence
34
+ self._compiled: dict[str, dict[str, list[re.Pattern]]] = {}
35
+ for qkey, spec in self.rules.items():
36
+ if isinstance(spec, list):
37
+ self._compiled[qkey] = {"true": [re.compile(p, re.IGNORECASE) for p in spec]}
38
+ else:
39
+ self._compiled[qkey] = {str(k): [re.compile(p, re.IGNORECASE) for p in v] for k, v in spec.items()}
40
+
41
+ @property
42
+ def capabilities(self) -> ModelCapabilities:
43
+ return ModelCapabilities(name=self.model_id, deployment="local", emits_confidence=True, supports_batching=True)
44
+
45
+ def decide(self, request: DecisionRequest) -> DecisionResponse:
46
+ t0 = self._now_ms()
47
+ text = request.state_text()
48
+ answers = {}
49
+ for k, q in request.questions.items():
50
+ keys = q.option_keys
51
+ rules = self._compiled.get(q.framing_group or k) or self._compiled.get(k) or {}
52
+ hits = {opt: sum(1 for p in pats if p.search(text)) for opt, pats in rules.items()}
53
+ hits = {o: h for o, h in hits.items() if h > 0 and (o in keys or q.type == "noul")}
54
+ n = len(keys)
55
+ if not hits:
56
+ probs = [1.0 / n] * n
57
+ elif q.type == "noul":
58
+ probs = [self.hit_confidence, 1.0 - self.hit_confidence]
59
+ elif q.type == "score":
60
+ winner = str(max(int(o) for o in hits))
61
+ probs = [(self.hit_confidence if o == winner else (1 - self.hit_confidence) / (n - 1)) for o in keys]
62
+ else:
63
+ winner = max(hits, key=hits.get)
64
+ probs = [(self.hit_confidence if o == winner else (1 - self.hit_confidence) / (n - 1)) for o in keys]
65
+ answers[k] = finalize_answer(q, probs, confidence=max(probs))
66
+ return DecisionResponse(
67
+ answers=answers,
68
+ latency=LatencyRecord(client_ms=self._now_ms() - t0, timestamp=time.time()),
69
+ provider=ProviderRecord(adapter_id=self.adapter_id, model_id_requested=self.model_id, cost_usd=0.0),
70
+ )
@@ -0,0 +1,4 @@
1
+ from .arms import arm_accuracy, label_noise_report, unknowable_report # noqa: F401
2
+ from .decomposition import decomposition_report # noqa: F401
3
+ from .meta_eval import label_order_report, leakage_report, short_circuit_report # noqa: F401
4
+ from .stats import cluster_bootstrap, holm, mcnemar, paired_bootstrap # noqa: F401
@@ -0,0 +1,46 @@
1
+ """Scoring for the control arms: unknowable (calibration ceiling), label-noise, corruption, short-circuit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from ..schemas import PredictionRow, TaskItem
8
+
9
+
10
+ def unknowable_report(rows: list[PredictionRow], question_key: str = "priority") -> dict:
11
+ """On items whose label depends on removed information, a calibrated model should spread mass. For the ticket
12
+ priority arm the tier is removed, so the truth is one of two adjacent levels: report mass on the two candidates,
13
+ max-probability, and how often confidence exceeds 0.7."""
14
+ rs = [r for r in rows if r.question_key == question_key and not r.error and r.controls.unknowable]
15
+ if not rs:
16
+ return {"n": 0}
17
+ conf = np.array([max(r.probs) for r in rs])
18
+ return {"n": len(rs), "mean_max_prob": float(conf.mean()), "frac_conf_over_0.7": float((conf > 0.7).mean()),
19
+ "frac_conf_over_0.9": float((conf > 0.9).mean()),
20
+ "accuracy_vs_hidden_truth": float(np.mean([r.correct for r in rs if r.correct is not None])),
21
+ "mean_entropy_bits": float(np.mean([-(np.array(r.probs) + 1e-12).dot(np.log2(np.array(r.probs) + 1e-12)) for r in rs]))}
22
+
23
+
24
+ def label_noise_report(rows: list[PredictionRow], items: list["TaskItem"]) -> dict:
25
+ """Manifest with a corrupted-label fraction: accuracy on corrupted rows should be near zero (the label is wrong, the
26
+ model should still answer the true thing) and confidence on them should not exceed confidence on clean rows.
27
+ Joined on task_id against the manifest so it works for rows produced by any runner version."""
28
+ noisy_ids = {it.task_id for it in items if it.label_provenance.noise_injected}
29
+ clean = [r for r in rows if not r.error and r.task_id not in noisy_ids]
30
+ noisy = [r for r in rows if not r.error and r.task_id in noisy_ids]
31
+
32
+ def f(rs):
33
+ acc = float(np.mean([r.correct for r in rs if r.correct is not None])) if rs else float("nan")
34
+ conf = float(np.mean([max(r.probs) for r in rs])) if rs else float("nan")
35
+ return acc, conf
36
+
37
+ ac, cc = f(clean)
38
+ an, cn = f(noisy)
39
+ return {"n_clean": len(clean), "n_noisy": len(noisy), "accuracy_clean": ac, "accuracy_on_corrupted_labels": an,
40
+ "confidence_clean": cc, "confidence_on_corrupted": cn,
41
+ "flag_more_confident_on_corrupted": bool(cn > cc + 0.02) if noisy else False}
42
+
43
+
44
+ def arm_accuracy(rows: list[PredictionRow], arm: str, question_key: str | None = None) -> float:
45
+ rs = [r for r in rows if r.arm == arm and r.correct is not None and (question_key is None or r.question_key == question_key)]
46
+ return float(np.mean([r.correct for r in rs])) if rs else float("nan")
@@ -0,0 +1,43 @@
1
+ """Suite I: downstream decision value from the cost matrices shipped in every Tier G manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from ..metrics.decision_value import expected_cost, value_of_calibration
10
+ from ..schemas import PredictionRow, TaskItem
11
+
12
+
13
+ def decision_value_report(rows: list[PredictionRow], items: list[TaskItem], escalate_cost: float | None = None,
14
+ thresholds: tuple[float, ...] = (0.5, 0.7, 0.9)) -> dict[str, Any]:
15
+ """Per question with a cost matrix: mean realised cost per 10k decisions under argmax, Bayes (uses probabilities)
16
+ and act-or-escalate at each threshold, plus regret vs oracle and the value of calibration (argmax minus Bayes)."""
17
+ by_task = {it.task_id: it for it in items}
18
+ out: dict[str, Any] = {}
19
+ keys = sorted({r.question_key for r in rows})
20
+ for k in keys:
21
+ cm = next((it.cost_matrix.get(k) for it in items if it.cost_matrix and k in it.cost_matrix), None)
22
+ if not cm:
23
+ continue
24
+ rs = [r for r in rows if r.question_key == k and not r.error and r.ground_truth in r.option_keys and r.task_id in by_task
25
+ and r.permutation_id == "p0" and r.framing_id == "f0"]
26
+ if len(rs) < 20:
27
+ continue
28
+ labels = rs[0].option_keys
29
+ C = np.array([[float(cm.get(str(t), {}).get(str(p), 1.0 if t != p else 0.0)) for p in labels] for t in labels])
30
+ P = np.array([r.probs for r in rs])
31
+ y = np.array([labels.index(r.ground_truth) for r in rs])
32
+ esc = escalate_cost if escalate_cost is not None else float(np.median(C[C > 0])) if (C > 0).any() else 1.0
33
+ res = {"n": len(rs), "labels": labels, "escalate_cost": esc,
34
+ "argmax": expected_cost(P, y, C, "argmax"), "bayes": expected_cost(P, y, C, "bayes"),
35
+ "value_of_calibration_per_10k": value_of_calibration(P, y, C) * 10_000}
36
+ for t in thresholds:
37
+ res[f"escalate@{t}"] = expected_cost(P, y, C, "threshold", escalate_cost=esc, threshold=t)
38
+ # trivial policies for reference
39
+ prior = np.bincount(y, minlength=len(labels)) / len(y)
40
+ Pprior = np.tile(prior, (len(y), 1))
41
+ res["majority_prior_bayes"] = expected_cost(Pprior, y, C, "bayes")
42
+ out[k] = res
43
+ return out
@@ -0,0 +1,47 @@
1
+ """Suite B decomposition analysis: holistic question vs sub-questions combined by a fixed rule vs fitted weights.
2
+
3
+ Rows must contain, per task_id, one holistic noul row (question_key == target) and one row per sub-question
4
+ (metadata.decomposition_of == target). Weights are fitted on the first half of task_ids and evaluated on the second.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from ..framing.expand import apply_decomposition_weights, decompose_fixed_rule, fit_decomposition_weights
12
+ from ..metrics.calibration import brier, ece
13
+ from ..schemas import PredictionRow
14
+
15
+
16
+ def decomposition_report(rows: list[PredictionRow], target: str, rule: str = "any") -> dict:
17
+ hol = {r.task_id: r for r in rows if r.question_key == target and not r.error and r.framing_id == "f0" and r.permutation_id == "p0"}
18
+ subs: dict[str, dict[str, PredictionRow]] = {}
19
+ for r in rows:
20
+ if r.metadata.get("decomposition_of") == target and not r.error and r.framing_id == "f0" and r.permutation_id == "p0":
21
+ subs.setdefault(r.task_id, {})[r.question_key] = r
22
+ tids = sorted(t for t in hol if t in subs)
23
+ if len(tids) < 20:
24
+ return {"n": len(tids), "note": "too few paired items"}
25
+ sub_keys = sorted({k for t in tids for k in subs[t]})
26
+ X = np.array([[subs[t][k].probs[0] if k in subs[t] else 0.5 for k in sub_keys] for t in tids])
27
+ y = np.array([1.0 if hol[t].ground_truth == "true" else 0.0 for t in tids])
28
+ p_hol = np.array([hol[t].probs[0] for t in tids])
29
+ p_rule = np.array([decompose_fixed_rule(dict(zip(sub_keys, x)), rule) for x in X])
30
+ half = len(tids) // 2
31
+ w = fit_decomposition_weights(X[:half], y[:half])
32
+ p_fit = apply_decomposition_weights(X[half:], w)
33
+
34
+ def summarise(p: np.ndarray, yy: np.ndarray) -> dict:
35
+ pred = p >= 0.5
36
+ P2 = np.stack([p, 1 - p], 1)
37
+ yi = (1 - yy).astype(int) # index 0 == "true"
38
+ return {"accuracy": float((pred == yy.astype(bool)).mean()), "brier": brier(P2, yi),
39
+ "ece15": ece(P2.max(1), (P2.argmax(1) == yi).astype(float))}
40
+
41
+ out = {"n": len(tids), "sub_questions": sub_keys, "rule": rule,
42
+ "holistic": summarise(p_hol, y), "fixed_rule": summarise(p_rule, y),
43
+ "holistic_eval_half": summarise(p_hol[half:], y[half:]), "fitted_weights_eval_half": summarise(p_fit, y[half:]),
44
+ "weights": dict(zip(sub_keys + ["bias"], [float(v) for v in w]))}
45
+ out["decomposition_gain_fixed"] = out["fixed_rule"]["accuracy"] - out["holistic"]["accuracy"]
46
+ out["decomposition_gain_fitted"] = out["fitted_weights_eval_half"]["accuracy"] - out["holistic_eval_half"]["accuracy"]
47
+ return out
@@ -0,0 +1,67 @@
1
+ """Benchmark audits: short-circuit, leakage, label-order bias, label-noise control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+
8
+ import numpy as np
9
+
10
+ from ..metrics.consistency import positional_bias_chi2
11
+ from ..schemas import PredictionRow, TaskItem
12
+
13
+
14
+ def _acc(rows: list[PredictionRow]) -> float:
15
+ c = [r.correct for r in rows if r.correct is not None]
16
+ return float(np.mean(c)) if c else float("nan")
17
+
18
+
19
+ def short_circuit_report(full: list[PredictionRow], state_only: list[PredictionRow], options_only: list[PredictionRow],
20
+ prior_acc: float, margin: float = 0.10) -> dict:
21
+ a_full, a_state, a_opts = _acc(full), _acc(state_only), _acc(options_only)
22
+ return {"accuracy_full": a_full, "accuracy_state_only": a_state, "accuracy_options_only": a_opts,
23
+ "accuracy_prior": prior_acc,
24
+ "flag_state_only": bool(a_state > prior_acc + margin), "flag_options_only": bool(a_opts > prior_acc + margin)}
25
+
26
+
27
+ def _ngrams(text: str, n: int) -> set[tuple[str, ...]]:
28
+ toks = re.findall(r"[a-z0-9]+", text.lower())
29
+ return {tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)}
30
+
31
+
32
+ def leakage_report(items: list[TaskItem], n: int = 1) -> dict:
33
+ """Fraction of items where the true option's key or description n-grams appear in the state."""
34
+ hits, total, per_q = 0, 0, Counter()
35
+ for it in items:
36
+ state = it.state_text()
37
+ sg = _ngrams(state, n)
38
+ for k, q in it.questions.items():
39
+ if q.type != "choice" or q.ground_truth is None:
40
+ continue
41
+ total += 1
42
+ opt = next((o for o in q.criteria if o.key == q.ground_truth), None) # type: ignore[union-attr]
43
+ if opt is None:
44
+ continue
45
+ key_words = set(re.findall(r"[a-z0-9]+", opt.key.lower()))
46
+ leak = bool(sg & {(w,) for w in key_words}) if n == 1 else bool(sg & _ngrams(opt.description, n))
47
+ if leak:
48
+ hits += 1
49
+ per_q[k] += 1
50
+ return {"n_choice_questions": total, "leak_rate": hits / total if total else float("nan"), "per_question": dict(per_q)}
51
+
52
+
53
+ def label_order_report(rows: list[PredictionRow]) -> dict:
54
+ """Over permuted rows, test whether argmax index prefers a position."""
55
+ by_k: dict[int, list[int]] = {}
56
+ for r in rows:
57
+ if r.primitive != "choice" or r.error or not r.argmax:
58
+ continue
59
+ by_k.setdefault(r.cardinality, []).append(r.option_keys.index(r.argmax))
60
+ return {k: positional_bias_chi2(np.array(v), k) for k, v in by_k.items()}
61
+
62
+
63
+ def label_noise_control(rows_clean: list[PredictionRow], rows_noisy: list[PredictionRow]) -> dict:
64
+ """On corrupted labels a model should be ~wrong at the corruption rate and NOT more confident."""
65
+ conf = lambda rs: float(np.mean([max(r.probs) for r in rs if not r.error])) if rs else float("nan")
66
+ return {"accuracy_clean": _acc(rows_clean), "accuracy_noisy_labels": _acc(rows_noisy),
67
+ "confidence_clean": conf(rows_clean), "confidence_noisy_labels": conf(rows_noisy)}
@@ -0,0 +1,76 @@
1
+ """Item-level statistics. The item (or framing group) is the unit; seeds are not."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ import numpy as np
8
+ from scipy.stats import binomtest
9
+
10
+
11
+ def paired_bootstrap(a: np.ndarray, b: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean,
12
+ n_boot: int = 10_000, seed: int = 0, ci: float = 0.95) -> dict[str, float]:
13
+ """Bootstrap CI for stat(a) - stat(b) over paired items."""
14
+ a, b = np.asarray(a, float), np.asarray(b, float)
15
+ assert a.shape == b.shape
16
+ rng = np.random.default_rng(seed)
17
+ n = len(a)
18
+ idx = rng.integers(0, n, size=(n_boot, n))
19
+ diffs = np.array([stat(a[i]) - stat(b[i]) for i in idx])
20
+ lo, hi = np.quantile(diffs, [(1 - ci) / 2, 1 - (1 - ci) / 2])
21
+ point = float(stat(a) - stat(b))
22
+ return {"diff": point, "ci_low": float(lo), "ci_high": float(hi),
23
+ "frac_sign_holds": float((np.sign(diffs) == np.sign(point)).mean()) if point != 0 else 0.5,
24
+ "n": int(n)}
25
+
26
+
27
+ def bootstrap_ci(x: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean, n_boot: int = 10_000, seed: int = 0,
28
+ ci: float = 0.95) -> tuple[float, float, float]:
29
+ x = np.asarray(x, float)
30
+ rng = np.random.default_rng(seed)
31
+ vals = np.array([stat(x[rng.integers(0, len(x), len(x))]) for _ in range(n_boot)])
32
+ lo, hi = np.quantile(vals, [(1 - ci) / 2, 1 - (1 - ci) / 2])
33
+ return float(stat(x)), float(lo), float(hi)
34
+
35
+
36
+ def mcnemar(correct_a: np.ndarray, correct_b: np.ndarray) -> dict[str, float]:
37
+ a, b = np.asarray(correct_a, bool), np.asarray(correct_b, bool)
38
+ n01 = int((a & ~b).sum())
39
+ n10 = int((~a & b).sum())
40
+ if n01 + n10 == 0:
41
+ return {"n_a_only": n01, "n_b_only": n10, "p": 1.0}
42
+ p = binomtest(n01, n01 + n10, 0.5).pvalue
43
+ return {"n_a_only": n01, "n_b_only": n10, "p": float(p)}
44
+
45
+
46
+ def cluster_bootstrap(values: np.ndarray, clusters: np.ndarray, stat: Callable[[np.ndarray], float] = np.mean,
47
+ n_boot: int = 5_000, seed: int = 0) -> tuple[float, float, float]:
48
+ """Resample clusters (e.g. task_id when paraphrases are pooled) rather than rows."""
49
+ values, clusters = np.asarray(values, float), np.asarray(clusters)
50
+ uniq = np.unique(clusters)
51
+ groups = [values[clusters == c] for c in uniq]
52
+ rng = np.random.default_rng(seed)
53
+ vals = []
54
+ for _ in range(n_boot):
55
+ pick = rng.integers(0, len(groups), len(groups))
56
+ vals.append(stat(np.concatenate([groups[i] for i in pick])))
57
+ lo, hi = np.quantile(vals, [0.025, 0.975])
58
+ return float(stat(values)), float(lo), float(hi)
59
+
60
+
61
+ def holm(pvals: dict[str, float], alpha: float = 0.05) -> dict[str, bool]:
62
+ items = sorted(pvals.items(), key=lambda kv: kv[1])
63
+ m = len(items)
64
+ out, stop = {}, False
65
+ for i, (k, p) in enumerate(items):
66
+ if not stop and p <= alpha / (m - i):
67
+ out[k] = True
68
+ else:
69
+ stop = True
70
+ out[k] = False
71
+ return out
72
+
73
+
74
+ def power_note(n: int) -> str:
75
+ half_width = 1.96 * np.sqrt(0.25 / n) * 100
76
+ return f"n={n}: accuracy 95% CI half-width about ±{half_width:.1f} points"