File size: 27,364 Bytes
be2a1e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
#!/usr/bin/env python3
"""Gnom trainer — KLD era (rewrite 2026-10-16).

    train_gnom.py --pool gnom/pool_v4.json --out gnom/models/v4.npz
                  [--variant trunk|modelbias|moeshared|rank|all] [--seed 7]
                  [--metric damage_kld] [--tail-feats]

Targets: the KLD family, 5 columns —
    damage_kld          mean KLD delta (primary, the bar column)
    damage_kld_max      tail / detonation
    damage_kld_median   body / skew
    damage_dp_rms       behavioral shift
    damage_same_top     top-1 agreement drop
All five arrive in one deterministic forward pass (new contract); the
loader reads any of them by name ('' as multi-target head sets).

DEAD METRICS (measured dead, kept only for archival pools):
    damage       PPL delta. Q5->Q4 std 0.77 BELOW the ~1.0 noise floor;
                 LOMO <= 0.06 across models (gnom/README, 2026-09/10).
    ssim_damage  structural. 4/4 structural metrics rank together and are
                 orthogonal to KLD/PPL (README SSIM section) — surrogate,
                 not discovery. No code path here does anything special
                 for it anyway.
Default --metric is and stays damage_kld.

Pool spec: [{"features": X.npz, "labels": L.jsonl, "model": tag,
             "base_bpw": 5.5, "drop_bpw": 4.5, "metric": "damage_kld"}, ...]
- null != 0. A null metric value (TERMINAL/censored record) is counted
  and skipped, never imputed (the guard this file has carried since
  2026-09-28, now explicit per-column).
- HOLE records live in *.failures.jsonl and are not read here at all.
- mapping check: every unit that carries a value must map to feature
  rows, tensor-complete. "mapping: U/U units, T/T tensors" is printed on
  every load; ANY miss prints details and exits 1 (--allow-partial-map
  downgrades the unit-with-no-match case to a warning). A pool that
  cannot prove its plumbing must not train.

Report is always: random split + LOMO (needs >= 2 models) + timp
baseline, each with its MDE at 80% power (alpha=0.05) next to it.
Bars: LOMO >= 0.45 (regression), rank LOMO-acc >= 0.60 (coin 0.50).
"""
import argparse
import copy
import json
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tinynet import (init_params, train, forward, standardize, fit_mixed,
                     mixed_predict, moe_train_shared, moe_predict_shared,
                     backward_delta)

KLD_COLUMNS = ["damage_kld", "damage_kld_max", "damage_kld_median",
               "damage_dp_rms", "damage_same_top"]

# z_{0.975} + z_{0.80}: two-sided alpha 0.05, power 80%
Z_POWER = 1.959963984540054 + 0.8416212335729143  # ~2.8016


def mde_rho(n):
    """MDE for a single Spearman exam at 80% power (Fisher-z approx)."""
    return Z_POWER / np.sqrt(n - 3) if n > 3 else float("inf")


def mde_lomo(ns):
    """MDE of the MEAN LOMO rho across folds of sizes ns (z-space)."""
    ns = [n for n in ns if n > 3]
    if not ns:
        return float("inf")
    se = np.sqrt(sum(1.0 / (n - 3) for n in ns)) / len(ns)
    return Z_POWER * se


def mde_pair(n):
    """MDE for pair accuracy vs coin 0.50 at 80% power (n pairs)."""
    return Z_POWER * 0.5 / np.sqrt(n) if n > 0 else float("inf")


def build_pairs(y, models, max_pairs=30000, seed=7):
    """Within-model ordered pairs (a beats b): decisions, not values.
    Cross-model pairs are meaningless (damage scales differ per model).
    Returns (model, i, j, gap): RDO-style (2024/26 offline-RLHF) — HOW MUCH
    one is preferred matters, not just the order. Gap doubles as the
    resolution filter: pairs below measurement noise carry ~no weight."""
    rng = np.random.default_rng(seed)
    pairs = []
    for m in sorted(set(models.tolist() if hasattr(models, "tolist") else models)):
        idx = np.where(np.asarray(models) == m)[0]
        yy = y[idx]
        scale = float(np.std(yy) + 1e-12)
        cand = [(i, j, abs(float(yy[a] - y[j])) / scale)
                for a, i in enumerate(idx) for j in idx
                if yy[a] > y[j] + 1e-12]
        # (dead-zone tried 2026-10-03 and lost with the cap as a package:
        # uniform-over-all-pairs 0.575 stands as the best setting.)
        cand = [(i, j, g) for (i, j, g) in cand]
        if len(cand) > max_pairs:
            cand = [cand[k] for k in rng.choice(len(cand), max_pairs, replace=False)]
        pairs += [(m, i, j, g) for (i, j, g) in cand]
    return pairs


def pair_acc(scores, y, models, idx):
    """Fraction of within-model pairs ordered correctly."""
    idx = np.asarray(idx)
    tot, hit = 0, 0
    for m in set(np.asarray(models)[idx].tolist()):
        ii = idx[np.asarray(models)[idx] == m]
        yy = y[ii]
        ss = np.asarray(scores)[ii]
        for a in range(len(ii)):
            for b in range(len(ii)):
                if yy[a] > yy[b] + 1e-12:
                    tot += 1
                    if ss[a] > ss[b]:
                        hit += 1
    return hit / tot if tot else 0.5


def pair_count(y, models, idx):
    """Number of within-model ordered pairs in the eval set (for MDE)."""
    idx = np.asarray(idx)
    tot = 0
    for m in set(np.asarray(models)[idx].tolist()):
        ii = idx[np.asarray(models)[idx] == m]
        yy = y[ii]
        for a in range(len(ii)):
            for b in range(len(ii)):
                if yy[a] > yy[b] + 1e-12:
                    tot += 1
    return tot


def train_rank(ps, X, pairs_idx, lr=3e-3, epochs=1500, l2=1e-5, seed=7,
               activation="mish", log_every=1500):
    """RankNet on score differences (operator's System-1 idea): the net
    learns WHICH unit suffers more, never how much. Robust to label
    noise by construction — a flipped magnitude keeps its order."""
    rng = np.random.default_rng(seed)
    P = np.asarray([(a, b) for (_, a, b, _) in pairs_idx])
    W = np.asarray([g for (_, _, _, g) in pairs_idx], dtype=np.float64)
    W = W / W.sum()
    nval = max(4, int(len(P) * 0.2))
    vidx = rng.choice(len(P), nval, replace=False)
    mask = np.ones(len(P), bool)
    mask[vidx] = False
    trp, vap = P[mask], P[~mask]
    # NOTE (2026-10-03): RDO-style gap weighting tried here (W below) and
    # LOST (0.563 vs 0.575 unweighted). Why: with n=770 noisy labels the
    # big-gap pairs are outliers that mislead; the transfer boundary lives
    # in close pairs. Uniform stands; RDO left in code as an option.
    _USE_RDO = False  # verdict 2026-10-03: uniform 0.575 > huber 0.568 >
    # raw-RDO 0.563. Dead-zone killed the close pairs where the boundary
    # lives. Uniform stands, finally.
    Wtr, Wva = W[mask], W[~mask]
    if not _USE_RDO:
        Wtr = np.ones_like(Wtr) / len(Wtr)
    else:
        Wtr = np.minimum(Wtr, 1.0)  # Huber cap: nothing above 1 std counts extra
    m = [{k: np.zeros_like(v) for k, v in p.items()} for p in ps]
    v = [{k: np.zeros_like(v) for k, v in p.items()} for p in ps]
    best = (0.0, None, 0)
    for ep in range(1, epochs + 1):
        bi = rng.choice(len(trp), min(512, len(trp)), replace=False)
        A, B = trp[bi][:, 0], trp[bi][:, 1]
        fa, preca = forward(ps, X[A], activation)
        fb, precb = forward(ps, X[B], activation)
        d = (fa[-1].ravel() - fb[-1].ravel()) / 1.0
        sig = 1.0 / (1.0 + np.exp(np.clip(d, -30, 30)))
        wb = Wtr[bi] / Wtr[bi].sum()
        ga, gb = -sig * wb, +sig * wb
        grads = [{k: np.zeros_like(vv) for k, vv in p.items()} for p in ps]
        for acts, pre, g in ((fa, preca, ga), (fb, precb, gb)):
            delta = g[:, None]
            for i in reversed(range(len(ps))):
                grads[i]["W"] += acts[i].T @ delta + l2 * ps[i]["W"] / 2
                grads[i]["b"] += delta.sum(axis=0)
                if i > 0:
                    delta = backward_delta(delta @ ps[i]["W"].T, pre[i - 1],
                                           activation)
        for i in range(len(ps)):
            for k in ("W", "b"):
                g = grads[i][k]
                m[i][k] = 0.9 * m[i][k] + 0.1 * g
                v[i][k] = 0.95 * v[i][k] + 0.05 * g * g
                mh = m[i][k] / (1 - 0.9 ** ep)
                vh = v[i][k] / (1 - 0.95 ** ep)
                ps[i][k] -= lr * mh / (np.sqrt(vh) + 1e-8)
        pa = forward(ps, X[vap[:, 0]], activation)[0][-1].ravel()
        pb = forward(ps, X[vap[:, 1]], activation)[0][-1].ravel()
        acc = float(np.mean(pa > pb))
        if acc > best[0]:
            best = (acc, copy.deepcopy(ps), ep)
        if ep % log_every == 0 or ep == 1:
            print(f"  rank ep {ep}: val pair-acc={acc:.4f} "
                  f"(best {best[0]:.4f} @ {best[2]})", flush=True)
    if best[1] is not None:
        for i in range(len(ps)):
            ps[i] = best[1][i]
    return ps

# raw feature columns (tinynet.featurize order): 0-5 onehot, 6 layer,
# 7 logN, 8 timp_mean, 9 timp_max, 10 frag, 11 econc, 12-15 w-stats
SCALE_COLS = [7, 8, 9]


def spearman(a, b):
    ra = np.argsort(np.argsort(np.asarray(a, dtype=float))).astype(float)
    rb = np.argsort(np.argsort(np.asarray(b, dtype=float))).astype(float)
    ra -= ra.mean()
    rb -= rb.mean()
    d = np.sqrt((ra ** 2).sum() * (rb ** 2).sum())
    return float((ra * rb).sum() / d) if d > 0 else 0.0


def _unit_tag(names):
    # same rule as the allocator lookup (gnom.group_tag)
    # and predict_net: tissue@layer per blk tensor, "global" otherwise
    return "+".join(n.replace(".weight", "").split(".")[-1] + "@" +
                    n.split(".")[1] for n in names
                    if n.startswith("blk.")) or "global"


def load_pool_multi(path, metrics, force_metric=True, strict_map=True,
                    quiet=False, w_center=True, namefeats="off"):
    """Null-safe pooled loader for 1..k KLD-family targets.

    Rows are keyed on the PRIMARY metric (metrics[0]): a unit with a null
    primary is a censored observation — counted, skipped, never imputed.
    Other columns are NaN where the file does not carry them (old files
    predate the 5-column contract) or where the value is null.

    Returns (X, Y[n x k], models, gtags, stats). X columns: raw features
    (16) + base_bpw + drop_bpw [+ caller-appended extras].
    With w_center (default), the 4 weight-stat columns are centered on
    each model's own median, so their raw scale cannot leak across
    models — the exam is scale-invariant, the columns are not.
    """
    specs = json.load(open(path))
    Xs, Ys, models, gtags = [], [], [], []
    stats = {"units": 0, "mapped": 0, "tensors": 0, "missing": 0,
             "censored": 0, "files": 0, "n_feat_raw": 0,
             "missing_units": [], "files_detail": []}
    for sp in specs:
        primary = metrics[0] if force_metric else sp.get("metric", metrics[0])
        keys = [primary] + [m for m in metrics[1:]]
        z = np.load(sp["features"])
        X0 = np.asarray(z["X"], dtype=np.float64)
        stats["n_feat_raw"] = X0.shape[1]
        names = [str(t) for t in z["names"]]
        tmu = float(z["timp_mean"][0]) if "timp_mean" in z else 0.0
        tsd = float(z["timp_std"][0]) if "timp_std" in z else 1.0
        lmed = float(z["logN_med"][0]) if "logN_med" in z else 0.0
        row = {n: i for i, n in enumerate(names)}
        wmed = (np.median(X0[:, 12:16], axis=0)
                if w_center and X0.shape[1] >= 16 else None)
        base, rows, cens = None, [], 0
        with open(sp["labels"]) as f:
            for line in f:
                d = json.loads(line)
                if d["unit"] == "BASELINE":
                    base = d.get("ppl")
                    continue
                v0 = d.get(keys[0])
                if v0 is None:
                    # TERMINAL/censored (null != 0): visible in the file,
                    # never imputed into training. HOLEs are not in this
                    # file at all (*.failures.jsonl).
                    cens += 1
                    continue
                rows.append((d["unit"], [t for t in d.get("tensors", [])],
                             [d.get(k) for k in keys]))
        # DEAD PATH (archival pools only): PPL damage is relative to the
        # file's BASELINE ppl. KLD columns are already deltas (kf = 1).
        kf = 1.0 if keys[0] == "damage_kld" else (1.0 / base if base else 1.0)
        n_ok = t_ok = t_bad = 0
        for ui, (unit, tensors, vals) in enumerate(rows):
            have = [t for t in tensors if t in row]
            missing = [t for t in tensors if t not in row]
            stats["units"] += 1
            stats["tensors"] += len(tensors)
            stats["missing"] += len(missing)
            if missing:
                stats["missing_units"].append((sp["labels"], unit, missing))
                if not quiet:
                    print(f"  MAPPING MISS: {unit} -> {missing[:3]}"
                          f"{'...' if len(missing) > 3 else ''}", flush=True)
            if not have:
                continue
            idx = [row[t] for t in have]
            A = X0[idx]
            if wmed is not None:
                A = A.copy()
                A[:, 12:16] -= wmed  # weight stats vs model's own median
            g = A.mean(axis=0)
            # scale-invariant: timp z-scored in-model, logN vs model median
            g[8] = (A[:, 8].mean() - tmu) / (tsd + 1e-12)
            g[9] = (A[:, 9].max() - tmu) / (tsd + 1e-12)
            g[7] = A[:, 7].mean() - lmed
            if namefeats == "off":
                extra = []
            else:
                import hashlib as _hl
                if namefeats == "hash":
                    key = _unit_tag(tensors)
                elif namefeats == "layerabs":
                    ls = [n.split(".")[1] for n in tensors
                          if n.startswith("blk.") and
                          n.split(".")[1].isdigit()]
                    key = f"layer@{min(map(int, ls))}" if ls else "global"
                else:
                    key = f"anon@{ui}"
                b = _hl.md5(key.encode()).digest()[0] % 16
                extra = [1.0 if i == b else 0.0 for i in range(16)]
            Xs.append(np.concatenate([g, extra,
                                      [sp["base_bpw"], sp["drop_bpw"]]]))
            y = [vals[0] * kf]
            for v in vals[1:]:
                y.append(np.nan if v is None else v)
            Ys.append(y)
            models.append(sp["model"])
            gtags.append(f'{sp["model"]}:{keys[0]}:{unit}')
            n_ok += 1
            t_ok += len(have)
            t_bad += len(missing)
        stats["mapped"] += n_ok
        stats["censored"] += cens
        stats["files"] += 1
        stats["files_detail"].append(
            (sp["labels"], sp["features"], n_ok, len(rows), cens, t_ok, t_bad))
    if not quiet:
        for lab, feat, n_ok2, n_rows, cens, t_ok2, t_bad2 in stats["files_detail"]:
            print(f"  {lab}: mapped {n_ok2}/{n_rows}"
                  f" (tensors {t_ok2}/{t_ok2 + t_bad2}, censored {cens})"
                  f" [features {os.path.basename(feat)}]", flush=True)
        print(f"mapping: {stats['mapped']}/{stats['units']} units, "
              f"{stats['tensors'] - stats['missing']}/{stats['tensors']} tensors, "
              f"censored={stats['censored']}", flush=True)
    if strict_map and stats["missing"]:
        print(f"MAPPING FAIL: {len(stats['missing_units'])} units carry "
              f"tensors absent from their feature files — refusing to train "
              f"(--allow-partial-map to override)", flush=True)
        sys.exit(1)
    if stats["mapped"] < stats["units"] and strict_map:
        print(f"MAPPING FAIL: {stats['units'] - stats['mapped']} units with "
              f"values mapped to ZERO features — refusing to train",
              flush=True)
        sys.exit(1)
    return (np.array(Xs), np.array(Ys), np.array(models), gtags, stats)


def load_pool(path, metric="damage_kld", force_metric=False, strict_map=True):
    """Single-target view over load_pool_multi (historic signature)."""
    X, Y, models, gtags, stats = load_pool_multi(
        path, [metric], force_metric=force_metric, strict_map=strict_map)
    return X, Y[:, 0], models, gtags


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pool", required=True)
    ap.add_argument("--out", default="gnom/models/v4.npz")
    ap.add_argument("--variant",
                    choices=["trunk", "modelbias", "moeshared", "rank", "all"],
                    default="all")
    ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--epochs", type=int, default=1500)
    ap.add_argument("--hidden", default="8,4",
                    help="hidden layer widths, comma-separated "
                         "(default '8,4'; try '16,8' for 2x capacity)")
    ap.add_argument("--metric", default="damage_kld",
                    help="label key; default damage_kld. 'damage' lists as "
                         "PPL — DEAD (archival pools only), see docstring.")
    ap.add_argument("--tail-feats", action="store_true",
                    help="append 4 explicit drop-depth x tissue interaction "
                         "columns (tail probe; see README)")
    ap.add_argument("--target-z", action="store_true",
                    help="z-score the target within each pool row (file): "
                         "equal footing for cross-corpus scales; the exam "
                         "(per-fold Spearman) is invariant to it")
    ap.add_argument("--allow-partial-map", action="store_true",
                    help="downgrade tensor-level mapping misses to warnings")
    ap.add_argument("--no-w-center", action="store_true",
                    help="keep raw (uncentered) weight-stat columns")
    ap.add_argument("--mask-timp", action="store_true",
                    help="zero the imatrix-derived columns (timp_mean/_max, "
                         "econc): statics + weight stats only, no lens")
    ap.add_argument("--namefeats", choices=["off", "hash", "anon",
                                                 "layerabs"],
                    default="off",
                    help="append a 16-bucket onehot of unit identity "
                         "(hash: md5(tag); anon: md5(unit index in file); "
                         "layerabs: md5(absolute layer number) — same "
                         "capacity everywhere; curiosity probe: "
                         "memorization vs transfer)")
    args = ap.parse_args()

    strict = not args.allow_partial_map
    X, Y, models, gtags, stats = load_pool_multi(
        args.pool, [args.metric], force_metric=True, strict_map=strict,
        w_center=not args.no_w_center, namefeats=args.namefeats)
    if args.namefeats != "off":
        print(f"namefeats ON ({args.namefeats}): +16 unit-identity columns",
              flush=True)
    y = Y[:, 0]  # single-target view: 1D, exactly the historic engine input
    if args.mask_timp:
        X[:, [8, 9, 11]] = 0.0  # before tail feats AND standardize/export
        print("mask-timp ON: imatrix columns zeroed (no-lens trunk)",
              flush=True)
    if args.tail_feats:
        # explicit tail interactions: drop depth x {body, tail, conc, attn}
        d = X[:, -2]
        attn = X[:, 0:3].sum(axis=1)
        extra = np.stack([d * X[:, 8], d * X[:, 9], d * X[:, 11],
                          d * attn], axis=1)
        X = np.concatenate([X, extra], axis=1)
        print("tail-feats ON: +4 columns (d*timp_mean, d*timp_max, d*econc, "
              "d*attn)", flush=True)
    y_raw = y.copy()  # exams always score against the raw target
    if args.target_z:
        for m in sorted(set(models)):
            msk = models == m
            yy = y[msk]
            y[msk] = (yy - yy.mean()) / (yy.std() + 1e-12)
        print("target-z ON: target z-scored within each pool row", flush=True)
    n_feat_raw = stats["n_feat_raw"] + (16 if args.namefeats != "off"
                                               else 0)
    print(f"pooled: n={len(y)}, models={sorted(set(models))}", flush=True)
    uniq_m = sorted(set(models))
    midx = np.array([uniq_m.index(m) for m in models])
    hidden = [int(h) for h in args.hidden.split(",") if h.strip()]
    DIMS = [X.shape[1]] + hidden + [1]
    print(f"arch: {'-'.join(map(str, DIMS))}", flush=True)

    def run_variant(variant, tr_idx, va_idx, seed):
        if variant == "rank":
            prs = build_pairs(y[tr_idx], midx[tr_idx])
            ps = init_params(DIMS, seed=seed)
            Xs, _, _ = standardize(X)
            train_rank(ps, Xs, prs, seed=seed)
            return forward(ps, Xs, "mish")[0][-1].ravel()
        if variant == "moeshared":
            exp = moe_train_shared(X[tr_idx][:, :n_feat_raw], y[tr_idx],
                                   seed=seed, epochs=args.epochs)
            return moe_predict_shared(exp, X[:, :n_feat_raw])
        if variant == "modelbias":
            # honest subset fit: trunk never sees va labels; unseen models
            # get bias 0 (pure trunk generalization).
            Xtr, ytr = X[tr_idx], y[tr_idx]
            mtr = midx[tr_idx]
            ug = sorted(set(mtr.tolist()))
            r = {g: i for i, g in enumerate(ug)}
            gs = np.array([r[g] for g in mtr])
            mp = fit_mixed(Xtr, ytr, gs, len(ug), DIMS, seed=seed,
                           epochs=args.epochs, log_every=args.epochs)
            Xs = (X - mp["xmu"]) / mp["xsd"]
            base = forward(mp["trunk"], Xs, "mish")[0][-1].ravel() \
                * mp["tsd"] + mp["tmu"]
            b = np.zeros(len(X))
            known = np.array([r[m] if m in r else -1 for m in midx])
            kk = known >= 0
            b[kk] = mp["bias"][known[kk]]
            return (base + b) * mp["ysd"] + mp["ymu"]
        ps = init_params(DIMS, seed=seed)
        Xs, _, _ = standardize(X)
        w = np.abs(y[tr_idx]) + 0.05 * np.abs(y[tr_idx]).max()
        train(ps, Xs[tr_idx], y[tr_idx], epochs=args.epochs,
              log_every=args.epochs, noise_std=0.05, sample_w=w,
              activation="mish", dropout=0.0, seed=seed)
        mu, sd = float(y.mean()), float(y.std() + 1e-12)
        return forward(ps, Xs, "mish")[0][-1].ravel() * sd + mu

    variants = (["trunk", "modelbias", "moeshared", "rank"]
                if args.variant == "all" else [args.variant])
    rng = np.random.default_rng(args.seed)
    # random split (group-disjoint)
    uniq_g = sorted(set(gtags))
    gperm = rng.permutation(len(uniq_g))
    gtr = set(gperm[:max(2, len(uniq_g) * 4 // 5)])
    gidx = np.array([uniq_g.index(t) for t in gtags])
    tr = np.array([i for i in range(len(X)) if gidx[i] in gtr])
    va = np.array([i for i in range(len(X)) if gidx[i] not in gtr])
    print(f"split: train {len(tr)} / heldout {len(va)}", flush=True)
    results = {}
    for v in variants:
        p = run_variant(v, tr, va, args.seed)
        if v == "rank":
            # decisions are scored by pair accuracy, not correlation:
            # coin is 0.50, bar is 0.60.
            acc_r = pair_acc(p, y_raw, midx, va)
            np_r = pair_count(y_raw, midx, va)
            acc_lomo, np_lomo = [], []
            if len(uniq_m) > 1:
                for m in uniq_m:
                    trm = np.where(models != m)[0]
                    vam = np.where(models == m)[0]
                    if len(vam) < 5 or len(trm) < 5:
                        continue
                    pm = run_variant(v, trm, vam, args.seed)
                    acc_lomo.append(pair_acc(pm, y_raw, midx, vam))
                    np_lomo.append(pair_count(y_raw, midx, vam))
            lomo = float(np.mean(acc_lomo)) if acc_lomo else 0.5
            mde_r = mde_pair(np_r)
            mde_l = mde_pair(np.mean(np_lomo)) if np_lomo else float("inf")
            print(f"{v}: random-acc={acc_r:.3f} (MDE={mde_r:.3f}, "
                  f"n={np_r}) LOMO-acc={lomo:.3f} (MDE~{mde_l:.3f}) "
                  f"(per-model {[f'{s:.2f}' for s in acc_lomo]})", flush=True)
            results[v] = {"random": acc_r, "lomo": lomo}
            continue
        sp_r = spearman(p[va], y_raw[va])
        # LOMO needs >=2 models; single-metric pools report random only.
        sp_lomo, fold_n, timp_lomo = [], [], []
        if len(uniq_m) > 1:
            for m in uniq_m:
                trm = np.where(models != m)[0]
                vam = np.where(models == m)[0]
                if len(vam) < 5 or len(trm) < 5:
                    continue
                pm = run_variant(v, trm, vam, args.seed)
                sp_lomo.append(spearman(pm[vam], y_raw[vam]))
                fold_n.append(len(vam))
                timp_lomo.append(spearman(X[vam, 8], y_raw[vam]))
        lomo = float(np.mean(sp_lomo)) if sp_lomo else 0.0
        base = spearman(X[va, 8], y_raw[va])
        tb_lomo = float(np.mean(timp_lomo)) if timp_lomo else None
        mde_r = mde_rho(len(va))
        mde_l = mde_lomo(fold_n)
        tb_str = (f"timp_base={base:.3f} timp_LOMO="
                  f"{tb_lomo:.3f}" if tb_lomo is not None
                  else f"timp_base={base:.3f} timp_LOMO=n/a")
        print(f"{v}: random={sp_r:.3f} (MDE={mde_r:.3f}, n={len(va)}) "
              f"LOMO={lomo:.3f} (MDE~{mde_l:.3f}, folds={fold_n}) "
              f"{tb_str}", flush=True)
        print(f"  per-model {[f'{s:.2f}' for s in sp_lomo]}"
              + (f" timp {[f'{s:.2f}' for s in timp_lomo]}"
                 if timp_lomo else ""), flush=True)
        results[v] = {"random": sp_r, "lomo": lomo}
    best_c = max([v for v in results if v != "rank"],
                 key=lambda v: results[v]["lomo"], default=None)
    if best_c:
        print(f"BEST-corr: {best_c} LOMO={results[best_c]['lomo']:.3f} "
              f"(bar 0.45: {'PASS' if results[best_c]['lomo'] >= 0.45 else 'FAIL'})",
              flush=True)
    if "rank" in results:
        print(f"BEST-rank: rank LOMO-acc={results['rank']['lomo']:.3f} "
              f"(bar 0.60, coin 0.50: "
              f"{'PASS' if results['rank']['lomo'] >= 0.60 else 'FAIL'})",
              flush=True)
    # Export: trunk trained on FULL data (moeshared/modelbias export TODO).
    # Whole gnom: ~220 floats ≈ 2KB in one .npz (precompute per model into
    # netpred.json; no live inference needed in the queue).
    # predict_net rebuilds hash-identity columns from unit tags, but
    # anon indices depend on labels-file order: anon trunks are
    # metrics-only, skipped here rather than shipping an unloadable model.
    if args.namefeats == "anon":
        print("anon trunk: export skipped (predict cannot rebuild "
              "file-order indices)", flush=True)
        return
    ps = init_params(DIMS, seed=args.seed)
    Xs, xmu, xsd = standardize(X)
    ymu, ysd = float(y.mean()), float(y.std() + 1e-12)
    w = np.abs(y) + 0.05 * np.abs(y).max()
    train(ps, Xs, (y - ymu) / ysd, epochs=args.epochs,
          log_every=args.epochs, noise_std=0.05, sample_w=w,
          activation="mish", dropout=0.0, seed=args.seed)
    _os = os
    _os.makedirs(_os.path.dirname(_os.path.abspath(args.out)) or ".",
                 exist_ok=True)
    np.savez(args.out, xmu=xmu, xsd=xsd, ymu=np.array(ymu), ysd=np.array(ysd),
             dims=np.array(DIMS), metric=np.array(args.metric),
             **{f"p{i}{k}": v for i, p in enumerate(ps) for k, v in p.items()})
    print(f"saved trunk {args.out}", flush=True)


if __name__ == "__main__":
    main()