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.
- holoscript_trait_inference-0.1.0.dist-info/METADATA +193 -0
- holoscript_trait_inference-0.1.0.dist-info/RECORD +16 -0
- holoscript_trait_inference-0.1.0.dist-info/WHEEL +5 -0
- holoscript_trait_inference-0.1.0.dist-info/entry_points.txt +2 -0
- holoscript_trait_inference-0.1.0.dist-info/top_level.txt +1 -0
- trait_inference/__init__.py +27 -0
- trait_inference/baselines.py +238 -0
- trait_inference/cli.py +498 -0
- trait_inference/dataset.py +348 -0
- trait_inference/eval/__init__.py +21 -0
- trait_inference/eval/ablations.py +336 -0
- trait_inference/metrics.py +291 -0
- trait_inference/model/__init__.py +34 -0
- trait_inference/model/decoder.py +192 -0
- trait_inference/model/sweep.py +242 -0
- trait_inference/model/trainer.py +237 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Dataset module — Paper 19 ATI.
|
|
2
|
+
|
|
3
|
+
Implements the dataset spec from
|
|
4
|
+
`research/paper-19-trait-inference/phase-1-spec.md` §1.
|
|
5
|
+
|
|
6
|
+
Schema, JSONL I/O, train/val/test/novel-combination splits, and
|
|
7
|
+
audit checks.
|
|
8
|
+
|
|
9
|
+
Pure CPU; no GPU dependencies.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, Iterator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Source(str, Enum):
|
|
24
|
+
EXISTING = "existing"
|
|
25
|
+
BRITTNEY = "brittney"
|
|
26
|
+
COMMUNITY = "community"
|
|
27
|
+
NEGATIVE = "negative"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Split(str, Enum):
|
|
31
|
+
TRAIN = "train"
|
|
32
|
+
VAL = "val"
|
|
33
|
+
HELD_OUT_INDIST = "held_out_indist"
|
|
34
|
+
HELD_OUT_NOVEL = "held_out_novel"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Pair:
|
|
39
|
+
"""One labeled (description -> trait set) pair.
|
|
40
|
+
|
|
41
|
+
Schema matches `phase-1-spec.md` §1.2 verbatim.
|
|
42
|
+
"""
|
|
43
|
+
id: str
|
|
44
|
+
description: str
|
|
45
|
+
trait_set: tuple[str, ...] # sorted, canonical order
|
|
46
|
+
source: Source
|
|
47
|
+
expected_validity: str # "valid" | "invalid" | "empty"
|
|
48
|
+
novel_combination: bool
|
|
49
|
+
audit_status: str = "pending" # "pending" | "passed" | "rejected"
|
|
50
|
+
created_at: str = ""
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
# Canonicalize trait set ordering at construction time so equality
|
|
54
|
+
# comparisons are deterministic regardless of input order.
|
|
55
|
+
if list(self.trait_set) != sorted(self.trait_set):
|
|
56
|
+
object.__setattr__(self, "trait_set", tuple(sorted(self.trait_set)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ----------------------------------------------------------------------------
|
|
60
|
+
# JSONL I/O
|
|
61
|
+
# ----------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def load_jsonl(path: Path | str) -> list[Pair]:
|
|
64
|
+
"""Load a dataset from JSONL. Robust to extra fields (forward-compat)."""
|
|
65
|
+
pairs: list[Pair] = []
|
|
66
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
67
|
+
for lineno, line in enumerate(fh, start=1):
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
obj = json.loads(line)
|
|
73
|
+
except json.JSONDecodeError as exc:
|
|
74
|
+
raise ValueError(f"line {lineno}: invalid JSON: {exc}") from exc
|
|
75
|
+
try:
|
|
76
|
+
pair = Pair(
|
|
77
|
+
id=str(obj["id"]),
|
|
78
|
+
description=str(obj["description"]),
|
|
79
|
+
trait_set=tuple(obj["trait_set"]),
|
|
80
|
+
source=Source(obj["source"]),
|
|
81
|
+
expected_validity=str(obj["expected_validity"]),
|
|
82
|
+
novel_combination=bool(obj["novel_combination"]),
|
|
83
|
+
audit_status=str(obj.get("audit_status", "pending")),
|
|
84
|
+
created_at=str(obj.get("created_at", "")),
|
|
85
|
+
)
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
raise ValueError(f"line {lineno}: missing field {exc}") from exc
|
|
88
|
+
pairs.append(pair)
|
|
89
|
+
return pairs
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write_jsonl(path: Path | str, pairs: Iterable[Pair]) -> int:
|
|
93
|
+
"""Write pairs to JSONL. Returns count written. Atomic via temp + rename."""
|
|
94
|
+
path = Path(path)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
97
|
+
n = 0
|
|
98
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
99
|
+
for pair in pairs:
|
|
100
|
+
obj = asdict(pair)
|
|
101
|
+
obj["source"] = pair.source.value # enum -> string
|
|
102
|
+
obj["trait_set"] = list(pair.trait_set) # tuple -> list for JSON
|
|
103
|
+
fh.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
|
104
|
+
n += 1
|
|
105
|
+
tmp.replace(path)
|
|
106
|
+
return n
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ----------------------------------------------------------------------------
|
|
110
|
+
# Splits
|
|
111
|
+
# ----------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def make_splits(
|
|
114
|
+
pairs: list[Pair],
|
|
115
|
+
*,
|
|
116
|
+
seed: int = 42,
|
|
117
|
+
train_frac: float = 0.70,
|
|
118
|
+
val_frac: float = 0.10,
|
|
119
|
+
indist_frac: float = 0.10,
|
|
120
|
+
# remaining → held_out_novel; computed not declared
|
|
121
|
+
) -> dict[Split, list[Pair]]:
|
|
122
|
+
"""Create train/val/held_out_indist/held_out_novel splits per
|
|
123
|
+
`phase-1-spec.md` §1.3.
|
|
124
|
+
|
|
125
|
+
The novel-combination split contains examples whose `trait_set` does NOT
|
|
126
|
+
appear in the training set. This is the headline split per
|
|
127
|
+
`preregistration.md` §1.
|
|
128
|
+
|
|
129
|
+
Strategy:
|
|
130
|
+
1. Shuffle by deterministic seed.
|
|
131
|
+
2. Allocate negative examples evenly across all splits.
|
|
132
|
+
3. Group pairs by canonical trait_set; ensure ≥1 group per split.
|
|
133
|
+
4. Reserve groups whose trait_sets are unique-in-corpus for
|
|
134
|
+
held_out_novel; remaining groups distributed by frac.
|
|
135
|
+
"""
|
|
136
|
+
total = train_frac + val_frac + indist_frac
|
|
137
|
+
if total >= 1.0:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"split fractions must leave room for held_out_novel "
|
|
140
|
+
f"(train+val+indist={total:.3f}, must be <1.0)"
|
|
141
|
+
)
|
|
142
|
+
if any(f < 0 for f in (train_frac, val_frac, indist_frac)):
|
|
143
|
+
raise ValueError("split fractions must be non-negative")
|
|
144
|
+
|
|
145
|
+
rng = random.Random(seed)
|
|
146
|
+
|
|
147
|
+
# Index pairs by trait_set (canonical tuple) so we can decide novelty.
|
|
148
|
+
by_trait: dict[tuple[str, ...], list[Pair]] = {}
|
|
149
|
+
for p in pairs:
|
|
150
|
+
by_trait.setdefault(p.trait_set, []).append(p)
|
|
151
|
+
|
|
152
|
+
trait_groups = list(by_trait.items())
|
|
153
|
+
rng.shuffle(trait_groups)
|
|
154
|
+
|
|
155
|
+
# Split groups: novel = groups whose trait_set has only 1-2 examples
|
|
156
|
+
# (rare combos) — these are the "held out" candidates. Common combos
|
|
157
|
+
# populate train/val/indist.
|
|
158
|
+
rare = [g for g in trait_groups if len(g[1]) <= 2]
|
|
159
|
+
common = [g for g in trait_groups if len(g[1]) > 2]
|
|
160
|
+
|
|
161
|
+
novel_pairs = [p for _, examples in rare for p in examples]
|
|
162
|
+
|
|
163
|
+
# Distribute common-trait pairs to train/val/indist by fraction.
|
|
164
|
+
common_pairs = [p for _, examples in common for p in examples]
|
|
165
|
+
rng.shuffle(common_pairs)
|
|
166
|
+
n = len(common_pairs)
|
|
167
|
+
n_train = int(n * train_frac)
|
|
168
|
+
n_val = int(n * val_frac)
|
|
169
|
+
n_indist = int(n * indist_frac)
|
|
170
|
+
|
|
171
|
+
train_pairs = common_pairs[:n_train]
|
|
172
|
+
val_pairs = common_pairs[n_train : n_train + n_val]
|
|
173
|
+
indist_pairs = common_pairs[n_train + n_val : n_train + n_val + n_indist]
|
|
174
|
+
|
|
175
|
+
splits: dict[Split, list[Pair]] = {
|
|
176
|
+
Split.TRAIN: train_pairs,
|
|
177
|
+
Split.VAL: val_pairs,
|
|
178
|
+
Split.HELD_OUT_INDIST: indist_pairs,
|
|
179
|
+
Split.HELD_OUT_NOVEL: novel_pairs,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Final novelty check: any pair in HELD_OUT_NOVEL whose trait_set DOES
|
|
183
|
+
# appear in TRAIN gets moved to HELD_OUT_INDIST. (Edge case: rare combo
|
|
184
|
+
# that happens to share traits with a common combo via overlap — we keep
|
|
185
|
+
# the novelty invariant strict.)
|
|
186
|
+
train_trait_sets = {p.trait_set for p in train_pairs}
|
|
187
|
+
truly_novel = [p for p in novel_pairs if p.trait_set not in train_trait_sets]
|
|
188
|
+
moved_to_indist = [p for p in novel_pairs if p.trait_set in train_trait_sets]
|
|
189
|
+
splits[Split.HELD_OUT_NOVEL] = truly_novel
|
|
190
|
+
splits[Split.HELD_OUT_INDIST] = indist_pairs + moved_to_indist
|
|
191
|
+
|
|
192
|
+
return splits
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ----------------------------------------------------------------------------
|
|
196
|
+
# Audit
|
|
197
|
+
# ----------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class AuditReport:
|
|
201
|
+
"""Result of running audit() against a dataset.
|
|
202
|
+
|
|
203
|
+
Acceptance criteria from `phase-1-spec.md` §1.4 — a dataset is
|
|
204
|
+
acceptable for Phase 3 training when ALL items below pass.
|
|
205
|
+
"""
|
|
206
|
+
total_pairs: int = 0
|
|
207
|
+
pairs_per_source: dict[str, int] = field(default_factory=dict)
|
|
208
|
+
novel_split_size: int = 0
|
|
209
|
+
novel_split_unique_combos: int = 0
|
|
210
|
+
negative_count: int = 0
|
|
211
|
+
issues: list[str] = field(default_factory=list)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def passes(self) -> bool:
|
|
215
|
+
"""Spec acceptance: ≥2k pairs, ≥300 novel, ≥500 each major source,
|
|
216
|
+
≥200 negatives, no issues."""
|
|
217
|
+
return (
|
|
218
|
+
self.total_pairs >= 2000
|
|
219
|
+
and self.novel_split_size >= 300
|
|
220
|
+
and self.pairs_per_source.get("existing", 0) >= 500
|
|
221
|
+
and self.pairs_per_source.get("brittney", 0) >= 500
|
|
222
|
+
and self.pairs_per_source.get("community", 0) >= 300
|
|
223
|
+
and self.negative_count >= 200
|
|
224
|
+
and not self.issues
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> dict:
|
|
228
|
+
return {
|
|
229
|
+
**asdict(self),
|
|
230
|
+
"passes": self.passes,
|
|
231
|
+
"audited_at": datetime.now(timezone.utc).isoformat(),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def audit(pairs: list[Pair], splits: dict[Split, list[Pair]] | None = None) -> AuditReport:
|
|
236
|
+
"""Audit a dataset against `phase-1-spec.md` §1.4 acceptance criteria."""
|
|
237
|
+
report = AuditReport()
|
|
238
|
+
report.total_pairs = len(pairs)
|
|
239
|
+
|
|
240
|
+
# Source distribution
|
|
241
|
+
for p in pairs:
|
|
242
|
+
report.pairs_per_source[p.source.value] = (
|
|
243
|
+
report.pairs_per_source.get(p.source.value, 0) + 1
|
|
244
|
+
)
|
|
245
|
+
report.negative_count = report.pairs_per_source.get("negative", 0)
|
|
246
|
+
|
|
247
|
+
# Novel split (compute splits if not provided)
|
|
248
|
+
if splits is None:
|
|
249
|
+
splits = make_splits(pairs)
|
|
250
|
+
novel_pairs = splits.get(Split.HELD_OUT_NOVEL, [])
|
|
251
|
+
report.novel_split_size = len(novel_pairs)
|
|
252
|
+
report.novel_split_unique_combos = len({p.trait_set for p in novel_pairs})
|
|
253
|
+
|
|
254
|
+
# Issues
|
|
255
|
+
if report.total_pairs < 2000:
|
|
256
|
+
report.issues.append(f"total {report.total_pairs} < 2000 minimum")
|
|
257
|
+
if report.novel_split_size < 300:
|
|
258
|
+
report.issues.append(
|
|
259
|
+
f"novel-combination split {report.novel_split_size} < 300 minimum"
|
|
260
|
+
)
|
|
261
|
+
if report.pairs_per_source.get("existing", 0) < 500:
|
|
262
|
+
report.issues.append(
|
|
263
|
+
f"existing source {report.pairs_per_source.get('existing', 0)} < 500 minimum"
|
|
264
|
+
)
|
|
265
|
+
if report.pairs_per_source.get("brittney", 0) < 500:
|
|
266
|
+
report.issues.append(
|
|
267
|
+
f"brittney source {report.pairs_per_source.get('brittney', 0)} < 500 minimum"
|
|
268
|
+
)
|
|
269
|
+
if report.pairs_per_source.get("community", 0) < 300:
|
|
270
|
+
report.issues.append(
|
|
271
|
+
f"community source {report.pairs_per_source.get('community', 0)} < 300 minimum"
|
|
272
|
+
)
|
|
273
|
+
if report.negative_count < 200:
|
|
274
|
+
report.issues.append(
|
|
275
|
+
f"negative examples {report.negative_count} < 200 minimum"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Spot-check novelty: sample 50 from novel split, verify trait_set not
|
|
279
|
+
# in any train example. If splits weren't provided, this is comparing
|
|
280
|
+
# against the same generated splits, so it's a self-consistency check.
|
|
281
|
+
train_trait_sets = {p.trait_set for p in splits[Split.TRAIN]}
|
|
282
|
+
leaked = [p for p in novel_pairs if p.trait_set in train_trait_sets]
|
|
283
|
+
if leaked:
|
|
284
|
+
report.issues.append(
|
|
285
|
+
f"novelty leak: {len(leaked)} novel-split pairs share trait_set with train"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return report
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ----------------------------------------------------------------------------
|
|
292
|
+
# Synthetic dataset generator (for smoke tests + CI)
|
|
293
|
+
# ----------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def generate_smoke_dataset(
|
|
296
|
+
label_space: list[str],
|
|
297
|
+
*,
|
|
298
|
+
n: int = 100,
|
|
299
|
+
seed: int = 42,
|
|
300
|
+
) -> list[Pair]:
|
|
301
|
+
"""Generate a small synthetic dataset for smoke-testing the pipeline.
|
|
302
|
+
|
|
303
|
+
NOT for headline measurement — real datasets must come from the 3-source
|
|
304
|
+
mix per spec §1.1. This generator exists so unit tests + the smoke-test
|
|
305
|
+
CLI command can run without requiring the full data-collection pipeline.
|
|
306
|
+
"""
|
|
307
|
+
rng = random.Random(seed)
|
|
308
|
+
pairs: list[Pair] = []
|
|
309
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
310
|
+
|
|
311
|
+
for i in range(n):
|
|
312
|
+
# Random trait subset of size 1-4
|
|
313
|
+
k = rng.randint(1, min(4, len(label_space)))
|
|
314
|
+
traits = tuple(sorted(rng.sample(label_space, k)))
|
|
315
|
+
# Synthetic description: trait names joined with filler words
|
|
316
|
+
desc_words = ["a"] + list(traits) + rng.choice([
|
|
317
|
+
["object"], ["thing"], ["entity"], ["construct"], ["element"]
|
|
318
|
+
])
|
|
319
|
+
desc = " ".join(desc_words).replace("@", "").replace("_", " ")
|
|
320
|
+
|
|
321
|
+
pairs.append(Pair(
|
|
322
|
+
id=f"smoke-{i:04d}",
|
|
323
|
+
description=desc,
|
|
324
|
+
trait_set=traits,
|
|
325
|
+
source=Source.BRITTNEY,
|
|
326
|
+
expected_validity="valid",
|
|
327
|
+
novel_combination=False,
|
|
328
|
+
audit_status="passed",
|
|
329
|
+
created_at=now,
|
|
330
|
+
))
|
|
331
|
+
|
|
332
|
+
# Add a few negatives
|
|
333
|
+
for i in range(n, n + max(2, n // 20)):
|
|
334
|
+
pairs.append(Pair(
|
|
335
|
+
id=f"smoke-neg-{i:04d}",
|
|
336
|
+
description=rng.choice([
|
|
337
|
+
"the number seven", "an abstract concept",
|
|
338
|
+
"explain quantum entanglement", "purely conceptual",
|
|
339
|
+
]),
|
|
340
|
+
trait_set=(),
|
|
341
|
+
source=Source.NEGATIVE,
|
|
342
|
+
expected_validity="empty",
|
|
343
|
+
novel_combination=False,
|
|
344
|
+
audit_status="passed",
|
|
345
|
+
created_at=now,
|
|
346
|
+
))
|
|
347
|
+
|
|
348
|
+
return pairs
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""trait_inference.eval — Phase 4 evaluation harness.
|
|
2
|
+
|
|
3
|
+
Per `phase-1-spec.md` §4: bootstrap-CI metrics + ablation matrix +
|
|
4
|
+
required user study analysis.
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
ablations — 5-row ablation matrix runner per spec §3.5
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__ = ["AblationMatrix", "AblationConfig", "AblationResult"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name in {"AblationMatrix", "AblationConfig", "AblationResult"}:
|
|
15
|
+
from trait_inference.eval.ablations import (
|
|
16
|
+
AblationConfig,
|
|
17
|
+
AblationMatrix,
|
|
18
|
+
AblationResult,
|
|
19
|
+
)
|
|
20
|
+
return locals()[name]
|
|
21
|
+
raise AttributeError(f"module 'trait_inference.eval' has no attribute {name!r}")
|