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,212 @@
1
+ """Support ticket generator (queue: choice, is_angry: noul, priority: score).
2
+
3
+ Priority policy (stated in the question, not derivable from the ticket alone):
4
+ base urgency by template (0..2) + 1 if angry + 1 if customer tier is gold/enterprise, capped at 4.
5
+ The customer tier appears in a `Context:` line. In the *unknowable* arm the tier line is removed, so a
6
+ calibrated model must spread mass over two adjacent levels.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..schemas import Controls, LabelProvenance, Level, Option, Question, TaskItem
12
+ from .base import BaseGenerator, canary_for, estimate_tokens, pad_to_tokens, register_generator
13
+
14
+ QUEUES = [
15
+ ("billing", "Charges, refunds, invoices, duplicate payments, autopay problems"),
16
+ ("shipping", "Delivery delays, tracking, lost or damaged packages, wrong address"),
17
+ ("account", "Login, password, two-factor, email change, account lockout"),
18
+ ("technical", "App crashes, errors, features not working, connectivity"),
19
+ ("cancellation", "Ending a subscription or closing an account"),
20
+ ("sales", "Pre-purchase questions, upgrades, quotes, discounts"),
21
+ ("privacy", "Data export or deletion requests, consent, GDPR/CCPA"),
22
+ ("returns", "Returning a product, exchanges, return labels, refund status after return"),
23
+ ("fraud", "Unrecognised transactions, stolen card, suspicious login"),
24
+ ("feedback", "Compliments or suggestions with no action requested"),
25
+ ("accessibility", "Screen reader, captions, contrast or keyboard navigation problems"),
26
+ ("partnership", "Business development, reseller or affiliate enquiries"),
27
+ ]
28
+
29
+ # (queue, base_urgency, template)
30
+ TEMPLATES = [
31
+ ("billing", 1, "I was charged ${amt} twice on {day} for order #{order}. Only one charge should exist."),
32
+ ("billing", 0, "Can you send me the invoice for order #{order}? I need it for expenses."),
33
+ ("billing", 1, "My autopay failed on {day} and now there's a late fee of ${amt}. I had funds available."),
34
+ ("shipping", 1, "Order #{order} says delivered on {day} but nothing arrived. Tracking shows a photo of a door that isn't mine."),
35
+ ("shipping", 0, "What's the estimated delivery for order #{order}? It's been {days} days."),
36
+ ("shipping", 2, "Package #{order} arrived crushed and the contents are broken. I need this for an event on {day}."),
37
+ ("account", 2, "I'm locked out. Two-factor codes go to a phone number I no longer have. Order #{order} is pending."),
38
+ ("account", 0, "How do I change the email on my account? I want to switch to my work address."),
39
+ ("technical", 2, "The app crashes on launch since the update on {day}. I can't access anything. Error code {code}."),
40
+ ("technical", 1, "Export to CSV produces an empty file for reports over {days} days."),
41
+ ("cancellation", 1, "Please cancel my subscription before the renewal on {day}. Reference #{order}."),
42
+ ("cancellation", 0, "If I cancel, do I keep access until the end of the billing period?"),
43
+ ("sales", 0, "Do you offer a discount for annual billing on the team plan? We'd be about {days} seats."),
44
+ ("privacy", 1, "Please delete all my personal data under GDPR. Account reference #{order}."),
45
+ ("returns", 1, "I returned order #{order} on {day} and tracking shows it was received. When is the refund?"),
46
+ ("fraud", 2, "There's a ${amt} charge on {day} I did not make. My card ending {last4} may be compromised."),
47
+ ("feedback", 0, "Just wanted to say the new dashboard is much better. No action needed."),
48
+ ("accessibility", 1, "The checkout button has no label so my screen reader announces 'button'. Order #{order} stuck."),
49
+ ("partnership", 0, "We're a reseller in {region} and would like to discuss a partnership."),
50
+ ("technical", 1, "Notifications stopped arriving on {day}. Settings look correct. Device: {device}."),
51
+ ]
52
+
53
+ # Surface variants applied to the customer text (labels unaffected): openers, register, sign-offs, light typos.
54
+ OPENERS = ["", "", "Hi,", "Hello team,", "Hey,", "Good morning.", "To whom it may concern,", "Quick one:", "ok so", "Hi there —"]
55
+ REGISTER = {
56
+ "formal": lambda t: t, "casual": lambda t: t.replace("I was", "i was").replace("Please", "pls").replace("please", "pls").replace("Thanks", "thx"),
57
+ "terse": lambda t: t.rstrip(".") + ".", "verbose": lambda t: t + " I have been a customer for a while and this has never happened before.",
58
+ }
59
+ SIGNOFFS = ["", "", "Regards, {name}", "— {name}", "Thanks, {name}", "Sent from my phone", "Best, {name}"]
60
+ NAMES = ["Priya", "Jonas", "Amara", "Luis", "Mei", "Tomasz", "Fatima", "Noah"]
61
+ ANGRY = [
62
+ "This is completely unacceptable and I am furious.",
63
+ "Honestly, this is ridiculous. Fix it today or I'm done.",
64
+ "I've had it with this company. Absolutely appalling service.",
65
+ "Unbelievable. Every single time. I want this escalated NOW.",
66
+ ]
67
+ CALM = [
68
+ "Thanks in advance for your help.",
69
+ "Let me know if you need anything else from me.",
70
+ "Appreciate you looking into it.",
71
+ "No rush, but I'd like to get this sorted.",
72
+ ]
73
+ TIERS = ["free", "standard", "gold", "enterprise"]
74
+ DISTRACTORS = [
75
+ "P.S. I once had a delivery problem years ago with a different company; that got resolved.",
76
+ "Also I noticed the password reset page has a typo, not important.",
77
+ "By the way, a friend asked me whether you offer refunds, I told her I'd ask.",
78
+ 'Log excerpt (from another tab): {"event":"cancel_click","source":"pricing_page","ts":"2026-09-01T10:00:00Z"}',
79
+ ]
80
+
81
+ PRIORITY_LEVELS = [
82
+ Level(level=0, description="No action needed or informational."),
83
+ Level(level=1, description="Routine request; respond within 2 business days."),
84
+ Level(level=2, description="Customer blocked or money at stake; respond within 1 business day."),
85
+ Level(level=3, description="Urgent: blocked plus angry or high-value customer; respond within 4 hours."),
86
+ Level(level=4, description="Critical: blocked, high-value and angry, or active fraud; respond within 1 hour."),
87
+ ]
88
+ PRIORITY_INSTRUCTIONS = (
89
+ "Assign a priority from 0 (lowest) to 4 (highest). Policy: start from the urgency of the issue "
90
+ "(informational 0, routine 1, blocked or money at stake 2). Add 1 if the customer is angry. "
91
+ "Add 1 if the customer tier is gold or enterprise. Cap at 4."
92
+ )
93
+
94
+
95
+ @register_generator("support_tickets")
96
+ class SupportTicketGenerator(BaseGenerator):
97
+ version = "1.1.0"
98
+
99
+ def _queues(self) -> list[tuple[str, str]]:
100
+ k = self.knobs.cardinality or 6
101
+ k = max(2, min(k, len(QUEUES)))
102
+ used = {t[0] for t in TEMPLATES}
103
+ ordered = [q for q in QUEUES if q[0] in used] + [q for q in QUEUES if q[0] not in used]
104
+ return ordered[:k]
105
+
106
+ def generate(self) -> list[TaskItem]:
107
+ kb = self.knobs
108
+ rng = self.rng("items") # content stream: identical across control arms
109
+ ctl = self.rng("controls") # control stream: distractors, padding, label noise, unknowable, none-correct
110
+ queues = self._queues()
111
+ qkeys = [q[0] for q in queues]
112
+ templates = [t for t in TEMPLATES if t[0] in qkeys] or TEMPLATES
113
+ items: list[TaskItem] = []
114
+ for i in range(kb.n):
115
+ queue, base, tmpl = rng.choice(templates)
116
+ angry = rng.random() < 0.3
117
+ tier = rng.choice(TIERS)
118
+ unknowable = ctl.random() < kb.unknowable_frac
119
+ none_correct = ctl.random() < kb.none_correct_frac
120
+ distract = ctl.random() < kb.distractor_density
121
+ noise_draw = ctl.random() < kb.label_noise
122
+ body = tmpl.format(amt=rng.choice([19, 45, 89, 120, 249, 1200]), day=rng.choice(["Monday", "the 3rd", "yesterday", "Sept 14"]),
123
+ order=rng.randrange(10000, 99999), days=rng.randrange(2, 30), code=f"E{rng.randrange(100,999)}",
124
+ last4=rng.randrange(1000, 9999), region=rng.choice(["Germany", "Brazil", "Japan", "Canada"]),
125
+ device=rng.choice(["Pixel 8", "iPhone 15", "Galaxy S24"]))
126
+ closing = rng.choice(ANGRY if angry else CALM)
127
+ # 1.1.0 surface diversity (content stream so arms stay paired)
128
+ opener = rng.choice(OPENERS)
129
+ reg = rng.choice(list(REGISTER))
130
+ body = REGISTER[reg](body)
131
+ signoff = rng.choice(SIGNOFFS).format(name=rng.choice(NAMES))
132
+ if rng.random() < 0.15: # light typos in the body, never inside the order number
133
+ chars = list(body)
134
+ for _ in range(max(1, len(chars) // 60)):
135
+ j = rng.randrange(len(chars))
136
+ if chars[j].isalpha():
137
+ chars[j] = rng.choice("abcdefghijklmnopqrstuvwxyz")
138
+ body = "".join(chars)
139
+ age, open_t = rng.randrange(1, 60), rng.randrange(0, 4)
140
+ ctx = f"Context: customer tier {tier}; account age {age} months; open tickets {open_t}."
141
+ if unknowable:
142
+ ctx = f"Context: account age {age} months; open tickets {open_t}."
143
+ text = " ".join(x for x in (opener, body, closing, signoff) if x)
144
+ state = f"Customer: \"{text}\"\n{ctx}"
145
+ if distract:
146
+ state += "\n" + ctl.choice(DISTRACTORS)
147
+ state = pad_to_tokens(ctl, state, kb.target_tokens)
148
+
149
+ priority = min(4, base + (1 if angry else 0) + (1 if tier in ("gold", "enterprise") else 0))
150
+ queue_truth = queue
151
+ options = [Option(key=k, description=d) for k, d in queues]
152
+ if none_correct:
153
+ # drop the true queue from the option list; truth becomes the abstain key
154
+ options = [o for o in options if o.key != queue] or options
155
+ queue_truth = "none_of_the_above"
156
+ noise = False
157
+ if kb.label_noise and noise_draw:
158
+ noise = True
159
+ queue_truth = ctl.choice([o.key for o in options if o.key != queue_truth] or [queue_truth])
160
+ priority = ctl.choice([p for p in range(5) if p != priority])
161
+ angry = not angry
162
+
163
+ tid = f"tickets_{kb.seed}_{i:05d}"
164
+ items.append(TaskItem(
165
+ task_id=tid, tier="G", domain="support_triage", language=kb.language, state=state,
166
+ state_tokens=estimate_tokens(state),
167
+ questions={
168
+ "queue": Question(type="choice", instructions="Which queue should handle this ticket?", criteria=options,
169
+ ground_truth=queue_truth, framing_group="tickets.queue", allow_abstain=none_correct),
170
+ "is_angry": Question(type="noul", instructions="Is the customer expressing anger or hostility?",
171
+ ground_truth=angry, framing_group="tickets.is_angry"),
172
+ "priority": Question(type="score", instructions=PRIORITY_INSTRUCTIONS, criteria=PRIORITY_LEVELS,
173
+ ground_truth=priority, framing_group="tickets.priority"),
174
+ },
175
+ label_provenance=LabelProvenance(source="generator", generator=self.generator_id, seed=kb.seed, noise_injected=noise),
176
+ controls=Controls(unknowable=unknowable, none_correct=none_correct, distractor_density=kb.distractor_density),
177
+ cost_matrix=self.cost_matrices(),
178
+ canary=canary_for(tid),
179
+ metadata={"template_queue": queue, "base_urgency": base, "tier": tier, "angry": angry, "register": reg},
180
+ ))
181
+ return items
182
+
183
+ def regex_rules(self) -> dict:
184
+ return {
185
+ "tickets.queue": {
186
+ "billing": [r"charged", r"invoice", r"autopay", r"late fee"],
187
+ "shipping": [r"deliver", r"tracking", r"package", r"arrived"],
188
+ "account": [r"locked out", r"two-factor", r"change the email", r"password"],
189
+ "technical": [r"crash", r"error code", r"empty file", r"notifications stopped"],
190
+ "cancellation": [r"cancel"],
191
+ "sales": [r"discount", r"annual billing", r"seats"],
192
+ "privacy": [r"GDPR", r"delete all my personal data"],
193
+ "returns": [r"returned order", r"refund\?"],
194
+ "fraud": [r"did not make", r"compromised"],
195
+ "feedback": [r"no action needed", r"wanted to say"],
196
+ "accessibility": [r"screen reader"],
197
+ "partnership": [r"reseller", r"partnership"],
198
+ },
199
+ "tickets.is_angry": [r"unacceptable", r"furious", r"ridiculous", r"appalling", r"had it with", r"escalated"],
200
+ "tickets.priority": {"4": [r"fraud|did not make|compromised"], "3": [r"crash|locked out|crushed"],
201
+ "2": [r"charged .* twice|late fee|delivered .* nothing arrived|refund"],
202
+ "1": [r"cancel|GDPR|export|screen reader"], "0": [r"no action needed|discount|partnership|invoice"]},
203
+ }
204
+
205
+ def cost_matrices(self) -> dict[str, dict[str, dict[str, float]]]:
206
+ # asymmetric: routing fraud/billing wrongly is expensive; misrouting feedback is cheap
207
+ base = {q[0]: 1.0 for q in QUEUES}
208
+ expensive = {"fraud": 8.0, "billing": 3.0, "privacy": 4.0, "account": 3.0}
209
+ queue = {t: {p: (0.0 if p == t else expensive.get(t, base[t])) for p in base} for t in base}
210
+ priority = {str(t): {str(p): float(abs(t - p) * (2 if p < t else 1)) for p in range(5)} for t in range(5)}
211
+ angry = {"true": {"true": 0.0, "false": 3.0}, "false": {"true": 0.5, "false": 0.0}}
212
+ return {"queue": queue, "priority": priority, "is_angry": angry}
@@ -0,0 +1,9 @@
1
+ from . import ( # noqa: F401
2
+ calibration,
3
+ consistency,
4
+ decision_value,
5
+ efficiency,
6
+ ordinal,
7
+ robustness,
8
+ selective,
9
+ )
@@ -0,0 +1,164 @@
1
+ """Calibration kernels. All functions take confidence/probability arrays and 0/1 correctness or one-hot labels.
2
+
3
+ Conventions: `probs` is (N, K); `y` is integer labels (N,); `conf` is max-probability (N,); `correct` is 0/1.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ from scipy.optimize import minimize_scalar
10
+
11
+
12
+ def _bins(conf: np.ndarray, m: int, scheme: str) -> np.ndarray:
13
+ if scheme == "width":
14
+ edges = np.linspace(0.0, 1.0, m + 1)
15
+ elif scheme == "mass":
16
+ edges = np.quantile(conf, np.linspace(0, 1, m + 1))
17
+ edges[0], edges[-1] = 0.0, 1.0
18
+ edges = np.unique(edges)
19
+ else:
20
+ raise ValueError(scheme)
21
+ idx = np.clip(np.searchsorted(edges, conf, side="right") - 1, 0, len(edges) - 2)
22
+ return idx
23
+
24
+
25
+ def ece(conf: np.ndarray, correct: np.ndarray, m: int = 15, scheme: str = "width") -> float:
26
+ conf, correct = np.asarray(conf, float), np.asarray(correct, float)
27
+ if len(conf) == 0:
28
+ return float("nan")
29
+ idx = _bins(conf, m, scheme)
30
+ total = 0.0
31
+ for b in np.unique(idx):
32
+ mask = idx == b
33
+ total += mask.mean() * abs(conf[mask].mean() - correct[mask].mean())
34
+ return float(total)
35
+
36
+
37
+ def mce(conf: np.ndarray, correct: np.ndarray, m: int = 15, scheme: str = "width") -> float:
38
+ conf, correct = np.asarray(conf, float), np.asarray(correct, float)
39
+ idx = _bins(conf, m, scheme)
40
+ gaps = [abs(conf[idx == b].mean() - correct[idx == b].mean()) for b in np.unique(idx)]
41
+ return float(max(gaps)) if gaps else float("nan")
42
+
43
+
44
+ def ece_noise_floor(conf: np.ndarray, m: int = 15, scheme: str = "width", resamples: int = 200,
45
+ seed: int = 0) -> tuple[float, float]:
46
+ """Expected ECE (mean, sd) of a *perfectly calibrated* model with this confidence histogram:
47
+ resample correctness ~ Bernoulli(conf). Report measured ECE / floor, not ECE alone."""
48
+ rng = np.random.default_rng(seed)
49
+ conf = np.asarray(conf, float)
50
+ vals = [ece(conf, rng.random(len(conf)) < conf, m, scheme) for _ in range(resamples)]
51
+ return float(np.mean(vals)), float(np.std(vals))
52
+
53
+
54
+ def smooth_ece(conf: np.ndarray, correct: np.ndarray, bandwidth: float = 0.05) -> float:
55
+ """Binning-free calibration error: kernel-smoothed |E[correct|conf] - conf| integrated over the
56
+ empirical confidence distribution (Gaussian kernel)."""
57
+ conf, correct = np.asarray(conf, float), np.asarray(correct, float)
58
+ if len(conf) == 0:
59
+ return float("nan")
60
+ d = (conf[:, None] - conf[None, :]) / bandwidth
61
+ w = np.exp(-0.5 * d**2)
62
+ smoothed_acc = (w * correct[None, :]).sum(1) / w.sum(1)
63
+ return float(np.mean(np.abs(smoothed_acc - conf)))
64
+
65
+
66
+ def brier(probs: np.ndarray, y: np.ndarray) -> float:
67
+ probs = np.asarray(probs, float)
68
+ onehot = np.zeros_like(probs)
69
+ onehot[np.arange(len(y)), np.asarray(y, int)] = 1.0
70
+ return float(((probs - onehot) ** 2).sum(1).mean())
71
+
72
+
73
+ def brier_decomposition(conf: np.ndarray, correct: np.ndarray, m: int = 15) -> dict[str, float]:
74
+ """Murphy decomposition on the top-label (confidence vs correctness) problem:
75
+ BS = reliability - resolution + uncertainty."""
76
+ conf, correct = np.asarray(conf, float), np.asarray(correct, float)
77
+ idx = _bins(conf, m, "width")
78
+ base = correct.mean()
79
+ rel = res = 0.0
80
+ for b in np.unique(idx):
81
+ mask = idx == b
82
+ rel += mask.mean() * (conf[mask].mean() - correct[mask].mean()) ** 2
83
+ res += mask.mean() * (correct[mask].mean() - base) ** 2
84
+ unc = base * (1 - base)
85
+ return {"reliability": float(rel), "resolution": float(res), "uncertainty": float(unc),
86
+ "brier_top": float(((conf - correct) ** 2).mean())}
87
+
88
+
89
+ def nll(probs: np.ndarray, y: np.ndarray, clip: float | None = 1e-4) -> float:
90
+ p = np.asarray(probs, float)[np.arange(len(y)), np.asarray(y, int)]
91
+ if clip is not None:
92
+ p = np.clip(p, clip, 1.0)
93
+ with np.errstate(divide="ignore"):
94
+ return float(-np.log(p).mean())
95
+
96
+
97
+ def zero_prob_on_truth(probs: np.ndarray, y: np.ndarray) -> int:
98
+ p = np.asarray(probs, float)[np.arange(len(y)), np.asarray(y, int)]
99
+ return int((p <= 0.0).sum())
100
+
101
+
102
+ def quantisation_report(probs: np.ndarray) -> dict[str, float | None]:
103
+ flat = np.asarray(probs, float).ravel()
104
+ flat = flat[np.isfinite(flat)]
105
+ step = None
106
+ for s in (0.1, 0.05, 0.01, 0.005, 0.001, 1e-4, 1e-5):
107
+ if np.allclose(np.round(flat / s) * s, flat, atol=s * 1e-3):
108
+ step = s
109
+ break
110
+ return {"step": step, "frac_exact_zero": float((flat == 0).mean()), "frac_exact_one": float((flat == 1).mean()),
111
+ "distinct_values": float(len(np.unique(np.round(flat, 6))))}
112
+
113
+
114
+ def fit_temperature(probs: np.ndarray, y: np.ndarray, eps: float = 1e-6) -> float:
115
+ """Single temperature minimising NLL on held-out data. T>1 means the model was over-confident."""
116
+ probs = np.clip(np.asarray(probs, float), eps, 1.0)
117
+ logits = np.log(probs)
118
+ y = np.asarray(y, int)
119
+
120
+ def loss(t: float) -> float:
121
+ z = logits / t
122
+ z = z - z.max(1, keepdims=True)
123
+ logp = z - np.log(np.exp(z).sum(1, keepdims=True))
124
+ return float(-logp[np.arange(len(y)), y].mean())
125
+
126
+ res = minimize_scalar(loss, bounds=(0.05, 20.0), method="bounded")
127
+ return float(res.x)
128
+
129
+
130
+ def apply_temperature(probs: np.ndarray, t: float, eps: float = 1e-6) -> np.ndarray:
131
+ z = np.log(np.clip(np.asarray(probs, float), eps, 1.0)) / t
132
+ z = z - z.max(1, keepdims=True)
133
+ p = np.exp(z)
134
+ return p / p.sum(1, keepdims=True)
135
+
136
+
137
+ def calibration_summary(probs: np.ndarray, y: np.ndarray, m: int = 15, floor_resamples: int = 200) -> dict:
138
+ probs, y = np.asarray(probs, float), np.asarray(y, int)
139
+ conf = probs.max(1)
140
+ correct = (probs.argmax(1) == y).astype(float)
141
+ floor_mean, floor_sd = ece_noise_floor(conf, m, "width", floor_resamples)
142
+ e_w = ece(conf, correct, m, "width")
143
+ out = {
144
+ "n": len(y),
145
+ "accuracy": float(correct.mean()),
146
+ "mean_confidence": float(conf.mean()),
147
+ "ece_width_15": e_w,
148
+ "ece_mass_15": ece(conf, correct, m, "mass"),
149
+ "ece_width_10": ece(conf, correct, 10, "width"),
150
+ "ece_width_25": ece(conf, correct, 25, "width"),
151
+ "ece_floor_mean": floor_mean,
152
+ "ece_floor_sd": floor_sd,
153
+ "ece_over_floor": (e_w / floor_mean) if floor_mean > 0 else float("nan"),
154
+ "mce_15": mce(conf, correct, m),
155
+ "smooth_ece": smooth_ece(conf, correct),
156
+ "brier": brier(probs, y),
157
+ "nll_clipped": nll(probs, y, 1e-4),
158
+ "nll_raw": nll(probs, y, None),
159
+ "zero_prob_on_truth": zero_prob_on_truth(probs, y),
160
+ "overconfidence": float((conf - correct).mean()),
161
+ }
162
+ out.update(brier_decomposition(conf, correct, m))
163
+ out.update({f"quant_{k}": v for k, v in quantisation_report(probs).items()})
164
+ return out
@@ -0,0 +1,71 @@
1
+ """Distribution-stability kernels: JSD, flip rates, complement consistency, interference matrices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def jsd(p: np.ndarray, q: np.ndarray, eps: float = 1e-12) -> float:
9
+ p, q = np.asarray(p, float) + eps, np.asarray(q, float) + eps
10
+ p, q = p / p.sum(), q / q.sum()
11
+ m = 0.5 * (p + q)
12
+ return float(0.5 * (p * np.log2(p / m)).sum() + 0.5 * (q * np.log2(q / m)).sum())
13
+
14
+
15
+ def pairwise_mean_jsd(dists: list[np.ndarray]) -> float:
16
+ if len(dists) < 2:
17
+ return float("nan")
18
+ vals = [jsd(dists[i], dists[j]) for i in range(len(dists)) for j in range(i + 1, len(dists))]
19
+ return float(np.mean(vals))
20
+
21
+
22
+ def flip_rate(argmaxes: list[str]) -> float:
23
+ """Fraction of variants whose argmax differs from the canonical (first) variant."""
24
+ if len(argmaxes) < 2:
25
+ return float("nan")
26
+ return float(np.mean([a != argmaxes[0] for a in argmaxes[1:]]))
27
+
28
+
29
+ def complement_consistency(p_yes_q: np.ndarray, p_yes_not_q: np.ndarray) -> dict[str, float]:
30
+ dev = np.asarray(p_yes_q, float) + np.asarray(p_yes_not_q, float) - 1.0
31
+ return {"complement_mad": float(np.abs(dev).mean()), "complement_bias": float(dev.mean())}
32
+
33
+
34
+ def framing_summary(acc_by_framing: dict[str, float], per_item_jsd: list[float], per_item_flip: list[float]) -> dict:
35
+ friendly = {f: a for f, a in acc_by_framing.items() if not f.startswith("adv")}
36
+ adversarial = {f: a for f, a in acc_by_framing.items() if f.startswith("adv")}
37
+ accs = np.array(list(friendly.values()), float)
38
+ adv = np.array(list(adversarial.values()), float)
39
+ return {
40
+ "n_adversarial": int(len(adv)),
41
+ "accuracy_adversarial_min": float(adv.min()) if len(adv) else float("nan"),
42
+ "accuracy_adversarial_drop": float(np.median(accs) - adv.min()) if len(adv) and len(accs) else float("nan"),
43
+ "n_framings": len(accs),
44
+ "accuracy_median": float(np.median(accs)) if len(accs) else float("nan"),
45
+ "accuracy_min": float(accs.min()) if len(accs) else float("nan"),
46
+ "accuracy_max": float(accs.max()) if len(accs) else float("nan"),
47
+ "accuracy_range": float(accs.max() - accs.min()) if len(accs) else float("nan"),
48
+ "accuracy_std": float(accs.std(ddof=1)) if len(accs) > 1 else float("nan"),
49
+ "mean_pairwise_jsd": float(np.nanmean(per_item_jsd)) if per_item_jsd else float("nan"),
50
+ "argmax_flip_rate": float(np.nanmean(per_item_flip)) if per_item_flip else float("nan"),
51
+ }
52
+
53
+
54
+ def interference_matrix(alone: dict[str, np.ndarray], batched: dict[tuple[str, str], np.ndarray]) -> dict[str, dict[str, float]]:
55
+ """alone[q] = distribution when q asked alone; batched[(q, other)] = distribution of q when asked with `other`.
56
+ Returns M[q][other] = JSD(alone[q], batched[(q, other)])."""
57
+ out: dict[str, dict[str, float]] = {}
58
+ for (q, other), d in batched.items():
59
+ out.setdefault(q, {})[other] = jsd(alone[q], d)
60
+ return out
61
+
62
+
63
+ def positional_bias_chi2(argmax_indices: np.ndarray, k: int) -> dict[str, float]:
64
+ """Chi-square test that argmax index is uniform over positions under random permutations."""
65
+ from scipy.stats import chisquare
66
+
67
+ counts = np.bincount(np.asarray(argmax_indices, int), minlength=k)[:k]
68
+ if counts.sum() == 0:
69
+ return {"chi2": float("nan"), "p": float("nan"), "index0_share": float("nan")}
70
+ stat, p = chisquare(counts)
71
+ return {"chi2": float(stat), "p": float(p), "index0_share": float(counts[0] / counts.sum())}
@@ -0,0 +1,43 @@
1
+ """Downstream decision value: expected cost of acting on the model's answers under a cost matrix,
2
+ with and without an abstain/escalate option. This is the metric calibration is supposed to buy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import numpy as np
7
+
8
+
9
+ def expected_cost(probs: np.ndarray, truth_idx: np.ndarray, cost: np.ndarray, policy: str = "argmax",
10
+ escalate_cost: float | None = None, threshold: float | None = None) -> dict[str, float]:
11
+ """cost[t, p] = cost of predicting p when truth is t. Policies:
12
+ - argmax: act on argmax.
13
+ - bayes: choose p minimising sum_t probs[t] * cost[t, p] (uses the probabilities).
14
+ - threshold: argmax if max prob >= threshold else escalate (pays escalate_cost, assumed correct).
15
+ """
16
+ probs, truth_idx, cost = np.asarray(probs, float), np.asarray(truth_idx, int), np.asarray(cost, float)
17
+ n = len(truth_idx)
18
+ if policy == "argmax":
19
+ pred = probs.argmax(1)
20
+ realised = cost[truth_idx, pred]
21
+ esc = np.zeros(n, bool)
22
+ elif policy == "bayes":
23
+ pred = (probs @ cost).argmin(1)
24
+ realised = cost[truth_idx, pred]
25
+ esc = np.zeros(n, bool)
26
+ elif policy == "threshold":
27
+ assert threshold is not None and escalate_cost is not None
28
+ pred = probs.argmax(1)
29
+ esc = probs.max(1) < threshold
30
+ realised = np.where(esc, escalate_cost, cost[truth_idx, pred])
31
+ else:
32
+ raise ValueError(policy)
33
+ oracle = cost[truth_idx, truth_idx]
34
+ return {"mean_cost": float(realised.mean()), "regret_vs_oracle": float((realised - oracle).mean()),
35
+ "escalation_rate": float(esc.mean()), "cost_per_10k": float(realised.mean() * 10_000)}
36
+
37
+
38
+ def value_of_calibration(probs: np.ndarray, truth_idx: np.ndarray, cost: np.ndarray) -> float:
39
+ """Cost saved by acting on probabilities (Bayes) rather than argmax. Zero for symmetric costs;
40
+ positive when calibrated probabilities carry usable information about asymmetric risk."""
41
+ a = expected_cost(probs, truth_idx, cost, "argmax")["mean_cost"]
42
+ b = expected_cost(probs, truth_idx, cost, "bayes")["mean_cost"]
43
+ return float(a - b)
@@ -0,0 +1,35 @@
1
+ """Latency, throughput, and cost. Hosted and local models are summarised separately by the report layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def latency_summary(ms: np.ndarray) -> dict[str, float]:
9
+ ms = np.asarray(ms, float)
10
+ ms = ms[np.isfinite(ms)]
11
+ if len(ms) == 0:
12
+ return {k: float("nan") for k in ("p50", "p90", "p95", "p99", "mean", "n")}
13
+ return {"p50": float(np.percentile(ms, 50)), "p90": float(np.percentile(ms, 90)),
14
+ "p95": float(np.percentile(ms, 95)), "p99": float(np.percentile(ms, 99)),
15
+ "mean": float(ms.mean()), "n": float(len(ms))}
16
+
17
+
18
+ def throughput(n_decisions: int, n_questions: int, wall_seconds: float) -> dict[str, float]:
19
+ return {"decisions_per_s": n_decisions / wall_seconds if wall_seconds > 0 else float("nan"),
20
+ "questions_per_s": n_questions / wall_seconds if wall_seconds > 0 else float("nan")}
21
+
22
+
23
+ def cost_summary(cost_per_call: np.ndarray, questions_per_call: np.ndarray | None = None,
24
+ gpu_hour_usd: float | None = None, wall_seconds: float | None = None) -> dict[str, float]:
25
+ c = np.asarray(cost_per_call, float)
26
+ c = c[np.isfinite(c)]
27
+ out: dict[str, float] = {}
28
+ if len(c):
29
+ out["cost_per_100k_decisions"] = float(c.mean() * 100_000)
30
+ if questions_per_call is not None:
31
+ q = np.asarray(questions_per_call, float)
32
+ out["cost_per_100k_questions"] = float(c.sum() / max(q.sum(), 1) * 100_000)
33
+ if gpu_hour_usd is not None and wall_seconds is not None and len(c):
34
+ out["amortised_gpu_cost_per_100k_decisions"] = float(gpu_hour_usd * wall_seconds / 3600 / len(c) * 100_000)
35
+ return out
@@ -0,0 +1,80 @@
1
+ """Ordinal metrics for the `score` primitive. Levels are integers; `probs` is (N, L) over levels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy.stats import spearmanr
7
+
8
+
9
+ def expected_level(probs: np.ndarray, levels: np.ndarray) -> np.ndarray:
10
+ return np.asarray(probs, float) @ np.asarray(levels, float)
11
+
12
+
13
+ def mae(pred: np.ndarray, true: np.ndarray) -> float:
14
+ return float(np.abs(np.asarray(pred, float) - np.asarray(true, float)).mean())
15
+
16
+
17
+ def off_by_one_accuracy(pred: np.ndarray, true: np.ndarray) -> float:
18
+ return float((np.abs(np.asarray(pred, float) - np.asarray(true, float)) <= 1).mean())
19
+
20
+
21
+ def quadratic_weighted_kappa(pred: np.ndarray, true: np.ndarray, levels: np.ndarray) -> float:
22
+ levels = list(np.asarray(levels))
23
+ k = len(levels)
24
+ idx = {lv: i for i, lv in enumerate(levels)}
25
+ o = np.zeros((k, k))
26
+ for p, t in zip(pred, true):
27
+ o[idx[int(t)], idx[int(p)]] += 1
28
+ n = o.sum()
29
+ if n == 0:
30
+ return float("nan")
31
+ w = np.array([[(i - j) ** 2 / (k - 1) ** 2 for j in range(k)] for i in range(k)])
32
+ e = np.outer(o.sum(1), o.sum(0)) / n
33
+ denom = (w * e).sum()
34
+ return float(1.0 - (w * o).sum() / denom) if denom > 0 else float("nan")
35
+
36
+
37
+ def ranked_probability_score(probs: np.ndarray, true_idx: np.ndarray) -> float:
38
+ """Proper scoring rule for ordinal forecasts: mean squared difference of cumulative distributions,
39
+ normalised by L-1 so it lies in [0, 1]."""
40
+ probs = np.asarray(probs, float)
41
+ n, L = probs.shape
42
+ cdf = np.cumsum(probs, 1)
43
+ onehot = np.zeros_like(probs)
44
+ onehot[np.arange(n), np.asarray(true_idx, int)] = 1.0
45
+ ocdf = np.cumsum(onehot, 1)
46
+ return float(((cdf - ocdf) ** 2).sum(1).mean() / (L - 1))
47
+
48
+
49
+ def monotonicity_rate(ladders: list[np.ndarray]) -> float:
50
+ """Each ladder is the expected level along a sequence of stimuli with increasing true severity.
51
+ Returns the fraction of ladders that are monotone non-decreasing."""
52
+ if not ladders:
53
+ return float("nan")
54
+ return float(np.mean([bool(np.all(np.diff(np.asarray(l, float)) >= -1e-9)) for l in ladders]))
55
+
56
+
57
+ def ordinal_summary(probs: np.ndarray, true_levels: np.ndarray, levels: np.ndarray) -> dict:
58
+ probs, levels = np.asarray(probs, float), np.asarray(levels)
59
+ true_levels = np.asarray(true_levels)
60
+ idx = {lv: i for i, lv in enumerate(levels)}
61
+ true_idx = np.array([idx[int(t)] for t in true_levels])
62
+ argmax_lv = levels[probs.argmax(1)]
63
+ exp_lv = expected_level(probs, levels)
64
+ exp_round = levels[np.abs(exp_lv[:, None] - levels[None, :]).argmin(1)]
65
+ if len(set(true_levels.tolist())) > 1 and np.ptp(exp_lv) > 1e-12:
66
+ rho = spearmanr(exp_lv, true_levels).statistic
67
+ else:
68
+ rho = float("nan")
69
+ return {
70
+ "n": len(true_levels),
71
+ "exact_accuracy_argmax": float((argmax_lv == true_levels).mean()),
72
+ "exact_accuracy_expected": float((exp_round == true_levels).mean()),
73
+ "off_by_one_accuracy": off_by_one_accuracy(argmax_lv, true_levels),
74
+ "mae_argmax": mae(argmax_lv, true_levels),
75
+ "mae_expected": mae(exp_lv, true_levels),
76
+ "qwk_argmax": quadratic_weighted_kappa(argmax_lv, true_levels, levels),
77
+ "rps": ranked_probability_score(probs, true_idx),
78
+ "spearman_expected": float(rho) if rho == rho else float("nan"),
79
+ "argmax_vs_expected_disagreement": float((argmax_lv != exp_round).mean()),
80
+ }