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.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: holoscript-trait-inference
3
+ Version: 0.1.0
4
+ Summary: Paper 19 (ATI) — Automated Trait Inference for HoloScript .hsplus. Phase 3 training pipeline + baselines + eval harness.
5
+ Author: HoloScript Core
6
+ License-Expression: MIT
7
+ Keywords: holoscript,trait-inference,paper-19,ml
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: scikit-learn>=1.3
12
+ Requires-Dist: scipy>=1.11
13
+ Requires-Dist: pandas>=2.0
14
+ Provides-Extra: model
15
+ Requires-Dist: torch>=2.1; extra == "model"
16
+ Requires-Dist: transformers>=4.40; extra == "model"
17
+ Requires-Dist: sentence-transformers>=2.6; extra == "model"
18
+ Requires-Dist: outlines>=0.0.40; extra == "model"
19
+ Requires-Dist: accelerate>=0.30; extra == "model"
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4; extra == "dev"
22
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
23
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
24
+ Requires-Dist: mypy>=1.7; extra == "dev"
25
+
26
+ # trait-inference — Paper 19 (ATI) Phase 3 Pipeline
27
+
28
+ Python package implementing the **frozen** Paper 19 (Automated Trait
29
+ Inference) Phase 3 training pipeline + baselines + eval harness, per:
30
+
31
+ - Spec: `ai-ecosystem/research/paper-19-trait-inference/phase-1-spec.md`
32
+ - Pre-registration: `ai-ecosystem/research/paper-19-trait-inference/preregistration.md`
33
+ - Brain: `ai-ecosystem/compositions/trait-inference-brain.hsplus`
34
+ - GPU-claim ticket: `task_1777072040695_mrr3`
35
+
36
+ **Status (2026-04-24)**: Phase 1 (CPU pipeline) shipped — dataset
37
+ loader/audit/splits + 3 baselines (keyword + TF-IDF + Brittney-stub) +
38
+ eval metrics with bootstrap CI + CLI runner + Vast.ai launcher.
39
+ **Phase 2 (model module)** — sentence-transformer encoder + constrained-
40
+ decoder LLM, requires `[model]` extra — pending follow-up commit.
41
+
42
+ ---
43
+
44
+ ## Quick start
45
+
46
+ ### 1. Install (CPU baselines + eval only)
47
+
48
+ ```bash
49
+ cd packages/trait-inference
50
+ pip install -e .
51
+ ```
52
+
53
+ ### 2. Smoke test (synthetic data, end-to-end)
54
+
55
+ Validates the pipeline runs without needing real data or GPU. ~2 min.
56
+
57
+ ```bash
58
+ trait-inference smoke --n 200 --bootstrap-b 200
59
+ ```
60
+
61
+ Should emit a JSON measurement bundle to stdout with `"smoke_test": true,
62
+ "passed": true`. Use this to validate a fresh install before committing
63
+ to a Vast.ai run.
64
+
65
+ ### 3. Extract trait label space from HoloScript core
66
+
67
+ ```bash
68
+ trait-inference extract-traits \
69
+ --constants-dir ../core/src/traits/constants/ \
70
+ --output trait_inference/data/trait_label_space.json \
71
+ --verbose
72
+ ```
73
+
74
+ Reads the 113 TS constant files, extracts string-array exports, writes
75
+ a single JSON consumed by the dataset + model modules.
76
+
77
+ ### 4. Audit a real dataset
78
+
79
+ ```bash
80
+ trait-inference dataset audit data/atimark.jsonl --output measurements/audit.json
81
+ ```
82
+
83
+ Returns exit 0 if the dataset passes spec §1.4 acceptance (≥2k pairs,
84
+ ≥300 novel combinations, ≥500 each major source, ≥200 negatives, no
85
+ novelty leak); exit 1 with `issues` list otherwise.
86
+
87
+ ### 5. Run baselines
88
+
89
+ ```bash
90
+ trait-inference dataset split data/atimark.jsonl --output-dir splits/ --seed 42
91
+ trait-inference baseline run keyword --train splits/train.jsonl --eval splits/held_out_novel.jsonl --output measurements/keyword.json
92
+ trait-inference baseline run tfidf --train splits/train.jsonl --eval splits/held_out_novel.jsonl --val splits/val.jsonl --tune-threshold --output measurements/tfidf.json
93
+ trait-inference baseline run brittney --train splits/train.jsonl --eval splits/held_out_novel.jsonl --output measurements/brittney.json
94
+ ```
95
+
96
+ Each emits `f1_macro`, `exact_match`, `bootstrap_ci`, sample predictions.
97
+
98
+ ---
99
+
100
+ ## Vast.ai GPU launch
101
+
102
+ Orchestration script: `scripts/vast-launch-paper-19.ps1` (PowerShell;
103
+ mirrors the existing `ai-ecosystem/scripts/vast-bench-runner.ps1`
104
+ pattern).
105
+
106
+ ```powershell
107
+ # Cheapest end-to-end pipeline validation (~$0.30, ~5 min)
108
+ .\scripts\vast-launch-paper-19.ps1 -Phase smoke -Label paper19-smoke
109
+
110
+ # Run all 3 baselines on the real dataset (~$0.30, ~10 min)
111
+ .\scripts\vast-launch-paper-19.ps1 -Phase baseline `
112
+ -DatasetPath data/atimark.jsonl -Label paper19-baselines
113
+
114
+ # Full training run (REQUIRES preregistration.md frozen + Phase 2 model module shipped)
115
+ .\scripts\vast-launch-paper-19.ps1 -Phase train -GpuName RTX_4090 `
116
+ -DatasetPath data/atimark.jsonl -Label paper19-headline-cell-1
117
+ ```
118
+
119
+ Pre-flight: requires `vastai set api-key` configured (see
120
+ `ai-ecosystem/.env` `VAST_API_KEY`); requires `~/.ssh/id_rsa` with the
121
+ matching public key registered on the Vast.ai account; requires
122
+ ≥$0.50 credit for `train`.
123
+
124
+ ---
125
+
126
+ ## Cost estimate (per
127
+
128
+ `ai-ecosystem/research/paper-19-trait-inference/README.md` Phase 2-4 task table + GPU-claim ticket `_mrr3`)
129
+
130
+ | Job | GPU | Hours | Cost |
131
+ | --------------------------------------------- | -------- | ------------------------------: | -----: |
132
+ | Smoke test | RTX 4090 | 0.1 | $0.03 |
133
+ | Baselines (CPU-bound) | RTX 4090 | 0.2 | $0.06 |
134
+ | Single training cell | RTX 4090 | ~6 | ~$1.80 |
135
+ | Full sweep (30 cells × N=5 reseed = 150 runs) | RTX 4090 | ~900 (parallel: 30 GPUs × 30hr) | ~$240 |
136
+
137
+ (A100 estimates are roughly 4-8× higher; A100 supply is also tighter.
138
+ 4090 is sufficient for ≤1B-param decoder per spec §3.1.)
139
+
140
+ ---
141
+
142
+ ## Per-spec deliverable map
143
+
144
+ | Spec section | Module | Status |
145
+ | ------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------ |
146
+ | §1.1 Sourcing 3-source mix | `dataset.py` Pair + Source | done (loader; data construction is Phase 2 task) |
147
+ | §1.2 Schema | `dataset.py` Pair dataclass | done |
148
+ | §1.3 Splits (train/val/indist/novel) | `dataset.py` make_splits | done |
149
+ | §1.4 Audit protocol | `dataset.py` audit + AuditReport | done |
150
+ | §2.1 Keyword baseline | `baselines.py` KeywordBaseline | done |
151
+ | §2.2 TF-IDF + LogReg baseline | `baselines.py` TfidfLogregBaseline | done |
152
+ | §2.3 Brittney few-shot baseline | `baselines.py` BrittneyFewShotBaseline | stub (real impl needs Brittney API integration) |
153
+ | §3.1 Constrained-decoder model | `model/` (Phase 2 commit) | pending |
154
+ | §3.2 Conditioning fields | `model/` (Phase 2 commit) | pending |
155
+ | §3.3 Hyperparameter sweep | `model/sweep.py` (Phase 2 commit) | pending |
156
+ | §4.1 Metric definitions | `metrics.py` f1_macro, f1_micro, exact_match_rate, bootstrap_ci | done |
157
+ | §4.2 Statistical protocol | `metrics.py` bootstrap_ci, evaluate_headline | done |
158
+ | §4.3 Ablation matrix | `eval/ablations.py` (Phase 2 commit) | pending |
159
+ | §4.4 Required user study | (separate UX-research task) | pending |
160
+ | §4.5 Pre-registration freeze | `ai-ecosystem/research/paper-19-trait-inference/preregistration.md` | FROZEN (do not edit) |
161
+
162
+ ---
163
+
164
+ ## Anti-pattern guards (binding — inherited from
165
+
166
+ `compositions/trait-inference-brain.hsplus`)
167
+
168
+ - **No train-set evaluation.** Headline metric on novel-combination split only.
169
+ - **No easy-split-only F1.** Reports include both indist (sanity) and novel (headline).
170
+ - **No single-source dataset.** Audit rejects datasets <500 from any of {existing, brittney, community}.
171
+ - **No optional user study.** §4.4 is required not optional (per F.031).
172
+ - **No after-the-fact threshold-shopping.** preregistration.md is frozen before any Phase 3 board task is filed.
173
+ - **No qualitative-only claims.** ML venue requires numbers; pipeline emits structured measurements.
174
+ - **No validity gap as "scoped contribution"** — constrained-decoding architecture (Phase 2 module) bakes ≥90% validity into the decoder, not into a post-filter.
175
+
176
+ ---
177
+
178
+ ## Known limitations / future work
179
+
180
+ - Brittney few-shot baseline is a stub returning empty predictions; real impl needs HoloScript MCP integration (separate task).
181
+ - Constrained-decoder model module (`model/`) is the Phase 2 deliverable — not in this commit.
182
+ - Training loop + ablation matrix runner pending Phase 2.
183
+ - User study (Phase 4 §4.4) is a separate UX-research deliverable.
184
+ - The PowerShell Vast.ai launcher targets Windows; a bash equivalent for macOS/Linux is a follow-up.
185
+
186
+ ---
187
+
188
+ ## Provenance
189
+
190
+ - Authored by `trait-inference-brain` (`compositions/trait-inference-brain.hsplus`).
191
+ - GPU-claim ticket: `task_1777072040695_mrr3` (live on team_1775935947314_f0noxi board).
192
+ - Capability-build provenance commit: `fc294af` (lean-theorist-brain — sibling).
193
+ - F.031 pre-emptions baked into spec; constrained decoding ships in Phase 2 model module.
@@ -0,0 +1,16 @@
1
+ trait_inference/__init__.py,sha256=xDTQIAVanZUfWnPGIR75AVMt5wyFIkQKM_nUltM39UE,860
2
+ trait_inference/baselines.py,sha256=5T9gGDd-ZWSvS9dSJenPIeIDN4gGYBXuRKs7-x8SxvU,9430
3
+ trait_inference/cli.py,sha256=qOqA8UKjWFhuuBhoOlxsxatVFgboWVDmLgD3wW_w0EQ,20011
4
+ trait_inference/dataset.py,sha256=yeWf-wRnIX8gicy2Ej_XHwVhXLc-f-cfpd14zYWoePA,12644
5
+ trait_inference/metrics.py,sha256=azwMBnbevZXGV51GIczbCbVWkf9YiOGeg1xoZV0W19U,9934
6
+ trait_inference/eval/__init__.py,sha256=vb3NIkjYFdoeLsozAudUskKTzXL2Gf1ydpb2xebTyUE,659
7
+ trait_inference/eval/ablations.py,sha256=tB7ktuHT6q1cJGvAuo9MsP4HllvwiX6t69mIAES4TcQ,13960
8
+ trait_inference/model/__init__.py,sha256=2NMRfCqfec3prig_URrkuvfADnNu_mtVBBUynBMKcfA,1310
9
+ trait_inference/model/decoder.py,sha256=phpasQIa4Y3ebws3dNWMif4hp94BPADl-R0_lsaIBcM,7840
10
+ trait_inference/model/sweep.py,sha256=OmUA34J3AZaJ8iXUYmXcMx3ye2cLxay3kN7SAfcv6VQ,9016
11
+ trait_inference/model/trainer.py,sha256=Nz9anThl_Uwk2ZOfZZqL41pzl0t_6l_SNpkImyhnLcc,9164
12
+ holoscript_trait_inference-0.1.0.dist-info/METADATA,sha256=dsOQ1ZEL6hj3E6yek57WFXck4Ea50ERtt1xfQkEtabA,10062
13
+ holoscript_trait_inference-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ holoscript_trait_inference-0.1.0.dist-info/entry_points.txt,sha256=IO3AzHJLf9aNRzlM8PObHGJGX61AA2MZU-hYvQZCoVk,61
15
+ holoscript_trait_inference-0.1.0.dist-info/top_level.txt,sha256=qwYoGcHApqn612UU2QXZSfYFdIe0uEgydsZ8LV7gY3M,16
16
+ holoscript_trait_inference-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trait-inference = trait_inference.cli:main
@@ -0,0 +1 @@
1
+ trait_inference
@@ -0,0 +1,27 @@
1
+ """trait-inference — Paper 19 (ATI) training pipeline + baselines + eval.
2
+
3
+ See ai-ecosystem/research/paper-19-trait-inference/{phase-1-spec,
4
+ preregistration}.md for the frozen spec.
5
+
6
+ Modules:
7
+ dataset — schema, JSONL loader, splits, audit (CPU-only)
8
+ baselines — keyword + TF-IDF + few-shot baselines (CPU-only)
9
+ metrics — F1 macro, exact-match, validity rate, bootstrap CI
10
+ cli — argparse runner; entrypoint `trait-inference`
11
+
12
+ Optional (install [model] extra):
13
+ model — sentence-transformer encoder + constrained-decoder LLM
14
+ trainer — training loop (PyTorch + transformers)
15
+ """
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ # Re-export commonly used types so callers can `from trait_inference import Pair`.
20
+ from trait_inference.dataset import ( # noqa: F401
21
+ Pair,
22
+ Source,
23
+ Split,
24
+ audit,
25
+ load_jsonl,
26
+ write_jsonl,
27
+ )
@@ -0,0 +1,238 @@
1
+ """Baselines — Paper 19 ATI.
2
+
3
+ Per `phase-1-spec.md` §2 — ≥3 baselines required:
4
+ 1. Keyword match (trivial; CPU; ~30-55% F1 expected on novel split)
5
+ 2. TF-IDF + multi-label LogReg (classical; CPU; ~50-65% F1 expected)
6
+ 3. Brittney few-shot (strong; needs Brittney API; ~60-75% F1 expected)
7
+
8
+ Per `trait-inference-brain` priority 3 + `phase-1-spec.md` §2:
9
+ > Build baselines BEFORE contribution model. If a baseline already
10
+ > hits the gate, the contribution is null.
11
+
12
+ Each baseline implements:
13
+ fit(train_pairs) -> None # may be no-op for keyword
14
+ predict(description) -> list[str]
15
+ predict_batch(descs) -> list[list[str]]
16
+
17
+ This module is CPU-only. Brittney baseline imports lazily so missing
18
+ API key doesn't break keyword/tfidf usage.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass, field
25
+ from typing import Protocol
26
+
27
+ import numpy as np
28
+ from sklearn.feature_extraction.text import TfidfVectorizer
29
+ from sklearn.linear_model import LogisticRegression
30
+ from sklearn.multiclass import OneVsRestClassifier
31
+ from sklearn.preprocessing import MultiLabelBinarizer
32
+
33
+ from trait_inference.dataset import Pair
34
+
35
+
36
+ # ----------------------------------------------------------------------------
37
+ # Baseline protocol
38
+ # ----------------------------------------------------------------------------
39
+
40
+ class Baseline(Protocol):
41
+ """Common interface for all baselines."""
42
+ name: str
43
+
44
+ def fit(self, train_pairs: list[Pair]) -> None: ...
45
+ def predict(self, description: str) -> list[str]: ...
46
+ def predict_batch(self, descriptions: list[str]) -> list[list[str]]: ...
47
+
48
+
49
+ # ----------------------------------------------------------------------------
50
+ # Baseline 1: Keyword match (trivial)
51
+ # ----------------------------------------------------------------------------
52
+
53
+ @dataclass
54
+ class KeywordBaseline:
55
+ """Trivial baseline: predict trait T if T's name (or known synonym)
56
+ appears in the description.
57
+
58
+ Per `phase-1-spec.md` §2.1 — expected F1 ≈ 30-55% on novel split.
59
+ """
60
+ name: str = "keyword"
61
+ label_space: tuple[str, ...] = ()
62
+ synonyms: dict[str, list[str]] = field(default_factory=dict)
63
+ _patterns: dict[str, re.Pattern[str]] = field(default_factory=dict, init=False)
64
+
65
+ def __post_init__(self) -> None:
66
+ self._compile()
67
+
68
+ def _compile(self) -> None:
69
+ """Build per-trait word-boundary regex from name + synonyms."""
70
+ self._patterns = {}
71
+ for trait in self.label_space:
72
+ # Strip @-prefix and _-separators for matching.
73
+ normalized = trait.lstrip("@").replace("_", " ")
74
+ tokens = [normalized] + self.synonyms.get(trait, [])
75
+ # Word-boundary regex; case-insensitive.
76
+ pattern = r"\b(" + "|".join(re.escape(t) for t in tokens) + r")\b"
77
+ self._patterns[trait] = re.compile(pattern, re.IGNORECASE)
78
+
79
+ def fit(self, train_pairs: list[Pair]) -> None:
80
+ # Could optionally derive synonyms from train co-occurrence here.
81
+ # Phase 1: synonyms come from constructor (caller-supplied).
82
+ return
83
+
84
+ def predict(self, description: str) -> list[str]:
85
+ hits = [t for t, p in self._patterns.items() if p.search(description)]
86
+ return sorted(hits)
87
+
88
+ def predict_batch(self, descriptions: list[str]) -> list[list[str]]:
89
+ return [self.predict(d) for d in descriptions]
90
+
91
+
92
+ # ----------------------------------------------------------------------------
93
+ # Baseline 2: TF-IDF + multi-label Logistic Regression (classical)
94
+ # ----------------------------------------------------------------------------
95
+
96
+ @dataclass
97
+ class TfidfLogregBaseline:
98
+ """TF-IDF vectorizer + one-vs-rest LogReg per trait.
99
+
100
+ Per `phase-1-spec.md` §2.2 — expected F1 ≈ 50-65% on novel split.
101
+ Captures bag-of-words associations beyond lexical match but cannot
102
+ generalize across novel combinations because training only sees
103
+ existing combinations.
104
+ """
105
+ name: str = "tfidf_logreg"
106
+ threshold: float = 0.5 # decision threshold for trait inclusion
107
+ max_features: int = 10000
108
+ ngram_range: tuple[int, int] = (1, 2)
109
+ _vectorizer: TfidfVectorizer | None = None
110
+ _classifier: OneVsRestClassifier | None = None
111
+ _binarizer: MultiLabelBinarizer | None = None
112
+ _label_space: list[str] = field(default_factory=list)
113
+
114
+ def fit(self, train_pairs: list[Pair]) -> None:
115
+ if not train_pairs:
116
+ raise ValueError("cannot fit on empty training set")
117
+
118
+ descriptions = [p.description for p in train_pairs]
119
+ trait_sets = [list(p.trait_set) for p in train_pairs]
120
+
121
+ # Establish label space from training data
122
+ self._binarizer = MultiLabelBinarizer()
123
+ Y = self._binarizer.fit_transform(trait_sets)
124
+ self._label_space = list(self._binarizer.classes_)
125
+
126
+ self._vectorizer = TfidfVectorizer(
127
+ max_features=self.max_features,
128
+ ngram_range=self.ngram_range,
129
+ sublinear_tf=True,
130
+ stop_words="english",
131
+ )
132
+ X = self._vectorizer.fit_transform(descriptions)
133
+
134
+ # Multi-label via one-vs-rest. LogReg's predict_proba enables
135
+ # threshold tuning on the validation split.
136
+ self._classifier = OneVsRestClassifier(
137
+ LogisticRegression(max_iter=500, C=1.0, solver="liblinear"),
138
+ n_jobs=-1,
139
+ )
140
+ self._classifier.fit(X, Y)
141
+
142
+ def _ensure_fit(self) -> None:
143
+ if self._vectorizer is None or self._classifier is None or self._binarizer is None:
144
+ raise RuntimeError("call fit() before predict()")
145
+
146
+ def predict(self, description: str) -> list[str]:
147
+ return self.predict_batch([description])[0]
148
+
149
+ def predict_batch(self, descriptions: list[str]) -> list[list[str]]:
150
+ self._ensure_fit()
151
+ assert self._vectorizer is not None and self._classifier is not None
152
+ assert self._binarizer is not None
153
+ X = self._vectorizer.transform(descriptions)
154
+ # predict_proba returns shape (n_samples, n_labels)
155
+ proba = self._classifier.predict_proba(X)
156
+ out: list[list[str]] = []
157
+ for row in proba:
158
+ chosen = [
159
+ self._label_space[i]
160
+ for i, p in enumerate(row)
161
+ if p >= self.threshold
162
+ ]
163
+ out.append(sorted(chosen))
164
+ return out
165
+
166
+ def tune_threshold(
167
+ self,
168
+ val_pairs: list[Pair],
169
+ candidates: Iterable[float] = (0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8),
170
+ ) -> float:
171
+ """Find the threshold that maximizes F1 macro on the validation
172
+ split. Updates self.threshold and returns it."""
173
+ from trait_inference.metrics import f1_macro
174
+
175
+ descriptions = [p.description for p in val_pairs]
176
+ gold = [list(p.trait_set) for p in val_pairs]
177
+
178
+ best_thr, best_f1 = self.threshold, -1.0
179
+ for thr in candidates:
180
+ self.threshold = thr
181
+ preds = self.predict_batch(descriptions)
182
+ score = f1_macro(gold, preds, label_space=self._label_space)
183
+ if score > best_f1:
184
+ best_thr, best_f1 = thr, score
185
+ self.threshold = best_thr
186
+ return best_thr
187
+
188
+
189
+ # ----------------------------------------------------------------------------
190
+ # Baseline 3: Brittney few-shot (strong; lazy import — needs API access)
191
+ # ----------------------------------------------------------------------------
192
+
193
+ @dataclass
194
+ class BrittneyFewShotBaseline:
195
+ """In-context-learn from K=8 stratified examples + Brittney prompt.
196
+
197
+ Per `phase-1-spec.md` §2.3 — expected F1 ≈ 60-75% on novel split.
198
+ Strongest baseline — represents "what you get for free from the
199
+ existing system."
200
+
201
+ Phase 3 implementation deferred — depends on Brittney API surface
202
+ decision (HoloScript MCP `brittney_*` tools). This stub captures the
203
+ interface so the eval harness can be tested end-to-end with mock
204
+ behavior; the real implementation lands when training Phase 3 starts.
205
+ """
206
+ name: str = "brittney_fewshot"
207
+ k_shots: int = 8
208
+ _examples: list[Pair] = field(default_factory=list)
209
+
210
+ def fit(self, train_pairs: list[Pair]) -> None:
211
+ # Stratified sampling: pick K examples that maximize trait diversity
212
+ # in the prompt context. Greedy: each next example adds the most
213
+ # not-yet-seen traits.
214
+ if not train_pairs:
215
+ self._examples = []
216
+ return
217
+ seen: set[str] = set()
218
+ chosen: list[Pair] = []
219
+ remaining = list(train_pairs)
220
+ while len(chosen) < self.k_shots and remaining:
221
+ best_idx, best_new = 0, -1
222
+ for i, p in enumerate(remaining):
223
+ new_traits = len(set(p.trait_set) - seen)
224
+ if new_traits > best_new:
225
+ best_idx, best_new = i, new_traits
226
+ picked = remaining.pop(best_idx)
227
+ chosen.append(picked)
228
+ seen.update(picked.trait_set)
229
+ self._examples = chosen
230
+
231
+ def predict(self, description: str) -> list[str]:
232
+ # Phase 1 stub: return empty (will be replaced by Brittney API call
233
+ # in Phase 3). Empty prediction is the most honest non-call default
234
+ # — the eval harness will record this baseline as needing impl.
235
+ return []
236
+
237
+ def predict_batch(self, descriptions: list[str]) -> list[list[str]]:
238
+ return [self.predict(d) for d in descriptions]