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,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}")