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
sys1bench/cli.py ADDED
@@ -0,0 +1,434 @@
1
+ """sys1bench CLI.
2
+
3
+ sys1bench generate support_tickets --n 1000 --seed 42 --out data/manifests/generated/tickets.jsonl
4
+ sys1bench run --manifest ... --adapter jev_openrouter --model typesafe/jev-1.13 --framings support_tickets
5
+ sys1bench score --predictions results/preds.jsonl --out results/summary.json
6
+ sys1bench canary --adapter jev_openrouter --model typesafe/jev-1.13
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import typer
16
+ import yaml
17
+
18
+ from . import __version__
19
+ from .adapters import get_adapter, list_adapters
20
+ from .data import framings_path
21
+ from .framing import apply_corruption, expand_framings, permute_options, strip_options, strip_state
22
+ from .framing.expand import load_framings
23
+ from .generators import GENERATORS, get_generator
24
+ from .report import framing_scorecard, group_rows, markdown_table, scorecard
25
+ from .runners import ResponseCache, load_manifest, run_items, write_manifest, write_predictions
26
+ from .runners.benchmark_runner import load_predictions
27
+ from .runners.canary import run_canary
28
+
29
+ app = typer.Typer(add_completion=False, help="sys1bench: benchmark harness for typed System One decision models (choice / score / noul).")
30
+
31
+
32
+ @app.callback(invoke_without_command=True)
33
+ def _main(ctx: typer.Context, version: bool = typer.Option(False, "--version", "-V", help="Print version and exit.")):
34
+ if version:
35
+ typer.echo(f"sys1bench {__version__}")
36
+ raise typer.Exit()
37
+ if ctx.invoked_subcommand is None:
38
+ typer.echo(ctx.get_help())
39
+
40
+
41
+ def _adapter_from(adapter: str | None, model: str | None, config: str | None):
42
+ cfg = {}
43
+ if config:
44
+ cfg = yaml.safe_load(Path(config).read_text()) or {}
45
+ adapter = adapter or cfg.pop("adapter")
46
+ if model:
47
+ cfg["model_id"] = model
48
+ return get_adapter(adapter, **cfg)
49
+
50
+
51
+ @app.command()
52
+ def adapters():
53
+ """List registered adapters (including entry-point plugins)."""
54
+ for a in list_adapters():
55
+ typer.echo(a)
56
+
57
+
58
+ @app.command()
59
+ def generators():
60
+ for g in sorted(GENERATORS):
61
+ typer.echo(f"{g}@{GENERATORS[g].version}")
62
+
63
+
64
+ @app.command()
65
+ def generate(name: str, out: Path, n: int = 1000, seed: int = 42, cardinality: int | None = None,
66
+ target_tokens: int | None = None, distractor_density: float = 0.0, label_noise: float = 0.0,
67
+ unknowable_frac: float = 0.0, none_correct_frac: float = 0.0, rules_out: Path | None = None):
68
+ """Generate a Tier G manifest (and optional regex rules file)."""
69
+ g = get_generator(name, n=n, seed=seed, cardinality=cardinality, target_tokens=target_tokens,
70
+ distractor_density=distractor_density, label_noise=label_noise, unknowable_frac=unknowable_frac,
71
+ none_correct_frac=none_correct_frac)
72
+ items = g.generate()
73
+ write_manifest(items, out)
74
+ if rules_out:
75
+ rules_out.write_text(yaml.safe_dump(g.regex_rules(), sort_keys=False))
76
+ typer.echo(f"wrote {len(items)} items to {out} ({g.generator_id})")
77
+
78
+
79
+ @app.command()
80
+ def run(manifest: Path, out: Path, adapter: str | None = None, model: str | None = None, config: Path | None = None,
81
+ framings: Path | None = None, permutations: int = 0, corruption: bool = False, short_circuit: bool = False,
82
+ cache: Path = Path("cache.sqlite"), concurrency: int = 1, suite: str | None = None, limit: int | None = None):
83
+ """Run a manifest through an adapter with optional framing / permutation / control expansions."""
84
+ ad = _adapter_from(adapter, model, str(config) if config else None)
85
+ items = load_manifest(manifest)
86
+ if limit:
87
+ items = items[:limit]
88
+ if hasattr(ad, "fit"):
89
+ ad.fit(items)
90
+ rows_all = []
91
+ if framings:
92
+ items = expand_framings(items, load_framings(framings_path(framings)))
93
+ if permutations:
94
+ items = permute_options(items, permutations)
95
+ c = ResponseCache(cache)
96
+ rows_all += run_items(items, ad, c, suite=suite, arm="main", concurrency=concurrency, progress=True)
97
+ base = [i for i in items if i.permutation_id == "p0" and all(q.framing_id == "f0" for q in i.questions.values())]
98
+ if corruption:
99
+ rows_all += run_items(apply_corruption(base), ad, c, suite=suite, arm="corruption", concurrency=concurrency)
100
+ if short_circuit:
101
+ rows_all += run_items(strip_state(base), ad, c, suite=suite, arm="state_only", concurrency=concurrency)
102
+ rows_all += run_items(strip_options(base), ad, c, suite=suite, arm="options_only", concurrency=concurrency)
103
+ write_predictions(rows_all, out)
104
+ typer.echo(f"wrote {len(rows_all)} prediction rows to {out}; cache size {len(c)}")
105
+
106
+
107
+ @app.command()
108
+ def score(predictions: list[Path], out: Path = Path("results/summary.json"), table: Path | None = None):
109
+ """Score prediction files into scorecards (per model x question) and a markdown table."""
110
+ rows = []
111
+ for p in predictions:
112
+ rows += load_predictions(p)
113
+ main = [r for r in rows if r.arm in (None, "main")]
114
+ cards = {}
115
+ for (model, qk), rs in group_rows(main, "model_id", "question_key").items():
116
+ cards[f"{model} / {qk}"] = scorecard(rs)
117
+ cards[f"{model} / {qk}"]["framing_variants"] = framing_scorecard([r for r in rows if r.model_id == model and r.question_key == qk])
118
+ out.parent.mkdir(parents=True, exist_ok=True)
119
+ out.write_text(json.dumps(cards, indent=1, default=str))
120
+ md = markdown_table(cards)
121
+ if table:
122
+ table.write_text(md)
123
+ typer.echo(md)
124
+
125
+
126
+ @app.command()
127
+ def canary(adapter: str, model: str | None = None, manifest: Path = Path("data/canary/canary.jsonl"),
128
+ store: Path = Path("results/canary"), config: Path | None = None):
129
+ """Run the drift canary for a hosted model."""
130
+ ad = _adapter_from(adapter, model, str(config) if config else None)
131
+ res = run_canary(load_manifest(manifest), ad, store)
132
+ typer.echo(json.dumps(res, indent=1))
133
+ if res["drift_suspected"]:
134
+ raise typer.Exit(code=2)
135
+
136
+
137
+
138
+ @app.command("sweep-cardinality")
139
+ def sweep_cardinality(out: Path, adapter: Optional[str] = None, model: Optional[str] = None, config: Optional[Path] = None,
140
+ ks: str = "2,4,6,8,10,12", n: int = 300, seed: int = 42, cache: Path = Path("cache.sqlite"), concurrency: int = 1,
141
+ generator: str = "support_tickets", question_key: str = "queue"):
142
+ """Suite C: accuracy / ECE-over-floor / latency vs number of options (use --generator rag_relevance --question-key best_passage for K up to 255)."""
143
+ from .runners.sweeps import cardinality_sweep
144
+
145
+ ad = _adapter_from(adapter, model, str(config) if config else None)
146
+ res = cardinality_sweep(ad, [int(k) for k in ks.split(",")], n=n, seed=seed, generator=generator, question_key=question_key,
147
+ cache=ResponseCache(cache), concurrency=concurrency)
148
+ out.parent.mkdir(parents=True, exist_ok=True)
149
+ out.write_text(json.dumps(res, indent=1, default=str))
150
+ for k, v in res.items():
151
+ typer.echo(f"K={k:3d} n={v.get('n')} acc={v.get('accuracy', float('nan')):.3f} ECE/floor={v.get('ece_over_floor', float('nan')):.2f} p50={v.get('latency', {}).get('p50', float('nan')):.0f}ms")
152
+
153
+
154
+ @app.command("sweep-length")
155
+ def sweep_length(out: Path, adapter: Optional[str] = None, model: Optional[str] = None, config: Optional[Path] = None,
156
+ lengths: str = "128,256,512,1024,2048,4096", n: int = 300, seed: int = 42, cache: Path = Path("cache.sqlite"), concurrency: int = 1):
157
+ """Suite C: accuracy / truncation / latency vs state length in approximate tokens."""
158
+ from .runners.sweeps import length_sweep
159
+
160
+ ad = _adapter_from(adapter, model, str(config) if config else None)
161
+ res = length_sweep(ad, [int(x) for x in lengths.split(",")], n=n, seed=seed, cache=ResponseCache(cache), concurrency=concurrency)
162
+ out.parent.mkdir(parents=True, exist_ok=True)
163
+ out.write_text(json.dumps(res, indent=1, default=str))
164
+ for L, v in res.items():
165
+ q = v["by_question"].get("queue", {})
166
+ typer.echo(f"L={L:5d} tokens~{v['state_tokens_mean']:.0f} queue acc={q.get('accuracy', float('nan')):.3f} trunc={q.get('truncation_rate', float('nan')):.2f} err={q.get('error_rate', float('nan')):.2f} p50={q.get('latency', {}).get('p50', float('nan')):.0f}ms")
167
+
168
+
169
+ @app.command("sweep-budget")
170
+ def sweep_budget(out: Path, budgets: str = "64,128,192,256,384,512", k: int = 12, n: int = 300, seed: int = 42,
171
+ checkpoint: str = "english", max_len: int = 1024, device: str = "cuda", cache: Path = Path("cache.sqlite")):
172
+ """Suite C (Laya): accuracy at fixed K as the option token budget head_max_len grows."""
173
+ from .runners.sweeps import budget_sweep
174
+
175
+ def factory(b: int):
176
+ return get_adapter("laya_local", checkpoint=checkpoint, head_max_len=b, max_len=max_len, device=device)
177
+
178
+ res = budget_sweep(factory, [int(b) for b in budgets.split(",")], k=k, n=n, seed=seed, cache=ResponseCache(cache))
179
+ out.parent.mkdir(parents=True, exist_ok=True)
180
+ out.write_text(json.dumps(res, indent=1, default=str))
181
+ for b, v in res.items():
182
+ typer.echo(f"head_max_len={b:4d} (~{v['tokens_per_option_approx']:.0f} tok/option) acc={v.get('accuracy', float('nan')):.3f} ECE/floor={v.get('ece_over_floor', float('nan')):.2f}")
183
+
184
+
185
+ @app.command()
186
+ def interference(manifest: Path, out: Path, target: str = "queue", adapter: Optional[str] = None, model: Optional[str] = None,
187
+ config: Optional[Path] = None, qs: str = "2,5,10,20", limit: int = 300, cache: Path = Path("cache.sqlite"), concurrency: int = 1):
188
+ """Suite E: does co-asking other questions change the target question's answer?"""
189
+ from .runners.sweeps import interference_sweep
190
+
191
+ ad = _adapter_from(adapter, model, str(config) if config else None)
192
+ items = load_manifest(manifest)[:limit]
193
+ res = interference_sweep(ad, items, target, qs=[0] + [int(q) for q in qs.split(",")], cache=ResponseCache(cache), concurrency=concurrency)
194
+ out.parent.mkdir(parents=True, exist_ok=True)
195
+ out.write_text(json.dumps(res, indent=1, default=str))
196
+ typer.echo(f"alone: acc={res['alone'].get('accuracy', float('nan')):.3f}")
197
+ for kind, d in res["by_kind"].items():
198
+ for Q, v in d.items():
199
+ typer.echo(f"{kind:11s} Q={Q:2d} JSD={v['mean_jsd_vs_alone']:.4f} flips={v['argmax_flip_rate']:.3f} dAcc={v['accuracy_delta_vs_alone']:+.3f} p50={v['latency_p50_ms']:.0f}ms cost/q={v['cost_per_question_usd']}")
200
+
201
+
202
+ @app.command()
203
+ def report(results_dir: Path, out: Optional[Path] = None, title: str = "sys1bench results", latex: Optional[Path] = None,
204
+ html: Optional[Path] = None):
205
+ """Build the markdown results report (hosted and local kept apart); optionally LaTeX headline tables and an HTML dashboard."""
206
+ from .report.results_doc import build_report
207
+
208
+ dirs = [results_dir] if (results_dir / "preds_tickets.jsonl").exists() else sorted(p for p in results_dir.iterdir() if p.is_dir() and not p.name.startswith("_"))
209
+ md = build_report(dirs, title=title)
210
+ out = out or (results_dir / "REPORT.md")
211
+ out.write_text(md)
212
+ typer.echo(f"wrote {out}")
213
+ if latex:
214
+ from .report.latex import latex_table
215
+ from .report.results_doc import _load_dir, _main_rows
216
+ from .report.scorecard import group_rows, scorecard
217
+
218
+ parts = []
219
+ for d in dirs:
220
+ data = _load_dir(d)
221
+ if not data["preds"]:
222
+ continue
223
+ cards = {}
224
+ model = None
225
+ for mname in ("tickets", "phish"):
226
+ for (qk,), rs in sorted(group_rows(_main_rows(data["preds"].get(mname, [])), "question_key").items()):
227
+ if qk.startswith("sub_"):
228
+ continue
229
+ c = scorecard(rs, floor_resamples=50)
230
+ c["primitive"] = rs[0].primitive
231
+ cards[f"{mname}.{qk}"] = c
232
+ model = rs[0].model_id
233
+ if cards:
234
+ parts.append(latex_table(cards, f"{model}: sys1bench Suite A headline metrics (median over framings; ECE relative to noise floor).", f"tab:{d.name}"))
235
+ latex.write_text("\n\n".join(parts))
236
+ typer.echo(f"wrote {latex}")
237
+ if html:
238
+ from .report.dashboard import build_dashboard
239
+
240
+ html.write_text(build_dashboard(dirs, title=title))
241
+ typer.echo(f"wrote {html}")
242
+
243
+
244
+ @app.command()
245
+ def robustness(out: Path, adapter: Optional[str] = None, model: Optional[str] = None, config: Optional[Path] = None,
246
+ generator: str = "support_tickets", n: int = 300, seed: int = 42, cache: Path = Path("cache.sqlite"), concurrency: int = 1):
247
+ """Suite D: distractors, none-of-the-above (with / without abstain option), surface perturbations, prior shift."""
248
+ from .runners.robustness import robustness_suite
249
+
250
+ ad = _adapter_from(adapter, model, str(config) if config else None)
251
+ res = robustness_suite(ad, n=n, seed=seed, generator=generator, cache=ResponseCache(cache), concurrency=concurrency)
252
+ out.parent.mkdir(parents=True, exist_ok=True)
253
+ out.write_text(json.dumps(res, indent=1, default=str))
254
+ typer.echo(json.dumps({k: v for k, v in res.items() if k in ("clean", "none_of_the_above", "prior_shift")}, indent=1, default=str)[:3000])
255
+ for kind, d in res.get("perturbations", {}).items():
256
+ typer.echo(f"perturb {kind:16s} " + " ".join(f"{q}: dAcc={v['accuracy_delta']:+.3f} JSD={v['jsd_vs_clean']:.4f}" for q, v in d.items()))
257
+ for dens, d in res.get("distractors", {}).items():
258
+ typer.echo(f"distractor {dens:<5} " + " ".join(f"{q}: dAcc={v['accuracy_delta']:+.3f} JSD={v['jsd_vs_clean']:.4f}" for q, v in d.items()))
259
+
260
+
261
+ @app.command()
262
+ def plots(results_dir: Path, out: Optional[Path] = None, questions: str = "tickets.queue,tickets.is_angry,tickets.priority,phish.is_phishing,phish.attack_class,phish.urgency"):
263
+ """Render reliability diagrams, risk-coverage curves and framing-range charts for every model dir under results_dir."""
264
+ from .report.plots import framing_range_plot, reliability_diagram, risk_coverage_plot
265
+ from .report.scorecard import framing_scorecard
266
+ from .runners.benchmark_runner import load_predictions
267
+
268
+ out = out or (results_dir / "plots")
269
+ dirs = [results_dir] if (results_dir / "preds_tickets.jsonl").exists() else sorted(p for p in results_dir.iterdir() if p.is_dir() and (p / "preds_tickets.jsonl").exists())
270
+ for spec in questions.split(","):
271
+ mname, qk = spec.split(".")
272
+ by_model: dict[str, list] = {}
273
+ acc_by_model: dict[str, dict[str, float]] = {}
274
+ for d in dirs:
275
+ f = d / f"preds_{mname}.jsonl"
276
+ if not f.exists():
277
+ continue
278
+ rows = [r for r in load_predictions(f) if r.question_key == qk and r.arm in (None, "main")]
279
+ if not rows:
280
+ continue
281
+ label = rows[0].model_id
282
+ by_model[label] = [r for r in rows if r.permutation_id == "p0" and r.framing_id == "f0"]
283
+ acc_by_model[label] = framing_scorecard(rows)["accuracy_by_framing"]
284
+ if not by_model:
285
+ continue
286
+ reliability_diagram(by_model, out / f"reliability_{mname}_{qk}.png", title=f"{mname}.{qk}: reliability (canonical framing)")
287
+ risk_coverage_plot(by_model, out / f"risk_coverage_{mname}_{qk}.png", title=f"{mname}.{qk}: risk-coverage")
288
+ framing_range_plot(acc_by_model, out / f"framing_{mname}_{qk}.png", title=f"{mname}.{qk}: accuracy across framings")
289
+ typer.echo(f"{spec}: {len(by_model)} models")
290
+ # sweep figures for every *_sweeps dir (or the dir itself)
291
+ from .report.plots import interference_heatmap, scaling_plot
292
+
293
+ sweep_dirs = [results_dir] if any(results_dir.glob("cardinality_*.json")) else sorted(p for p in results_dir.iterdir() if p.is_dir() and any(p.glob("*.json")))
294
+ for sd in sweep_dirs:
295
+ tag = sd.name.replace("_sweeps", "")
296
+ for f in sd.glob("interference_*.json"):
297
+ res = json.loads(f.read_text())
298
+ res["by_kind"] = {k: {int(q): v for q, v in d.items()} for k, d in res["by_kind"].items()}
299
+ interference_heatmap(res, out / f"interference_{tag}_{res['target']}.png", title=f"{tag}: JSD of `{res['target']}` vs asked alone")
300
+ for f in sd.glob("cardinality_*.json"):
301
+ res = {int(k): v for k, v in json.loads(f.read_text()).items()}
302
+ scaling_plot(res, out / f"cardinality_{tag}_acc.png", "options K", "accuracy", f"{tag}: accuracy vs K")
303
+ scaling_plot(res, out / f"cardinality_{tag}_ece.png", "options K", "ece_over_floor", f"{tag}: ECE/floor vs K")
304
+ for f in sd.glob("budget_*.json"):
305
+ res = {int(k): v for k, v in json.loads(f.read_text()).items()}
306
+ scaling_plot(res, out / f"budget_{tag}_acc.png", "head_max_len (tokens for all options)", "accuracy", f"{tag}: accuracy vs option budget")
307
+ for f in sd.glob("length_*.json"):
308
+ res = {int(k): {"accuracy": v["by_question"].get("priority", v["by_question"][next(iter(v["by_question"]))]).get("accuracy", float("nan"))}
309
+ for k, v in json.loads(f.read_text()).items()}
310
+ scaling_plot(res, out / f"length_{tag}_priority_acc.png", "state tokens (target)", "accuracy", f"{tag}: priority accuracy vs state length")
311
+ typer.echo(f"plots in {out}")
312
+
313
+
314
+
315
+ @app.command("noul-consistency")
316
+ def noul_consistency(manifest: Path, out: Path, key: str = "is_angry", adapter: Optional[str] = None, model: Optional[str] = None,
317
+ config: Optional[Path] = None, limit: int = 300, cache: Path = Path("cache.sqlite"), concurrency: int = 1,
318
+ portability_manifest: Optional[Path] = None):
319
+ """Suite G: complement consistency, choice-vs-noul agreement, threshold portability (to a second manifest)."""
320
+ from .runners.noul_consistency import choice_vs_noul, complement_test, threshold_portability
321
+
322
+ ad = _adapter_from(adapter, model, str(config) if config else None)
323
+ items = load_manifest(manifest)[:limit]
324
+ fr_name = items[0].questions[key].framing_group
325
+ negs = None
326
+ for name in ("support_tickets", "phishing_email"):
327
+ try:
328
+ spec = load_framings(framings_path(name)).get(fr_name or "", {})
329
+ except FileNotFoundError:
330
+ spec = {}
331
+ if spec.get("negations"):
332
+ negs = spec["negations"]
333
+ c = ResponseCache(cache)
334
+ res = {"complement": complement_test(ad, items, key, negs, c, concurrency), "choice_vs_noul": choice_vs_noul(ad, items, key, c, concurrency)}
335
+ if portability_manifest:
336
+ other = load_manifest(portability_manifest)[:limit]
337
+ okey = next((k for k, q in other[0].questions.items() if q.type == "noul"), None)
338
+ if okey:
339
+ ra = [r for r in run_items([_only(it, key) for it in items], ad, c, suite="G", arm="port_a", concurrency=concurrency)]
340
+ rb = [r for r in run_items([_only(it, okey) for it in other], ad, c, suite="G", arm="port_b", concurrency=concurrency)]
341
+ res["threshold_portability"] = {"from": f"{manifest.name}:{key}", "to": f"{portability_manifest.name}:{okey}", **threshold_portability(ra, rb)}
342
+ out.parent.mkdir(parents=True, exist_ok=True)
343
+ out.write_text(json.dumps(res, indent=1, default=str))
344
+ typer.echo(json.dumps(res, indent=1, default=str)[:2500])
345
+
346
+
347
+ def _only(item, key):
348
+ import copy
349
+
350
+ b = copy.deepcopy(item)
351
+ b.questions = {key: item.questions[key]}
352
+ return b
353
+
354
+
355
+ @app.command("decision-value")
356
+ def decision_value(predictions: Path, manifest: Path, out: Path, escalate_cost: Optional[float] = None):
357
+ """Suite I: realised cost per 10k decisions under argmax / Bayes / escalate policies using the manifest's cost matrices."""
358
+ from .analysis.decision_value import decision_value_report
359
+
360
+ rows = [r for r in load_predictions(predictions) if r.arm in (None, "main")]
361
+ res = decision_value_report(rows, load_manifest(manifest), escalate_cost)
362
+ out.parent.mkdir(parents=True, exist_ok=True)
363
+ out.write_text(json.dumps(res, indent=1, default=str))
364
+ for k, v in res.items():
365
+ typer.echo(f"{k:14s} n={v['n']} cost/10k: argmax {v['argmax']['cost_per_10k']:.0f} bayes {v['bayes']['cost_per_10k']:.0f} "
366
+ f"escalate@0.7 {v['escalate@0.7']['cost_per_10k']:.0f} (esc {v['escalate@0.7']['escalation_rate']:.2f}) prior {v['majority_prior_bayes']['cost_per_10k']:.0f} "
367
+ f"value of calibration/10k {v['value_of_calibration_per_10k']:+.0f}")
368
+
369
+
370
+ @app.command("ordinal-probes")
371
+ def ordinal_probes_cmd(out: Path, adapter: Optional[str] = None, model: Optional[str] = None, config: Optional[Path] = None,
372
+ cache: Path = Path("cache.sqlite"), concurrency: int = 1):
373
+ """Suite F: monotonicity ladders and 3/5/10-level scale invariance for `score`."""
374
+ from .runners.ordinal_probes import ordinal_probes
375
+
376
+ ad = _adapter_from(adapter, model, str(config) if config else None)
377
+ res = ordinal_probes(ad, ResponseCache(cache), concurrency)
378
+ out.parent.mkdir(parents=True, exist_ok=True)
379
+ out.write_text(json.dumps(res, indent=1, default=str))
380
+ for L, v in res["by_levels"].items():
381
+ typer.echo(f"{L:>2} levels: monotone ladders {v['monotonicity_rate']:.2f} exact {v['exact_accuracy']:.2f} E[level] by severity {[round(x, 2) for x in v['mean_expected_by_severity']]}")
382
+ typer.echo("scale invariance: " + json.dumps(res["scale_invariance"]))
383
+
384
+
385
+ @app.command("hybrid-sweep")
386
+ def hybrid_sweep(primary_preds: Path, fallback_preds: Path, out: Path, thresholds: str = "0.5,0.6,0.7,0.8,0.9,0.95,0.99"):
387
+ """Accuracy / latency / cost vs escalation rate for 'System One first, escalate below a threshold'."""
388
+ from .runners.hybrid_sweep import hybrid_curve
389
+
390
+ res = hybrid_curve([r for r in load_predictions(primary_preds) if r.arm in (None, "main") and r.permutation_id == "p0" and r.framing_id == "f0"],
391
+ [r for r in load_predictions(fallback_preds) if r.arm in (None, "main") and r.permutation_id == "p0" and r.framing_id == "f0"],
392
+ tuple(float(t) for t in thresholds.split(",")))
393
+ out.parent.mkdir(parents=True, exist_ok=True)
394
+ out.write_text(json.dumps(res, indent=1, default=str))
395
+ typer.echo(f"primary only: {res.get('primary_only')}")
396
+ typer.echo(f"fallback only: {res.get('fallback_only')}")
397
+ for t, v in res.get("curve", {}).items():
398
+ typer.echo(f"t={t:<5} esc={v['escalation_rate']:.2f} acc={v['accuracy']:.3f} p50={v['p50_ms']:.0f}ms cost/100k=${v['cost_per_100k']:.2f}")
399
+
400
+
401
+
402
+ @app.command()
403
+ def suite(which: str, out: Path, adapter: Optional[str] = None, model: Optional[str] = None, config: Optional[Path] = None,
404
+ n: int = 500, seed: int = 42, perms: int = 3, concurrency: int = 1, generators: str = "support_tickets,phishing_email",
405
+ manifests_from: Optional[Path] = None, budget: bool = False, sweeps_out: Optional[Path] = None):
406
+ """Run a suite (A, C, D, E, F, G, I or `all`) into OUT; sweeps go to SWEEPS_OUT (default OUT_sweeps). Portable replacement for scripts/."""
407
+ from .runners import suite as S
408
+
409
+ ad = _adapter_from(adapter, model, str(config) if config else None)
410
+ sw = sweeps_out or out.parent / f"{out.name}_sweeps"
411
+ which = which.upper()
412
+ todo = ["A", "C", "D", "E", "G", "F", "I"] if which == "ALL" else list(which)
413
+ for s in todo:
414
+ if s == "A":
415
+ S.suite_a(ad, out, n=n, seed=seed, perms=perms, generators=tuple(generators.split(",")), concurrency=concurrency, manifests_from=manifests_from, log=typer.echo)
416
+ elif s == "C":
417
+ S.suite_c(ad, sw, n=min(n, 150), seed=seed, concurrency=concurrency, budget=budget, log=typer.echo)
418
+ elif s == "D":
419
+ S.suite_d(ad, sw, n=min(n, 200), seed=seed, concurrency=concurrency, log=typer.echo)
420
+ elif s == "E":
421
+ S.suite_e(ad, sw, out / "tickets.jsonl", n=n, concurrency=concurrency, log=typer.echo)
422
+ elif s == "G":
423
+ S.suite_g(ad, sw, out / "tickets.jsonl", out / "phish.jsonl", n=n, concurrency=concurrency, log=typer.echo)
424
+ elif s == "F":
425
+ S.suite_f(ad, sw, concurrency=concurrency, log=typer.echo)
426
+ elif s == "I":
427
+ S.suite_i(out, sw, log=typer.echo)
428
+ else:
429
+ raise typer.BadParameter(f"unknown suite {s!r}")
430
+ typer.echo(f"done: {which} -> {out} (sweeps: {sw})")
431
+
432
+
433
+ if __name__ == "__main__":
434
+ app()
@@ -0,0 +1,37 @@
1
+ """Packaged data: paraphrase/criteria framing sets per generator and the fixed drift canary.
2
+
3
+ from sys1bench.data import framings_path, canary_path
4
+ framings_path("support_tickets") -> Path to the packaged YAML (or a user path if one is given)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ _PKG = "sys1bench.data"
13
+
14
+
15
+ def _pkg_file(sub: str, name: str) -> Path:
16
+ return Path(str(resources.files(_PKG).joinpath(sub, name)))
17
+
18
+
19
+ def framings_path(name_or_path: str | Path) -> Path:
20
+ """Resolve a framing set: an existing file path is returned as is; otherwise `name` maps to the packaged
21
+ `framings/<name>.yaml` (e.g. "support_tickets", "phishing_email")."""
22
+ p = Path(name_or_path)
23
+ if p.exists():
24
+ return p
25
+ stem = p.stem if p.suffix else str(name_or_path)
26
+ cand = _pkg_file("framings", f"{stem}.yaml")
27
+ if cand.exists():
28
+ return cand
29
+ raise FileNotFoundError(f"no framing set {name_or_path!r}; packaged: {sorted(list_framings())}")
30
+
31
+
32
+ def list_framings() -> list[str]:
33
+ return [p.stem for p in Path(str(resources.files(_PKG).joinpath("framings"))).glob("*.yaml")]
34
+
35
+
36
+ def canary_path() -> Path:
37
+ return _pkg_file("canary", "canary.jsonl")