holoscript-trait-inference 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.
trait_inference/cli.py ADDED
@@ -0,0 +1,498 @@
1
+ """trait-inference CLI — argparse runner.
2
+
3
+ Subcommands:
4
+ trait-inference extract-traits — run scripts/extract_trait_constants.py wrapped
5
+ trait-inference smoke — generate synthetic dataset + run keyword baseline + emit measurement
6
+ trait-inference dataset audit — run audit() against a JSONL dataset
7
+ trait-inference dataset split — emit train/val/indist/novel split files
8
+ trait-inference baseline run — fit + evaluate a named baseline against a dataset
9
+ trait-inference eval headline — compute headline measurement bundle (model + baseline preds)
10
+
11
+ Output: structured JSON to stdout OR to --output path.
12
+
13
+ CLI is the thin layer over trait_inference.{dataset,baselines,metrics}.
14
+ Heavy ML deps (torch, transformers) imported only by `model run` /
15
+ `train` subcommands which require the [model] extra.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from trait_inference.dataset import (
26
+ Pair,
27
+ Source,
28
+ Split,
29
+ audit,
30
+ generate_smoke_dataset,
31
+ load_jsonl,
32
+ make_splits,
33
+ write_jsonl,
34
+ )
35
+
36
+
37
+ def _emit(payload: dict, output: Path | None) -> None:
38
+ """Emit JSON to stdout or output file."""
39
+ text = json.dumps(payload, indent=2, sort_keys=False, default=str)
40
+ if output:
41
+ output.parent.mkdir(parents=True, exist_ok=True)
42
+ output.write_text(text + "\n", encoding="utf-8")
43
+ print(f"Wrote {output}", file=sys.stderr)
44
+ else:
45
+ print(text)
46
+
47
+
48
+ # ----------------------------------------------------------------------------
49
+ # extract-traits
50
+ # ----------------------------------------------------------------------------
51
+
52
+ def cmd_extract_traits(args: argparse.Namespace) -> int:
53
+ from scripts.extract_trait_constants import main as extract_main
54
+ return extract_main([
55
+ "--constants-dir", str(args.constants_dir),
56
+ "--output", str(args.output),
57
+ *(["--verbose"] if args.verbose else []),
58
+ ])
59
+
60
+
61
+ # ----------------------------------------------------------------------------
62
+ # smoke — end-to-end pipeline test
63
+ # ----------------------------------------------------------------------------
64
+
65
+ def cmd_smoke(args: argparse.Namespace) -> int:
66
+ """Generate synthetic dataset, run keyword baseline, emit measurement.
67
+
68
+ Exits 0 on full pipeline success, 1 on any failure. Used for CI smoke
69
+ test + on-instance Vast.ai pipeline validation BEFORE expensive
70
+ training runs.
71
+ """
72
+ from trait_inference.baselines import KeywordBaseline
73
+ from trait_inference.metrics import bootstrap_ci, exact_match_rate, f1_macro
74
+
75
+ # Use minimal label space for synthetic data
76
+ label_space = ["physics", "grabbable", "collidable", "rigid", "kinematic", "softBody"]
77
+
78
+ print(f"[smoke] Generating synthetic dataset (n={args.n})...", file=sys.stderr)
79
+ pairs = generate_smoke_dataset(label_space, n=args.n, seed=args.seed)
80
+
81
+ print(f"[smoke] Auditing...", file=sys.stderr)
82
+ splits = make_splits(pairs, seed=args.seed)
83
+ audit_report = audit(pairs, splits)
84
+
85
+ print(f"[smoke] Fitting keyword baseline...", file=sys.stderr)
86
+ baseline = KeywordBaseline(label_space=tuple(label_space))
87
+ baseline.fit(splits[Split.TRAIN])
88
+
89
+ print(f"[smoke] Predicting on novel split (n={len(splits[Split.HELD_OUT_NOVEL])})...", file=sys.stderr)
90
+ novel = splits[Split.HELD_OUT_NOVEL]
91
+ if not novel:
92
+ # Smoke datasets too small to generate novel split; fall back to indist
93
+ novel = splits[Split.HELD_OUT_INDIST]
94
+ descriptions = [p.description for p in novel]
95
+ gold = [list(p.trait_set) for p in novel]
96
+ preds = baseline.predict_batch(descriptions)
97
+
98
+ print(f"[smoke] Computing metrics + bootstrap CI...", file=sys.stderr)
99
+ f1m = f1_macro(gold, preds, label_space=label_space)
100
+ em = exact_match_rate(gold, preds)
101
+ ci = bootstrap_ci(gold, preds, metric="f1_macro", b=args.bootstrap_b,
102
+ seed=args.seed, label_space=label_space)
103
+
104
+ payload = {
105
+ "smoke_test": True,
106
+ "passed": True,
107
+ "n_pairs": len(pairs),
108
+ "splits": {s.value: len(p) for s, p in splits.items()},
109
+ "audit_passes": audit_report.passes, # expected False on smoke (size)
110
+ "audit_issues": audit_report.issues,
111
+ "baseline": {
112
+ "name": baseline.name,
113
+ "f1_macro": f1m,
114
+ "exact_match": em,
115
+ "bootstrap_ci": ci.to_dict(),
116
+ },
117
+ "smoke_completed_at": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+ _emit(payload, args.output)
120
+ print(f"[smoke] PASSED — pipeline ran end-to-end. F1 macro={f1m:.3f}", file=sys.stderr)
121
+ return 0
122
+
123
+
124
+ # ----------------------------------------------------------------------------
125
+ # dataset audit / split
126
+ # ----------------------------------------------------------------------------
127
+
128
+ def cmd_dataset_audit(args: argparse.Namespace) -> int:
129
+ pairs = load_jsonl(args.input)
130
+ report = audit(pairs)
131
+ _emit(report.to_dict(), args.output)
132
+ return 0 if report.passes else 1
133
+
134
+
135
+ def cmd_dataset_split(args: argparse.Namespace) -> int:
136
+ pairs = load_jsonl(args.input)
137
+ splits = make_splits(pairs, seed=args.seed)
138
+ out_dir = Path(args.output_dir)
139
+ out_dir.mkdir(parents=True, exist_ok=True)
140
+ summary: dict[str, int] = {}
141
+ for split, split_pairs in splits.items():
142
+ path = out_dir / f"{split.value}.jsonl"
143
+ n = write_jsonl(path, split_pairs)
144
+ summary[split.value] = n
145
+ summary["seed"] = args.seed
146
+ summary["written_at"] = datetime.now(timezone.utc).isoformat()
147
+ _emit(summary, args.output)
148
+ return 0
149
+
150
+
151
+ # ----------------------------------------------------------------------------
152
+ # baseline run
153
+ # ----------------------------------------------------------------------------
154
+
155
+ def cmd_baseline_run(args: argparse.Namespace) -> int:
156
+ from trait_inference.baselines import (
157
+ BrittneyFewShotBaseline,
158
+ KeywordBaseline,
159
+ TfidfLogregBaseline,
160
+ )
161
+ from trait_inference.metrics import bootstrap_ci, exact_match_rate, f1_macro
162
+
163
+ train_pairs = load_jsonl(args.train)
164
+ eval_pairs = load_jsonl(args.eval)
165
+
166
+ label_space: list[str] = sorted({t for p in train_pairs for t in p.trait_set})
167
+
168
+ if args.name == "keyword":
169
+ baseline = KeywordBaseline(label_space=tuple(label_space))
170
+ elif args.name == "tfidf":
171
+ baseline = TfidfLogregBaseline(threshold=args.tfidf_threshold)
172
+ elif args.name == "brittney":
173
+ baseline = BrittneyFewShotBaseline(k_shots=args.brittney_k)
174
+ else:
175
+ print(f"unknown baseline: {args.name}", file=sys.stderr)
176
+ return 1
177
+
178
+ baseline.fit(train_pairs)
179
+
180
+ if args.name == "tfidf" and args.tune_threshold:
181
+ # Optional threshold tuning on a separate validation file
182
+ if args.val:
183
+ val_pairs = load_jsonl(args.val)
184
+ best = baseline.tune_threshold(val_pairs) # type: ignore[attr-defined]
185
+ print(f"[baseline-run] tfidf tuned threshold = {best:.3f}", file=sys.stderr)
186
+
187
+ descriptions = [p.description for p in eval_pairs]
188
+ gold = [list(p.trait_set) for p in eval_pairs]
189
+ preds = baseline.predict_batch(descriptions)
190
+
191
+ f1m = f1_macro(gold, preds, label_space=label_space)
192
+ em = exact_match_rate(gold, preds)
193
+ ci = bootstrap_ci(gold, preds, metric="f1_macro",
194
+ b=args.bootstrap_b, seed=args.seed,
195
+ label_space=label_space)
196
+
197
+ payload = {
198
+ "baseline_name": baseline.name,
199
+ "train_size": len(train_pairs),
200
+ "eval_size": len(eval_pairs),
201
+ "label_space_size": len(label_space),
202
+ "f1_macro": f1m,
203
+ "exact_match": em,
204
+ "bootstrap_ci": ci.to_dict(),
205
+ "predictions_sample": [
206
+ {"id": p.id, "gold": list(p.trait_set), "pred": preds[i]}
207
+ for i, p in enumerate(eval_pairs[:5])
208
+ ],
209
+ "evaluated_at": datetime.now(timezone.utc).isoformat(),
210
+ }
211
+ _emit(payload, args.output)
212
+ return 0
213
+
214
+
215
+ # ----------------------------------------------------------------------------
216
+ # model — train / eval / sweep
217
+ # ----------------------------------------------------------------------------
218
+
219
+ def _load_label_space(path: Path) -> tuple[str, ...]:
220
+ """Load label space JSON emitted by extract_trait_constants."""
221
+ obj = json.loads(path.read_text(encoding="utf-8"))
222
+ return tuple(obj["all_traits"])
223
+
224
+
225
+ def cmd_model_train(args: argparse.Namespace) -> int:
226
+ from trait_inference.model.trainer import TraitTrainer, TrainConfig
227
+
228
+ label_space = _load_label_space(args.label_space)
229
+ train_pairs = load_jsonl(args.train)
230
+ val_pairs = load_jsonl(args.val)
231
+
232
+ cfg = TrainConfig(
233
+ model_name=args.model_name,
234
+ label_space=label_space,
235
+ output_dir=str(args.output_dir),
236
+ num_epochs=args.num_epochs,
237
+ train_batch_size=args.batch_size,
238
+ eval_batch_size=args.batch_size,
239
+ learning_rate=args.learning_rate,
240
+ seed=args.seed,
241
+ fp16=not args.no_fp16,
242
+ )
243
+ trainer = TraitTrainer(cfg)
244
+ summary = trainer.train(train_pairs, val_pairs)
245
+
246
+ payload = {
247
+ "config": cfg.to_dict(),
248
+ "train_size": len(train_pairs),
249
+ "val_size": len(val_pairs),
250
+ **summary,
251
+ }
252
+ _emit(payload, args.output)
253
+ return 0
254
+
255
+
256
+ def cmd_model_eval(args: argparse.Namespace) -> int:
257
+ from trait_inference.metrics import bootstrap_ci, exact_match_rate, f1_macro
258
+ from trait_inference.model.decoder import TraitDecoder, TraitDecoderConfig
259
+
260
+ label_space = _load_label_space(args.label_space)
261
+ eval_pairs = load_jsonl(args.eval)
262
+
263
+ cfg = TraitDecoderConfig(
264
+ model_name=str(args.checkpoint),
265
+ label_space=label_space,
266
+ device="cpu" if args.no_fp16 else "cuda",
267
+ )
268
+ decoder = TraitDecoder(cfg)
269
+
270
+ descriptions = [p.description for p in eval_pairs]
271
+ gold = [list(p.trait_set) for p in eval_pairs]
272
+ preds = decoder.predict_batch(descriptions)
273
+
274
+ f1m = f1_macro(gold, preds, label_space=label_space)
275
+ em = exact_match_rate(gold, preds)
276
+ ci = bootstrap_ci(
277
+ gold, preds, metric="f1_macro",
278
+ b=args.bootstrap_b, seed=args.seed, label_space=label_space,
279
+ )
280
+ payload = {
281
+ "checkpoint": str(args.checkpoint),
282
+ "eval_size": len(eval_pairs),
283
+ "label_space_size": len(label_space),
284
+ "f1_macro": f1m,
285
+ "exact_match": em,
286
+ "bootstrap_ci": ci.to_dict(),
287
+ "evaluated_at": datetime.now(timezone.utc).isoformat(),
288
+ }
289
+ _emit(payload, args.output)
290
+ return 0
291
+
292
+
293
+ def cmd_model_sweep(args: argparse.Namespace) -> int:
294
+ from trait_inference.model.sweep import SweepConfig, TraitSweep
295
+
296
+ cfg = SweepConfig(
297
+ fractional_factorial_pruned=not args.no_prune,
298
+ reseed_n=args.reseed_n,
299
+ )
300
+ sweep = TraitSweep(cfg)
301
+ summary = sweep.emit_cells(args.output_dir)
302
+ _emit(summary, None)
303
+ return 0
304
+
305
+
306
+ def cmd_eval_ablations(args: argparse.Namespace) -> int:
307
+ """Run the 5-row ablation matrix per spec §3.5.
308
+
309
+ Heavy GPU work — caller is expected to have a trained checkpoint.
310
+ Each ablation runs the model with a perturbed input or a different
311
+ training condition; total compute ≈ 4-6× the baseline training run.
312
+ """
313
+ from trait_inference.eval.ablations import AblationConfig, AblationMatrix
314
+ from trait_inference.model.decoder import TraitDecoder, TraitDecoderConfig
315
+
316
+ label_space = _load_label_space(args.label_space)
317
+ train_pairs = load_jsonl(args.train)
318
+ eval_pairs = load_jsonl(args.eval)
319
+
320
+ decoder_cfg = TraitDecoderConfig(
321
+ model_name=str(args.checkpoint),
322
+ label_space=label_space,
323
+ device="cpu" if args.no_fp16 else "cuda",
324
+ )
325
+ decoder = TraitDecoder(decoder_cfg)
326
+
327
+ # Baseline validity = fraction of decoder predictions that are
328
+ # subsets of label_space (expected ≥0.95 with constrained decoding).
329
+ descs = [p.description for p in eval_pairs]
330
+ baseline_preds = decoder.predict_batch(descs)
331
+ ls_set = set(label_space)
332
+ baseline_validity = (
333
+ sum(1 for p in baseline_preds if all(t in ls_set for t in p)) / len(baseline_preds)
334
+ if baseline_preds else 0.0
335
+ )
336
+
337
+ # Ablation 2 needs an UNCONSTRAINED variant — same model, no
338
+ # outlines regex. Stub: run decoder.predict_batch directly via
339
+ # transformers generate (no FSM). For Phase 2 v0 we emit a
340
+ # structured "needs separate impl" placeholder; full unconstrained
341
+ # variant is a Phase 2.1 follow-up.
342
+ def _unconstrained_stub(descriptions: list[str]) -> list[list[str]]:
343
+ # Returns empty predictions — flagged as Phase 2.1 deferral.
344
+ # Eval harness records this honestly rather than fabricating.
345
+ return [[] for _ in descriptions]
346
+
347
+ # Ablation 3 needs train_eval_at_size_fn — Phase 2.1 deferral
348
+ def _train_size_stub(n: int) -> float:
349
+ return 0.0
350
+
351
+ # Ablation 4 needs brittney_only_train_eval_fn — Phase 2.1 deferral
352
+ def _brittney_only_stub(_pairs: list[Pair]) -> float:
353
+ return 0.0
354
+
355
+ matrix = AblationMatrix(AblationConfig(
356
+ label_space=label_space,
357
+ bootstrap_b=args.bootstrap_b,
358
+ seed=args.seed,
359
+ ))
360
+ results = matrix.run_all(
361
+ predict_fn=decoder.predict_batch,
362
+ unconstrained_predict_fn=_unconstrained_stub,
363
+ train_eval_at_size_fn=_train_size_stub,
364
+ brittney_only_train_eval_fn=_brittney_only_stub,
365
+ eval_pairs=eval_pairs,
366
+ train_pairs=train_pairs,
367
+ baseline_validity=baseline_validity,
368
+ )
369
+ results["phase_2_1_deferrals"] = [
370
+ "constrained_decoding_off (need unconstrained variant)",
371
+ "training_size_sweep (need 3 separate train runs)",
372
+ "source_ablation_brittney_only (need separate train run)",
373
+ ]
374
+ _emit(results, args.output)
375
+ return 0
376
+
377
+
378
+ # ----------------------------------------------------------------------------
379
+ # parser construction
380
+ # ----------------------------------------------------------------------------
381
+
382
+ def build_parser() -> argparse.ArgumentParser:
383
+ p = argparse.ArgumentParser(
384
+ prog="trait-inference",
385
+ description="Paper 19 (ATI) — training pipeline + baselines + eval",
386
+ )
387
+ sub = p.add_subparsers(dest="cmd", required=True)
388
+
389
+ # extract-traits
390
+ et = sub.add_parser("extract-traits", help="Extract trait label space from TS constants")
391
+ et.add_argument("--constants-dir", type=Path, required=True)
392
+ et.add_argument("--output", type=Path, required=True)
393
+ et.add_argument("--verbose", "-v", action="store_true")
394
+ et.set_defaults(func=cmd_extract_traits)
395
+
396
+ # smoke
397
+ sm = sub.add_parser("smoke", help="End-to-end pipeline smoke test (synthetic data)")
398
+ sm.add_argument("--n", type=int, default=100)
399
+ sm.add_argument("--seed", type=int, default=42)
400
+ sm.add_argument("--bootstrap-b", type=int, default=200)
401
+ sm.add_argument("--output", type=Path, default=None)
402
+ sm.set_defaults(func=cmd_smoke)
403
+
404
+ # dataset
405
+ ds = sub.add_parser("dataset", help="Dataset operations").add_subparsers(dest="ds_cmd", required=True)
406
+
407
+ ds_audit = ds.add_parser("audit", help="Audit a JSONL dataset against spec §1.4")
408
+ ds_audit.add_argument("input", type=Path)
409
+ ds_audit.add_argument("--output", type=Path, default=None)
410
+ ds_audit.set_defaults(func=cmd_dataset_audit)
411
+
412
+ ds_split = ds.add_parser("split", help="Generate train/val/indist/novel split files")
413
+ ds_split.add_argument("input", type=Path)
414
+ ds_split.add_argument("--output-dir", type=Path, required=True)
415
+ ds_split.add_argument("--seed", type=int, default=42)
416
+ ds_split.add_argument("--output", type=Path, default=None,
417
+ help="Summary JSON output (separate from split files)")
418
+ ds_split.set_defaults(func=cmd_dataset_split)
419
+
420
+ # baseline
421
+ bl = sub.add_parser("baseline", help="Baseline operations").add_subparsers(dest="bl_cmd", required=True)
422
+ bl_run = bl.add_parser("run", help="Fit + evaluate a baseline")
423
+ bl_run.add_argument("name", choices=["keyword", "tfidf", "brittney"])
424
+ bl_run.add_argument("--train", type=Path, required=True)
425
+ bl_run.add_argument("--eval", type=Path, required=True)
426
+ bl_run.add_argument("--val", type=Path, default=None)
427
+ bl_run.add_argument("--output", type=Path, default=None)
428
+ bl_run.add_argument("--bootstrap-b", type=int, default=1000)
429
+ bl_run.add_argument("--seed", type=int, default=42)
430
+ bl_run.add_argument("--tfidf-threshold", type=float, default=0.5)
431
+ bl_run.add_argument("--tune-threshold", action="store_true",
432
+ help="Tune tfidf threshold on --val split")
433
+ bl_run.add_argument("--brittney-k", type=int, default=8)
434
+ bl_run.set_defaults(func=cmd_baseline_run)
435
+
436
+ # model — Phase 2 contribution model (requires [model] extras)
437
+ md = sub.add_parser("model", help="Contribution model (requires .[model])").add_subparsers(dest="md_cmd", required=True)
438
+
439
+ md_train = md.add_parser("train", help="Fine-tune the constrained-decoding trait model")
440
+ md_train.add_argument("--train", type=Path, required=True)
441
+ md_train.add_argument("--val", type=Path, required=True)
442
+ md_train.add_argument("--label-space", type=Path, required=True,
443
+ help="Path to trait_label_space.json (from extract-traits)")
444
+ md_train.add_argument("--model-name", default="Qwen/Qwen2.5-0.5B")
445
+ md_train.add_argument("--output-dir", type=Path, default=Path("checkpoints/trait_decoder_v0"))
446
+ md_train.add_argument("--num-epochs", type=int, default=20)
447
+ md_train.add_argument("--batch-size", type=int, default=32)
448
+ md_train.add_argument("--learning-rate", type=float, default=5e-5)
449
+ md_train.add_argument("--seed", type=int, default=42)
450
+ md_train.add_argument("--no-fp16", action="store_true",
451
+ help="Disable fp16 (use for CPU smoke tests)")
452
+ md_train.add_argument("--output", type=Path, default=None)
453
+ md_train.set_defaults(func=cmd_model_train)
454
+
455
+ md_eval = md.add_parser("eval", help="Evaluate a trained model on a split")
456
+ md_eval.add_argument("--checkpoint", type=Path, required=True)
457
+ md_eval.add_argument("--eval", type=Path, required=True)
458
+ md_eval.add_argument("--label-space", type=Path, required=True)
459
+ md_eval.add_argument("--bootstrap-b", type=int, default=1000)
460
+ md_eval.add_argument("--seed", type=int, default=42)
461
+ md_eval.add_argument("--no-fp16", action="store_true")
462
+ md_eval.add_argument("--output", type=Path, default=None)
463
+ md_eval.set_defaults(func=cmd_model_eval)
464
+
465
+ md_sweep = md.add_parser("sweep", help="Emit hyperparameter-sweep cell configs")
466
+ md_sweep.add_argument("--output-dir", type=Path, required=True)
467
+ md_sweep.add_argument("--no-prune", action="store_true",
468
+ help="Emit full 162-cell grid instead of pruned ~30")
469
+ md_sweep.add_argument("--reseed-n", type=int, default=5)
470
+ md_sweep.set_defaults(func=cmd_model_sweep)
471
+
472
+ # eval — Phase 4 ablation matrix
473
+ ev = sub.add_parser("eval", help="Evaluation harness").add_subparsers(dest="ev_cmd", required=True)
474
+ ev_ablations = ev.add_parser("ablations", help="Run 5-row ablation matrix (requires .[model])")
475
+ ev_ablations.add_argument("--checkpoint", type=Path, required=True)
476
+ ev_ablations.add_argument("--train", type=Path, required=True)
477
+ ev_ablations.add_argument("--eval", type=Path, required=True)
478
+ ev_ablations.add_argument("--label-space", type=Path, required=True)
479
+ ev_ablations.add_argument("--bootstrap-b", type=int, default=1000)
480
+ ev_ablations.add_argument("--seed", type=int, default=42)
481
+ ev_ablations.add_argument("--no-fp16", action="store_true")
482
+ ev_ablations.add_argument("--output", type=Path, default=None)
483
+ ev_ablations.set_defaults(func=cmd_eval_ablations)
484
+
485
+ return p
486
+
487
+
488
+ def main(argv: list[str] | None = None) -> int:
489
+ parser = build_parser()
490
+ args = parser.parse_args(argv)
491
+ if not hasattr(args, "func"):
492
+ parser.print_help()
493
+ return 1
494
+ return args.func(args)
495
+
496
+
497
+ if __name__ == "__main__":
498
+ sys.exit(main())