strands-decider 0.1.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.
@@ -0,0 +1,3 @@
1
+ """strands-decider: a System One model -- typed, calibrated answers instead of text."""
2
+
3
+ __version__ = "0.1.0"
strands_decider/cli.py ADDED
@@ -0,0 +1,390 @@
1
+ """The strands-decider CLI: build data, train, calibrate, evaluate, serve, ask.
2
+
3
+ Only `ask` and `serve` show in `--help`. The training and evaluation commands stay
4
+ registered but hidden: `strands-decider train --help` works, and training/recipe.sh
5
+ calls them through `python -m strands_decider.cli`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from collections import Counter
13
+ from itertools import islice
14
+
15
+ import torch
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from .data import recipes as _recipes
21
+ from .data.format import Example, read_jsonl, write_jsonl
22
+ from .prompting import build_prompt
23
+ from .schema import ChoiceQuestion, NoulQuestion, Question, ScoreQuestion
24
+
25
+ app = typer.Typer(
26
+ name="strands-decider",
27
+ help="A System One model: typed, calibrated answers instead of text.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ )
31
+ data_app = typer.Typer(help="Build and inspect training corpora.", no_args_is_help=True)
32
+ app.add_typer(data_app, name="data", hidden=True)
33
+
34
+ console = Console()
35
+
36
+
37
+ # ---------------------------------------------------------------- data
38
+
39
+
40
+ @data_app.command("recipes")
41
+ def data_recipes() -> None:
42
+ """List the datasets that can be converted into training examples."""
43
+ table = Table(title="Available recipes")
44
+ for col in ("name", "dataset", "config", "split", "max examples"):
45
+ table.add_column(col)
46
+ for name in sorted(_recipes.SPECS):
47
+ s = _recipes.SPECS[name]
48
+ table.add_row(name, s.path, s.config or "-", s.split, str(s.max_examples))
49
+ console.print(table)
50
+
51
+
52
+ @data_app.command("build")
53
+ def data_build(
54
+ out: str = typer.Option("data/train.jsonl", help="Output JSONL (.gz supported)."),
55
+ recipes: list[str] | None = typer.Option(
56
+ None, "--recipe", "-r",
57
+ help="Recipe name; repeat. Default: all. If a recipe fails, the build writes nothing.",
58
+ ),
59
+ max_options: int = typer.Option(16, help="Cap options per example (large label sets)."),
60
+ max_chars: int = typer.Option(2000, help="Truncate each state to this many characters."),
61
+ max_examples: int | None = typer.Option(None, help="Per-recipe example cap."),
62
+ seed: int = typer.Option(0),
63
+ holdout: list[str] | None = typer.Option(
64
+ None, "--holdout", help="Task to route to a separate held-out file; repeat."
65
+ ),
66
+ ) -> None:
67
+ """Download datasets and convert them to (state, question, answer) examples.
68
+
69
+ Tasks named with --holdout are written to a sibling *.holdout.jsonl instead, so
70
+ you can measure generalisation to label sets the model never trained on.
71
+ """
72
+ names = list(recipes) if recipes else _recipes.available_recipes()
73
+ hold = set(holdout or [])
74
+
75
+ kept: list[Example] = []
76
+ held: list[Example] = []
77
+ for name in names:
78
+ console.print(f"[cyan]building[/] {name} ...")
79
+ try:
80
+ examples = _recipes.build_recipe(
81
+ name,
82
+ max_options=max_options,
83
+ max_chars=max_chars,
84
+ seed=seed,
85
+ max_examples=max_examples,
86
+ )
87
+ except Exception as exc:
88
+ # Stop here. A missing recipe removes rows and moves every later row to a new
89
+ # position. The frozen teacher and replay targets attach to rows by position.
90
+ hint = (' Install the "train" extra: pip install -e ".[train]".'
91
+ if isinstance(exc, ModuleNotFoundError) and exc.name == "datasets" else "")
92
+ raise SystemExit(f"recipe {name} failed, no corpus written: {exc!r}.{hint}") from exc
93
+ (held if name in hold else kept).extend(examples)
94
+ console.print(f" [green]{len(examples):,}[/] examples")
95
+
96
+ if not kept:
97
+ raise typer.Exit(code=1)
98
+
99
+ n = write_jsonl(out, kept)
100
+ console.print(f"[green]wrote[/] {n:,} examples -> {out}")
101
+ if held:
102
+ hpath = out.replace(".jsonl", ".holdout.jsonl")
103
+ console.print(f"[green]wrote[/] {write_jsonl(hpath, held):,} held-out -> {hpath}")
104
+
105
+
106
+ @data_app.command("stats")
107
+ def data_stats(path: str = typer.Argument(..., help="JSONL corpus to summarise.")) -> None:
108
+ """Show composition of a corpus: tasks, kinds, option-count distribution."""
109
+ tasks: Counter = Counter()
110
+ kinds: Counter = Counter()
111
+ nopts: Counter = Counter()
112
+ total = 0
113
+ for ex in read_jsonl(path):
114
+ tasks[ex.task] += 1
115
+ kinds[ex.kind] += 1
116
+ nopts[ex.n_options] += 1
117
+ total += 1
118
+
119
+ console.print(f"[bold]{total:,}[/] examples in {path}")
120
+ t = Table(title="By task")
121
+ t.add_column("task")
122
+ t.add_column("n", justify="right")
123
+ t.add_column("share", justify="right")
124
+ for name, c in tasks.most_common():
125
+ t.add_row(name, f"{c:,}", f"{100 * c / total:.1f}%")
126
+ console.print(t)
127
+
128
+ k = Table(title="By kind / option count")
129
+ k.add_column("kind")
130
+ k.add_column("n", justify="right")
131
+ for name, c in kinds.most_common():
132
+ k.add_row(name, f"{c:,}")
133
+ console.print(k)
134
+ console.print("option counts: " + ", ".join(f"{n}:{c:,}" for n, c in sorted(nopts.items())))
135
+
136
+
137
+ @data_app.command("peek")
138
+ def data_peek(
139
+ path: str = typer.Argument(...),
140
+ n: int = typer.Option(3, help="How many rendered prompts to print."),
141
+ ) -> None:
142
+ """Render a few examples exactly as the model will see them."""
143
+ for ex in islice(read_jsonl(path), n):
144
+ prompt, rq = build_prompt(ex.state, ex.to_question())
145
+ console.rule(f"{ex.task} / {ex.kind} / gold slot {ex.label}")
146
+ console.print(prompt)
147
+ console.print(f"[dim]slots: {rq.slot_labels}[/]")
148
+
149
+
150
+ # ---------------------------------------------------------------- train
151
+
152
+
153
+ @app.command("train", hidden=True)
154
+ def train_cmd(
155
+ config: str | None = typer.Option(None, "--config", "-c", help="YAML training config."),
156
+ train_file: list[str] | None = typer.Option(None, "--train-file", help="Override data."),
157
+ base_model: str | None = typer.Option(None, help="Override base model id."),
158
+ output_dir: str | None = typer.Option(None, help="Override checkpoint directory."),
159
+ epochs: int | None = typer.Option(None),
160
+ micro_batch_size: int | None = typer.Option(None),
161
+ max_steps: int | None = typer.Option(None, help="Hard step cap (smoke tests)."),
162
+ ) -> None:
163
+ """Train the slot heads and a LoRA adapter on the torso."""
164
+ from .train import TrainConfig, train
165
+
166
+ cfg = TrainConfig.from_yaml(config) if config else TrainConfig()
167
+ if train_file:
168
+ cfg.train_files = list(train_file)
169
+ for key, val in (
170
+ ("base_model", base_model),
171
+ ("output_dir", output_dir),
172
+ ("epochs", epochs),
173
+ ("micro_batch_size", micro_batch_size),
174
+ ("max_steps", max_steps),
175
+ ):
176
+ if val is not None:
177
+ setattr(cfg, key, val)
178
+ if not cfg.train_files:
179
+ raise typer.BadParameter("no training files; pass --train-file or set train_files")
180
+ train(cfg)
181
+
182
+
183
+ @app.command("calibrate", hidden=True)
184
+ def calibrate_cmd(
185
+ checkpoint: str = typer.Argument(...),
186
+ data: str = typer.Option(..., "--data", help="Held-out JSONL for fitting temperature."),
187
+ limit: int = typer.Option(4000, help="Cap examples used."),
188
+ batch_size: int = typer.Option(16),
189
+ split: str = typer.Option(
190
+ "calib", help="Half of --data to use: calib|test|all. Disjoint from `eval --split test`."
191
+ ),
192
+ ) -> None:
193
+ """Fit a temperature on held-out data and write it into the checkpoint.
194
+
195
+ Run this before serving. Without it, `confidence` is whatever the raw head
196
+ happened to produce, and the documented thresholds will not hold.
197
+
198
+ Defaults to the `calib` half so that `strands-decider eval --split test` scores rows the
199
+ temperature was never fitted on.
200
+ """
201
+
202
+ from .evaluate import calibrate_checkpoint, partition_examples, sample_examples
203
+
204
+ examples = sample_examples(partition_examples(list(read_jsonl(data)), split), limit)
205
+ result = calibrate_checkpoint(checkpoint, examples, batch_size=batch_size)
206
+ console.print(f"[green]global temperature = {result['temperature']:.4f}[/]")
207
+ by_kind = result.get("temperature_by_kind") or {}
208
+ if by_kind:
209
+ console.print(
210
+ "[green]per-primitive[/] "
211
+ + ", ".join(f"{k}={v:.4f}" for k, v in sorted(by_kind.items()))
212
+ + " (written to checkpoint)"
213
+ )
214
+ console.print("before: ", result["before"])
215
+ if "after_global" in result:
216
+ console.print("after global:", result["after_global"])
217
+ console.print("after: ", result["after"])
218
+
219
+
220
+ @app.command("eval", hidden=True)
221
+ def eval_cmd(
222
+ checkpoint: str = typer.Argument(...),
223
+ data: str = typer.Option(..., "--data"),
224
+ limit: int = typer.Option(4000),
225
+ batch_size: int = typer.Option(16),
226
+ out: str | None = typer.Option(None, help="Write the full report as JSON."),
227
+ raw: bool = typer.Option(False, "--raw", help="Ignore the fitted temperature."),
228
+ split: str = typer.Option(
229
+ "test", help="Half of --data to score: test|calib|all. Disjoint from `calibrate`."
230
+ ),
231
+ ) -> None:
232
+ """Accuracy, ECE, NLL and score MAE, broken down by task and confidence band.
233
+
234
+ Defaults to the `test` half, which `strands-decider calibrate` does not fit on.
235
+ """
236
+ from .evaluate import evaluate_checkpoint, partition_examples, sample_examples
237
+
238
+ examples = sample_examples(partition_examples(list(read_jsonl(data)), split), limit)
239
+ report = evaluate_checkpoint(
240
+ checkpoint, examples, batch_size=batch_size, apply_temperature=not raw
241
+ )
242
+
243
+ overall = report["overall"]
244
+ console.print(
245
+ f"[bold]overall[/] n={overall['n']:,} acc={overall['accuracy']:.3f} "
246
+ f"ece={overall['ece']:.3f} nll={overall['nll']:.3f} T={_fmt_temperature(report['temperature'])}"
247
+ )
248
+
249
+ for section in ("by_kind", "by_task", "by_confidence_band"):
250
+ rows = {k: v for k, v in report[section].items() if v}
251
+ if not rows:
252
+ continue
253
+ t = Table(title=section)
254
+ for col in ("group", "n", "accuracy", "ece", "mean conf"):
255
+ t.add_column(col, justify="right" if col != "group" else "left")
256
+ for name, m in rows.items():
257
+ t.add_row(
258
+ name, f"{m['n']:,}", f"{m['accuracy']:.3f}",
259
+ f"{m['ece']:.3f}", f"{m['mean_confidence']:.3f}",
260
+ )
261
+ console.print(t)
262
+
263
+ if out:
264
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
265
+ with open(out, "w", encoding="utf-8") as fh:
266
+ json.dump(report, fh, indent=2)
267
+ console.print(f"[green]report -> {out}[/]")
268
+
269
+
270
+ def _auto_device() -> str:
271
+ """Best available torch device: cuda > mps > cpu."""
272
+ if torch.cuda.is_available():
273
+ return "cuda"
274
+ if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
275
+ return "mps"
276
+ return "cpu"
277
+
278
+
279
+ def _fmt_temperature(t: float | dict[str, float]) -> str:
280
+ """Temperature is a scalar, or a {primitive: value} map once fitted per kind."""
281
+ if isinstance(t, dict):
282
+ return "{" + ", ".join(f"{k}:{v:.3f}" for k, v in sorted(t.items())) + "}"
283
+ return f"{t:.3f}"
284
+
285
+
286
+ # ---------------------------------------------------------------- serve / ask
287
+
288
+
289
+ @app.command("serve")
290
+ def serve_cmd(
291
+ checkpoint: str = typer.Argument(...),
292
+ host: str = typer.Option("127.0.0.1"),
293
+ port: int = typer.Option(8000),
294
+ device: str | None = typer.Option(
295
+ None, "--device",
296
+ help="Torch device (cuda|mps|cpu). Auto-detected when omitted.",
297
+ ),
298
+ no_prefix_cache: bool = typer.Option(False, "--no-prefix-cache"),
299
+ model_name: str | None = typer.Option(
300
+ None, "--model-name",
301
+ help="Value returned as `model` in responses. Defaults to the checkpoint basename.",
302
+ ),
303
+ ) -> None:
304
+ """Serve POST /v1/systemone. JevBench's typesafe adapter runs against it unchanged."""
305
+ from .server import serve
306
+
307
+ console.print(f"[green]serving[/] {checkpoint} on http://{host}:{port}")
308
+ serve(
309
+ checkpoint, host=host, port=port, device=device or _auto_device(),
310
+ use_prefix_cache=not no_prefix_cache, model_name=model_name,
311
+ )
312
+
313
+
314
+ @app.command("ask")
315
+ def ask_cmd(
316
+ checkpoint: str = typer.Argument(...),
317
+ state: str = typer.Option(..., "--state", "-s", help="The content to evaluate."),
318
+ noul: list[str] | None = typer.Option(None, "--noul", help="Yes/no question; repeat."),
319
+ choice: list[str] | None = typer.Option(
320
+ None, "--choice", help="'question?=opt1,opt2,opt3'; repeat."
321
+ ),
322
+ score: list[str] | None = typer.Option(
323
+ None, "--score", help="'question?=low,mid,high' (ascending); repeat."
324
+ ),
325
+ device: str | None = typer.Option(
326
+ None, "--device",
327
+ help="Torch device (cuda|mps|cpu). Auto-detected when omitted.",
328
+ ),
329
+ as_json: bool = typer.Option(False, "--json", help="Print the raw API response."),
330
+ ) -> None:
331
+ """Ask one state a set of typed questions from the command line."""
332
+ from .infer import load_engine
333
+
334
+ questions: dict[str, Question] = {}
335
+ for i, q in enumerate(noul or []):
336
+ questions[f"noul_{i}"] = NoulQuestion(instructions=q)
337
+ for i, spec in enumerate(choice or []):
338
+ q, _, opts = spec.partition("=")
339
+ if not opts:
340
+ raise typer.BadParameter(f"--choice needs 'question=opt1,opt2': {spec!r}")
341
+ questions[f"choice_{i}"] = ChoiceQuestion(
342
+ instructions=q, criteria={o.strip(): "" for o in opts.split(",") if o.strip()}
343
+ )
344
+ for i, spec in enumerate(score or []):
345
+ q, _, levels = spec.partition("=")
346
+ if not levels:
347
+ raise typer.BadParameter(f"--score needs 'question=low,high': {spec!r}")
348
+ questions[f"score_{i}"] = ScoreQuestion(
349
+ instructions=q, criteria=[s.strip() for s in levels.split(",") if s.strip()]
350
+ )
351
+ if not questions:
352
+ raise typer.BadParameter("ask at least one --noul / --choice / --score question")
353
+
354
+ engine = load_engine(checkpoint, device=device or _auto_device())
355
+ response = engine.ask(state, questions)
356
+
357
+ if as_json:
358
+ console.print_json(response.model_dump_json())
359
+ return
360
+
361
+ for name, ans in response.answers.items():
362
+ if ans.type == "noul":
363
+ console.print(f"[bold]{name}[/] noul = [cyan]{ans.noul:.3f}[/]")
364
+ elif ans.type == "choice":
365
+ console.print(
366
+ f"[bold]{name}[/] -> [cyan]{ans.choice}[/] "
367
+ f"(confidence {ans.confidence:.3f})"
368
+ )
369
+ for opt, p in sorted(ans.probabilities.items(), key=lambda kv: -kv[1]):
370
+ console.print(f" {opt:<24} {p:.3f}")
371
+ else:
372
+ console.print(
373
+ f"[bold]{name}[/] score = [cyan]{ans.score:.2f}[/] "
374
+ f"(confidence {ans.confidence:.3f})"
375
+ )
376
+ for lvl, p in ans.probabilities.items():
377
+ console.print(f" {lvl}: {ans.legend.get(lvl, ''):<40} {p:.3f}")
378
+
379
+
380
+ @app.command("info", hidden=True)
381
+ def info_cmd(checkpoint: str = typer.Argument(...)) -> None:
382
+ """Print a checkpoint's configuration."""
383
+ from .modeling import StrandsDeciderConfig, checkpoint_dir, config_path
384
+
385
+ cfg = StrandsDeciderConfig.from_json(config_path(checkpoint_dir(checkpoint)))
386
+ console.print_json(cfg.to_json())
387
+
388
+
389
+ if __name__ == "__main__": # pragma: no cover
390
+ app()
@@ -0,0 +1,12 @@
1
+ from .collate import CollatorConfig, SystemOneCollator
2
+ from .format import Example, load_examples, read_jsonl, split_examples, write_jsonl
3
+
4
+ __all__ = [
5
+ "CollatorConfig",
6
+ "Example",
7
+ "SystemOneCollator",
8
+ "load_examples",
9
+ "read_jsonl",
10
+ "split_examples",
11
+ "write_jsonl",
12
+ ]
@@ -0,0 +1,115 @@
1
+ """Answer adequacy from HelpSteer2 (nvidia/HelpSteer2, CC-BY-4.0): a request, a response,
2
+ and "is the response adequate?".
3
+
4
+ JevBench's `adequacy` family asks this question, and nothing in the training corpus
5
+ does: v17 answers "adequate" on 11 of that family's 12 public tasks. HelpSteer2 has
6
+ human ratings (Scale AI, 3-5 annotators per response, 0-4 scales) of responses written
7
+ by NVIDIA's in-house models, two responses per prompt.
8
+
9
+ adequate helpfulness >= 3 and correctness >= 3
10
+ inadequate helpfulness <= 1 or correctness <= 1
11
+ (the middle band is dropped as ambiguous)
12
+
13
+ Single-turn prompts only. Both splits are balanced to equal "yes" and "no": every
14
+ inadequate response is kept, and the adequate ones are drawn first from the same
15
+ prompts (the other response to a prompt whose first is inadequate), so that the
16
+ request alone does not predict the answer. The eval comes from HelpSteer2's
17
+ validation split and training rows only from its train split.
18
+
19
+ python -m strands_decider.data.adequacy --raw data/raw/helpsteer2 \
20
+ --out data/adequacy_hs2.jsonl --eval-out data/adequacy_hs2_eval.jsonl
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import json
26
+ import random
27
+ from collections import defaultdict
28
+
29
+ from .format import Example
30
+
31
+ TASK = "adequacy_hs2"
32
+ QUESTION = "Does the response adequately answer the request: correct, complete and on point?"
33
+ VARIANTS = [
34
+ QUESTION,
35
+ "Is this response good enough to send as the answer to the request?",
36
+ "Would a careful reviewer accept this response as an adequate answer to the request?",
37
+ "Does the response do what the request asks, without significant errors or omissions?",
38
+ ]
39
+ OPTIONS = [["false", "the response is not adequate"], ["true", "the response is adequate"]]
40
+
41
+
42
+ def verdict(r: dict) -> bool | None:
43
+ if r["helpfulness"] >= 3 and r["correctness"] >= 3:
44
+ return True
45
+ if r["helpfulness"] <= 1 or r["correctness"] <= 1:
46
+ return False
47
+ return None
48
+
49
+
50
+ def load(path: str) -> list[dict]:
51
+ with gzip.open(path, "rt", encoding="utf-8") as fh:
52
+ rows = [json.loads(line) for line in fh if line.strip()]
53
+ return [r for r in rows if "<extra_id_1>" not in r["prompt"] and verdict(r) is not None]
54
+
55
+
56
+ def balanced(rows: list[dict], seed: int = 0) -> list[dict]:
57
+ """Every inadequate response, and as many adequate ones, pair-mates first."""
58
+ rng = random.Random(seed)
59
+ bad = [r for r in rows if not verdict(r)]
60
+ bad_prompts = {r["prompt"] for r in bad}
61
+ good = [r for r in rows if verdict(r)]
62
+ mates = [r for r in good if r["prompt"] in bad_prompts]
63
+ others = [r for r in good if r["prompt"] not in bad_prompts]
64
+ rng.shuffle(mates)
65
+ rng.shuffle(others)
66
+ return bad + (mates + others)[:len(bad)]
67
+
68
+
69
+ def to_example(r: dict) -> Example:
70
+ state = f"Request:\n{r['prompt'].strip()}\n\nResponse:\n{r['response'].strip()}"
71
+ return Example(kind="noul", state=state, instructions=QUESTION, options=[list(o) for o in OPTIONS],
72
+ label=int(bool(verdict(r))), task=TASK, instruction_variants=list(VARIANTS))
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> None:
76
+ import argparse
77
+
78
+ from transformers import AutoTokenizer
79
+
80
+ from .format import write_jsonl
81
+ from .multistep import fits
82
+
83
+ ap = argparse.ArgumentParser(description="Build the HelpSteer2 answer-adequacy rows.")
84
+ ap.add_argument("--raw", default="data/raw/helpsteer2")
85
+ ap.add_argument("--out", default="", help="training rows (train split); omitted if empty")
86
+ ap.add_argument("--eval-out", default="data/adequacy_hs2_eval.jsonl")
87
+ ap.add_argument("--tokenizer", default="Qwen/Qwen3.5-2B-Base")
88
+ ap.add_argument("--max-tokens", type=int, default=3000)
89
+ ap.add_argument("--seed", type=int, default=0)
90
+ args = ap.parse_args(argv)
91
+ tok = AutoTokenizer.from_pretrained(args.tokenizer)
92
+
93
+ splits = {"eval": (f"{args.raw}/validation.jsonl.gz", args.eval_out)}
94
+ if args.out:
95
+ splits["train"] = (f"{args.raw}/train.jsonl.gz", args.out)
96
+ prompts = defaultdict(set)
97
+ for name, (src, dst) in splits.items():
98
+ rows = balanced(load(src), seed=args.seed)
99
+ prompts[name] = {r["prompt"] for r in rows}
100
+ examples = fits([to_example(r) for r in rows], tok, args.max_tokens)
101
+ # fits() may drop rows over the window unevenly; rebalance what remains
102
+ yes = [e for e in examples if e.label == 1]
103
+ no = [e for e in examples if e.label == 0]
104
+ n = min(len(yes), len(no))
105
+ examples = yes[:n] + no[:n]
106
+ random.Random(args.seed).shuffle(examples)
107
+ print(f"{name}: {len(examples):,} rows ({n:,} adequate, {n:,} not), "
108
+ f"{len(prompts[name]):,} distinct prompts")
109
+ write_jsonl(dst, examples)
110
+ if "train" in prompts:
111
+ print(f"prompts shared by train and eval: {len(prompts['train'] & prompts['eval'])}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,114 @@
1
+ """Catch-all options (v20): choice rows where "other" / "none of these" is an option, and
2
+ half the time the right one.
3
+
4
+ The classification corpus names every category it asks about, so the model has never
5
+ had to pick a catch-all because the true category is missing, or to pass one over
6
+ because it is present. JevBench's routing and intent questions carry such options, and
7
+ v19 misses one of each with high confidence. This adds rows built from the corpus's own
8
+ choice tasks with named categories (ag_news, banking77, clinc150, dbpedia,
9
+ yahoo_topics):
10
+
11
+ absent the gold option is removed and a catch-all is added: the catch-all is right
12
+ present a catch-all is added beside the gold option: the gold option is right
13
+
14
+ in equal numbers, so the presence of a catch-all never predicts the answer. Rows whose
15
+ options already include CLINC150's out-of-scope class are skipped. The catch-all goes
16
+ at the end of the canonical order; options are shuffled at collate time.
17
+
18
+ The eval applies the same transform to the test half of two held-out tasks never
19
+ trained on (`massive_intent`, `emotion` in data/holdout_v5_norule.jsonl).
20
+
21
+ python -m strands_decider.data.catchall --out data/catchall_v20.jsonl --eval-out data/catchall_v20_eval.jsonl
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import random
26
+ from collections.abc import Iterable
27
+
28
+ from .format import Example
29
+
30
+ SOURCES = ("ag_news", "banking77", "clinc150", "dbpedia", "yahoo_topics")
31
+ HELDOUT = ("massive_intent", "emotion")
32
+ NAMES = ["other", "none of these", "something else", "none of the listed categories"]
33
+ DESCRIPTION = "the text fits none of the other options"
34
+
35
+
36
+ def _oos(ex: Example) -> bool:
37
+ return any("oos" in n.lower() or "out of scope" in n.lower() or "out_of_scope" in n.lower()
38
+ for n, _ in ex.options)
39
+
40
+
41
+ def transform(ex: Example, mode: str, rng: random.Random, max_options: int = 24) -> Example | None:
42
+ """One catch-all row from `ex`; None if the row cannot take one. A row already at
43
+ `max_options` (the model's num_slots, the width of its training KL reference) gives
44
+ up one wrong option at random to make room for the catch-all."""
45
+ if ex.kind != "choice" or _oos(ex) or any(n.lower() in NAMES for n, _ in ex.options):
46
+ return None
47
+ catch = [rng.choice(NAMES), DESCRIPTION]
48
+ if mode == "absent":
49
+ rest = [o for i, o in enumerate(ex.options) if i != ex.label]
50
+ if len(rest) < 2:
51
+ return None
52
+ options, label = [*rest, catch], len(rest)
53
+ elif mode == "present":
54
+ options, label = list(ex.options), ex.label
55
+ if len(options) >= max_options:
56
+ drop = rng.choice([i for i in range(len(options)) if i != label])
57
+ options = [o for i, o in enumerate(options) if i != drop]
58
+ label = label - (drop < label)
59
+ options = [*options, catch]
60
+ else:
61
+ raise ValueError(mode)
62
+ return Example(kind="choice", state=ex.state, instructions=ex.instructions,
63
+ options=[list(o) for o in options], label=label, task=f"{ex.task}+catchall:{mode}",
64
+ weight=ex.weight, instruction_variants=list(ex.instruction_variants))
65
+
66
+
67
+ def build(examples: list[Example], tasks: Iterable[str], per_task: int, seed: int = 0) -> list[Example]:
68
+ """`per_task` rows from each task, half absent and half present."""
69
+ rng = random.Random(seed)
70
+ out: list[Example] = []
71
+ for task in tasks:
72
+ pool = [e for e in examples if e.task == task and e.kind == "choice"]
73
+ rng.shuffle(pool)
74
+ want = {"absent": per_task // 2, "present": per_task - per_task // 2}
75
+ for e in pool:
76
+ mode = "absent" if want["absent"] >= want["present"] and want["absent"] > 0 else "present"
77
+ if want[mode] == 0:
78
+ break
79
+ row = transform(e, mode, rng)
80
+ if row is not None:
81
+ out.append(row)
82
+ want[mode] -= 1
83
+ rng.shuffle(out)
84
+ return out
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> None:
88
+ import argparse
89
+
90
+ from ..evaluate import partition_examples
91
+ from .format import read_jsonl, write_jsonl
92
+
93
+ ap = argparse.ArgumentParser(description="Build the catch-all option rows (v20).")
94
+ ap.add_argument("--src", default="data/train_v5.jsonl")
95
+ ap.add_argument("--heldout", default="data/holdout_v5_norule.jsonl")
96
+ ap.add_argument("--out", default="data/catchall_v20.jsonl")
97
+ ap.add_argument("--eval-out", default="data/catchall_v20_eval.jsonl")
98
+ ap.add_argument("--per-task", type=int, default=1000)
99
+ ap.add_argument("--eval-per-task", type=int, default=400)
100
+ ap.add_argument("--seed", type=int, default=0)
101
+ args = ap.parse_args(argv)
102
+
103
+ train = build(list(read_jsonl(args.src)), SOURCES, args.per_task, seed=args.seed)
104
+ test_half = partition_examples(list(read_jsonl(args.heldout)), "test")
105
+ evals = build(test_half, HELDOUT, args.eval_per_task, seed=args.seed)
106
+ for name, rows in (("train", train), ("eval", evals)):
107
+ absent = sum(r.task.endswith(":absent") for r in rows)
108
+ print(f"{name}: {len(rows):,} rows ({absent:,} catch-all right, {len(rows) - absent:,} catch-all a distractor)")
109
+ write_jsonl(args.out, train)
110
+ write_jsonl(args.eval_out, evals)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()