juiceb0xc0de commited on
Commit
5cebbaf
·
1 Parent(s): 169d199

Add bucket metrics and contrast delta analysis

Browse files
README.md CHANGED
@@ -32,11 +32,16 @@ prompts/ # your JSONL corpora
32
 
33
  1. Activation census extraction (`mlp`, `gate`, `up` by default)
34
  2. Finalize `.npz` chunks
35
- 3. Per-layer analysis: taxonomy, heatmap, F-stat separation, co-activation, code cross-reference
36
  4. OV-circuit spectral analysis: SVD over `W_V @ W_O` per head
37
  5. Compliance-behaviour axis extraction (positive vs negative corpus)
38
  6. Atlas build + SQLite index
39
 
 
 
 
 
 
40
  ## Dev-mode setup
41
 
42
  Inside the HF Space terminal:
 
32
 
33
  1. Activation census extraction (`mlp`, `gate`, `up` by default)
34
  2. Finalize `.npz` chunks
35
+ 3. Per-layer analysis: taxonomy, heatmap, F-stat separation, bucket quality, contrast deltas, co-activation, code cross-reference
36
  4. OV-circuit spectral analysis: SVD over `W_V @ W_O` per head
37
  5. Compliance-behaviour axis extraction (positive vs negative corpus)
38
  6. Atlas build + SQLite index
39
 
40
+ Per-component analysis now writes:
41
+
42
+ - `l<N>_<component>_bucket_metrics.json`: dominant bucket, entropy, eta-squared, and bucket-quality score per feature.
43
+ - `l<N>_<component>_contrast_delta.json`: feature deltas for explicit `contrast_pair_id` pairs when the corpus provides exactly two rows per pair.
44
+
45
  ## Dev-mode setup
46
 
47
  Inside the HF Space terminal:
qwip_atlas/analyze_layers.py CHANGED
@@ -423,6 +423,183 @@ def compute_separation_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
423
  return fstat.astype(np.float32)
424
 
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  def compute_silhouette_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
427
  """
428
  Per-feature silhouette score (kept for backward comparison).
@@ -461,6 +638,44 @@ def print_top_separators(scores: np.ndarray, n: int = 20,
461
  print(f" {idx:>8} {scores[idx]:>10.4f} {cls:<30} {act_rate:>9.3f}")
462
 
463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  # ---------------------------------------------------------------------------
465
  # Phase 6: Cross-reference with capability damage
466
  # ---------------------------------------------------------------------------
@@ -655,11 +870,27 @@ def analyze_one(name: str, A: np.ndarray, records: list[dict],
655
  np.save(out / f"{prefix}_separation_scores.npy", scores)
656
  print_top_separators(scores, n=20, classifications=classifications, A=A)
657
 
658
- # Phase 6: Code cross-reference
659
- print(f"\n--- {name}: Phase 6: Code feature cross-reference ---")
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  code_analysis = analyze_code_neurons(A, buckets, scores, code_bucket=code_bucket)
661
  write_json(out / f"{prefix}_code_analysis.json", code_analysis)
662
 
 
 
 
663
  # Per-component summary stats
664
  from collections import Counter
665
  def _norm_cls(c):
@@ -672,6 +903,14 @@ def analyze_one(name: str, A: np.ndarray, records: list[dict],
672
  "taxonomy": dict(tax_counts),
673
  "top_sep_score": float(np.max(scores)),
674
  "mean_sep_score": float(np.mean(scores)),
 
 
 
 
 
 
 
 
675
  "n_active": int((A > ACTIVATION_THRESHOLD).any(axis=1).sum()),
676
  "mean": float(A.mean()),
677
  "std": float(A.std()),
@@ -719,6 +958,20 @@ def print_comparison(summaries: list[dict], layer: int):
719
  row(" best feature", lambda s: f"{s['top_sep_score']:.4f}")
720
  row(" mean (all feats)",lambda s: f"{s['mean_sep_score']:.4f}")
721
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
722
  print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
723
  print(" Code cross-reference (top 30 code-preferring):")
724
  row(" entangled", lambda s: s["code_entangled"])
@@ -733,6 +986,13 @@ def print_comparison(summaries: list[dict], layer: int):
733
  f"(n={best_specific['taxonomy'].get('specific', 0)})")
734
  print(f" Highest single-feature separation: {best_sep['name']} "
735
  f"(score={best_sep['top_sep_score']:.4f})")
 
 
 
 
 
 
 
736
 
737
 
738
  def main():
@@ -846,6 +1106,7 @@ def main():
846
  prefix = f"l{args.layer}_{s['name']}"
847
  for suffix in ["neuron_taxonomy.json", "census_heatmap.png",
848
  "coactivation_pairs.json", "separation_scores.npy",
 
849
  "code_analysis.json"]:
850
  p = out / f"{prefix}_{suffix}"
851
  if p.exists():
 
423
  return fstat.astype(np.float32)
424
 
425
 
426
+ def compute_feature_bucket_metrics(A: np.ndarray, buckets: list[str]) -> list[dict]:
427
+ """
428
+ Per-feature bucket quality metrics.
429
+
430
+ These make bucket behavior visible instead of hiding it behind a single
431
+ F-statistic:
432
+ - dominant_bucket: bucket with highest mean activation
433
+ - dominance_margin: top bucket mean minus runner-up
434
+ - bucket_entropy: normalized entropy over positive bucket means
435
+ - eta_squared: fraction of activation variance explained by buckets
436
+ - bucket_quality: eta_squared weighted by low entropy
437
+ """
438
+ bucket_arr = np.asarray(buckets)
439
+ unique = sorted(set(buckets))
440
+ n_features, n_prompts = A.shape
441
+ if n_prompts != len(buckets):
442
+ raise ValueError(f"A has {n_prompts} prompts, but buckets has {len(buckets)} entries")
443
+
444
+ overall_mean = A.mean(axis=1, keepdims=True)
445
+ bucket_means = np.zeros((n_features, len(unique)), dtype=np.float64)
446
+ bucket_counts = np.zeros(len(unique), dtype=np.int64)
447
+
448
+ for j, bucket in enumerate(unique):
449
+ mask = bucket_arr == bucket
450
+ bucket_counts[j] = int(mask.sum())
451
+ if bucket_counts[j]:
452
+ bucket_means[:, j] = A[:, mask].mean(axis=1)
453
+
454
+ between_var = np.zeros(n_features, dtype=np.float64)
455
+ for j in range(len(unique)):
456
+ if bucket_counts[j] < 1:
457
+ continue
458
+ delta = bucket_means[:, j] - overall_mean.squeeze(-1)
459
+ between_var += bucket_counts[j] * delta**2
460
+
461
+ centered = A - overall_mean
462
+ total_var = np.sum(centered**2, axis=1)
463
+ eta_squared = np.divide(
464
+ between_var,
465
+ total_var,
466
+ out=np.zeros_like(between_var),
467
+ where=total_var > 1e-10,
468
+ )
469
+
470
+ # Entropy over positive bucket means. If all bucket means are non-positive,
471
+ # fall back to absolute means so inhibitory/signed features still get shape.
472
+ positive = np.clip(bucket_means, 0, None)
473
+ fallback = np.abs(bucket_means)
474
+ weights = np.where(positive.sum(axis=1, keepdims=True) > 1e-10, positive, fallback)
475
+ weight_sums = weights.sum(axis=1, keepdims=True)
476
+ probs = np.divide(weights, weight_sums, out=np.zeros_like(weights), where=weight_sums > 1e-10)
477
+ log_probs = np.zeros_like(probs)
478
+ nz = probs > 0
479
+ log_probs[nz] = np.log(probs[nz])
480
+ raw_entropy = -(probs * log_probs).sum(axis=1)
481
+ norm = np.log(len(unique)) if len(unique) > 1 else 1.0
482
+ bucket_entropy = raw_entropy / norm if norm > 0 else raw_entropy
483
+
484
+ top_idx = np.argmax(bucket_means, axis=1)
485
+ order_desc = np.argsort(-bucket_means, axis=1)
486
+ second_idx = order_desc[:, 1] if len(unique) > 1 else top_idx
487
+
488
+ metrics = []
489
+ for i in range(n_features):
490
+ top = int(top_idx[i])
491
+ second = int(second_idx[i])
492
+ margin = float(bucket_means[i, top] - bucket_means[i, second]) if len(unique) > 1 else 0.0
493
+ entropy = float(bucket_entropy[i])
494
+ eta = float(eta_squared[i])
495
+ metrics.append({
496
+ "feature": i,
497
+ "dominant_bucket": unique[top],
498
+ "dominant_bucket_mean": float(bucket_means[i, top]),
499
+ "runner_up_bucket": unique[second],
500
+ "runner_up_bucket_mean": float(bucket_means[i, second]),
501
+ "dominance_margin": margin,
502
+ "bucket_entropy": entropy,
503
+ "eta_squared": eta,
504
+ "bucket_quality": float(eta * (1.0 - entropy)),
505
+ "bucket_means": {bucket: float(bucket_means[i, j]) for j, bucket in enumerate(unique)},
506
+ })
507
+ return metrics
508
+
509
+
510
+ def compute_contrast_delta_scores(A: np.ndarray, records: list[dict]) -> dict:
511
+ """
512
+ Rank features by consistent activation change across explicit contrast pairs.
513
+
514
+ Pairs are not inferred from prompt text. The corpus must provide
515
+ ``contrast_pair_id`` metadata; exactly two rows per pair are required.
516
+ Within each pair, rows are ordered by bucket name and the delta is:
517
+
518
+ activation(second bucket lexically) - activation(first bucket lexically)
519
+
520
+ The report includes the direction labels so downstream readers can see what
521
+ a positive delta means.
522
+ """
523
+ from collections import defaultdict
524
+
525
+ by_pair: dict[str, list[int]] = defaultdict(list)
526
+ for i, record in enumerate(records):
527
+ pair_id = record.get("contrast_pair_id")
528
+ if pair_id:
529
+ by_pair[str(pair_id)].append(i)
530
+
531
+ pair_rows = []
532
+ skipped = []
533
+ directions = []
534
+ for pair_id in sorted(by_pair):
535
+ idxs = by_pair[pair_id]
536
+ if len(idxs) != 2:
537
+ skipped.append(pair_id)
538
+ continue
539
+ ordered = sorted(idxs, key=lambda idx: (
540
+ str(records[idx].get("bucket", "")),
541
+ str(records[idx].get("id", idx)),
542
+ ))
543
+ left, right = ordered
544
+ left_bucket = str(records[left].get("bucket", ""))
545
+ right_bucket = str(records[right].get("bucket", ""))
546
+ directions.append((left_bucket, right_bucket))
547
+ pair_rows.append({
548
+ "pair_id": pair_id,
549
+ "left_index": int(left),
550
+ "right_index": int(right),
551
+ "left_bucket": left_bucket,
552
+ "right_bucket": right_bucket,
553
+ "left_id": records[left].get("id"),
554
+ "right_id": records[right].get("id"),
555
+ })
556
+
557
+ if not pair_rows:
558
+ return {
559
+ "n_pairs": 0,
560
+ "direction": "lexical_bucket_order",
561
+ "direction_counts": {},
562
+ "skipped_pair_ids": skipped,
563
+ "pairs": [],
564
+ "features": [],
565
+ }
566
+
567
+ left_idx = np.array([p["left_index"] for p in pair_rows], dtype=np.int64)
568
+ right_idx = np.array([p["right_index"] for p in pair_rows], dtype=np.int64)
569
+ deltas = A[:, right_idx] - A[:, left_idx]
570
+ mean_delta = deltas.mean(axis=1)
571
+ abs_mean_delta = np.abs(mean_delta)
572
+ std_delta = deltas.std(axis=1)
573
+ positive_fraction = (deltas > 0).mean(axis=1)
574
+ consistency = np.maximum(positive_fraction, 1.0 - positive_fraction)
575
+ effect_score = abs_mean_delta * consistency
576
+
577
+ from collections import Counter
578
+ direction_counts = Counter(f"{left}->{right}" for left, right in directions)
579
+
580
+ order = np.argsort(effect_score)[::-1]
581
+ features = []
582
+ for idx in order:
583
+ features.append({
584
+ "feature": int(idx),
585
+ "mean_delta": float(mean_delta[idx]),
586
+ "abs_mean_delta": float(abs_mean_delta[idx]),
587
+ "std_delta": float(std_delta[idx]),
588
+ "positive_fraction": float(positive_fraction[idx]),
589
+ "consistency": float(consistency[idx]),
590
+ "effect_score": float(effect_score[idx]),
591
+ })
592
+
593
+ return {
594
+ "n_pairs": len(pair_rows),
595
+ "direction": "lexical_bucket_order",
596
+ "direction_counts": dict(direction_counts),
597
+ "skipped_pair_ids": skipped,
598
+ "pairs": pair_rows,
599
+ "features": features,
600
+ }
601
+
602
+
603
  def compute_silhouette_scores(A: np.ndarray, buckets: list[str]) -> np.ndarray:
604
  """
605
  Per-feature silhouette score (kept for backward comparison).
 
638
  print(f" {idx:>8} {scores[idx]:>10.4f} {cls:<30} {act_rate:>9.3f}")
639
 
640
 
641
+ def print_top_bucket_metrics(metrics: list[dict], n: int = 20) -> None:
642
+ top = sorted(metrics, key=lambda row: row["bucket_quality"], reverse=True)[:n]
643
+ print(f"\nTop {n} bucket-clean features (eta^2 * low entropy):")
644
+ print(
645
+ f" {'feature':>8} {'quality':>10} {'eta^2':>8} "
646
+ f"{'entropy':>8} {'dominant':<24} {'margin':>9}"
647
+ )
648
+ for row in top:
649
+ print(
650
+ f" {row['feature']:>8} {row['bucket_quality']:>10.4f} "
651
+ f"{row['eta_squared']:>8.4f} {row['bucket_entropy']:>8.4f} "
652
+ f"{row['dominant_bucket']:<24} {row['dominance_margin']:>9.4f}"
653
+ )
654
+
655
+
656
+ def print_contrast_delta_summary(report: dict, n: int = 20) -> None:
657
+ print(f"\nContrast pairs: {report['n_pairs']} usable")
658
+ if report["skipped_pair_ids"]:
659
+ print(f" skipped pair ids (not exactly 2 rows): {report['skipped_pair_ids'][:10]}")
660
+ if report["direction_counts"]:
661
+ print(" positive delta direction counts:")
662
+ for direction, count in sorted(report["direction_counts"].items()):
663
+ print(f" {direction}: {count}")
664
+ if not report["features"]:
665
+ return
666
+ print(f"\nTop {n} contrast-shifting features:")
667
+ print(
668
+ f" {'feature':>8} {'effect':>10} {'mean_delta':>11} "
669
+ f"{'consistency':>11} {'pos_frac':>9}"
670
+ )
671
+ for row in report["features"][:n]:
672
+ print(
673
+ f" {row['feature']:>8} {row['effect_score']:>10.4f} "
674
+ f"{row['mean_delta']:>11.4f} {row['consistency']:>11.4f} "
675
+ f"{row['positive_fraction']:>9.4f}"
676
+ )
677
+
678
+
679
  # ---------------------------------------------------------------------------
680
  # Phase 6: Cross-reference with capability damage
681
  # ---------------------------------------------------------------------------
 
870
  np.save(out / f"{prefix}_separation_scores.npy", scores)
871
  print_top_separators(scores, n=20, classifications=classifications, A=A)
872
 
873
+ # Phase 6: Bucket quality metrics
874
+ print(f"\n--- {name}: Phase 6: Bucket quality metrics ---")
875
+ bucket_metrics = compute_feature_bucket_metrics(A, buckets)
876
+ write_json(out / f"{prefix}_bucket_metrics.json", bucket_metrics)
877
+ print_top_bucket_metrics(bucket_metrics)
878
+
879
+ # Phase 7: Contrast pair deltas
880
+ print(f"\n--- {name}: Phase 7: Contrast pair deltas ---")
881
+ contrast_report = compute_contrast_delta_scores(A, records)
882
+ if contrast_report["n_pairs"]:
883
+ write_json(out / f"{prefix}_contrast_delta.json", contrast_report)
884
+ print_contrast_delta_summary(contrast_report)
885
+
886
+ # Phase 8: Code cross-reference
887
+ print(f"\n--- {name}: Phase 8: Code feature cross-reference ---")
888
  code_analysis = analyze_code_neurons(A, buckets, scores, code_bucket=code_bucket)
889
  write_json(out / f"{prefix}_code_analysis.json", code_analysis)
890
 
891
+ best_bucket = max(bucket_metrics, key=lambda row: row["bucket_quality"]) if bucket_metrics else {}
892
+ best_contrast = contrast_report["features"][0] if contrast_report["features"] else {}
893
+
894
  # Per-component summary stats
895
  from collections import Counter
896
  def _norm_cls(c):
 
903
  "taxonomy": dict(tax_counts),
904
  "top_sep_score": float(np.max(scores)),
905
  "mean_sep_score": float(np.mean(scores)),
906
+ "top_bucket_quality": float(best_bucket.get("bucket_quality", 0.0)),
907
+ "top_bucket_feature": int(best_bucket.get("feature", 0)) if best_bucket else None,
908
+ "top_bucket": best_bucket.get("dominant_bucket"),
909
+ "bucket_entropy_at_top": float(best_bucket.get("bucket_entropy", 0.0)) if best_bucket else None,
910
+ "eta_squared_at_top": float(best_bucket.get("eta_squared", 0.0)) if best_bucket else None,
911
+ "n_contrast_pairs": int(contrast_report["n_pairs"]),
912
+ "top_contrast_feature": int(best_contrast.get("feature", 0)) if best_contrast else None,
913
+ "top_contrast_effect": float(best_contrast.get("effect_score", 0.0)) if best_contrast else None,
914
  "n_active": int((A > ACTIVATION_THRESHOLD).any(axis=1).sum()),
915
  "mean": float(A.mean()),
916
  "std": float(A.std()),
 
958
  row(" best feature", lambda s: f"{s['top_sep_score']:.4f}")
959
  row(" mean (all feats)",lambda s: f"{s['mean_sep_score']:.4f}")
960
 
961
+ print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
962
+ print(" Bucket quality (eta^2 * low entropy):")
963
+ row(" best quality", lambda s: f"{s.get('top_bucket_quality', 0.0):.4f}")
964
+ row(" best bucket", lambda s: str(s.get("top_bucket") or "-")[:10])
965
+ row(" best feature", lambda s: s.get("top_bucket_feature") if s.get("top_bucket_feature") is not None else "-")
966
+ row(" eta^2 at top", lambda s: f"{s.get('eta_squared_at_top'):.4f}" if s.get("eta_squared_at_top") is not None else "-")
967
+ row(" entropy at top", lambda s: f"{s.get('bucket_entropy_at_top'):.4f}" if s.get("bucket_entropy_at_top") is not None else "-")
968
+
969
+ print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
970
+ print(" Contrast pairs (explicit contrast_pair_id only):")
971
+ row(" usable pairs", lambda s: s.get("n_contrast_pairs", 0))
972
+ row(" best feature", lambda s: s.get("top_contrast_feature") if s.get("top_contrast_feature") is not None else "-")
973
+ row(" best effect", lambda s: f"{s.get('top_contrast_effect'):.4f}" if s.get("top_contrast_effect") is not None else "-")
974
+
975
  print(f" {'-'*28}" + "".join("-" * col_width for _ in summaries))
976
  print(" Code cross-reference (top 30 code-preferring):")
977
  row(" entangled", lambda s: s["code_entangled"])
 
986
  f"(n={best_specific['taxonomy'].get('specific', 0)})")
987
  print(f" Highest single-feature separation: {best_sep['name']} "
988
  f"(score={best_sep['top_sep_score']:.4f})")
989
+ best_bucket = max(summaries, key=lambda s: s.get("top_bucket_quality", 0.0))
990
+ print(f" Cleanest bucket signal: {best_bucket['name']} "
991
+ f"(bucket={best_bucket.get('top_bucket')}, quality={best_bucket.get('top_bucket_quality', 0.0):.4f})")
992
+ if any(s.get("n_contrast_pairs", 0) for s in summaries):
993
+ best_contrast = max(summaries, key=lambda s: s.get("top_contrast_effect", 0.0) or 0.0)
994
+ print(f" Strongest contrast delta: {best_contrast['name']} "
995
+ f"(effect={best_contrast.get('top_contrast_effect', 0.0):.4f})")
996
 
997
 
998
  def main():
 
1106
  prefix = f"l{args.layer}_{s['name']}"
1107
  for suffix in ["neuron_taxonomy.json", "census_heatmap.png",
1108
  "coactivation_pairs.json", "separation_scores.npy",
1109
+ "bucket_metrics.json", "contrast_delta.json",
1110
  "code_analysis.json"]:
1111
  p = out / f"{prefix}_{suffix}"
1112
  if p.exists():
qwip_atlas/build_atlas.py CHANGED
@@ -213,6 +213,8 @@ def cmd_merge_layer(args):
213
  "taxonomy.json": analysis_dir / f"{prefix}neuron_taxonomy.json",
214
  "separation.npy": analysis_dir / f"{prefix}separation_scores.npy",
215
  "coactivation.json": analysis_dir / f"{prefix}coactivation_pairs.json",
 
 
216
  "code_analysis.json": analysis_dir / f"{prefix}code_analysis.json",
217
  "census_heatmap.png": analysis_dir / f"{prefix}census_heatmap.png",
218
  }
@@ -245,6 +247,29 @@ def cmd_merge_layer(args):
245
  "code_selective":code.get("selective_count", 0),
246
  "files": copied,
247
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  write_json(cdir / "summary.json", summary)
249
 
250
  # 3. Per-head ingest
 
213
  "taxonomy.json": analysis_dir / f"{prefix}neuron_taxonomy.json",
214
  "separation.npy": analysis_dir / f"{prefix}separation_scores.npy",
215
  "coactivation.json": analysis_dir / f"{prefix}coactivation_pairs.json",
216
+ "bucket_metrics.json":analysis_dir / f"{prefix}bucket_metrics.json",
217
+ "contrast_delta.json":analysis_dir / f"{prefix}contrast_delta.json",
218
  "code_analysis.json": analysis_dir / f"{prefix}code_analysis.json",
219
  "census_heatmap.png": analysis_dir / f"{prefix}census_heatmap.png",
220
  }
 
247
  "code_selective":code.get("selective_count", 0),
248
  "files": copied,
249
  }
250
+ bucket_metrics_path = cdir / "bucket_metrics.json"
251
+ if bucket_metrics_path.exists():
252
+ bucket_metrics = read_json(bucket_metrics_path)
253
+ if bucket_metrics:
254
+ top_bucket = max(bucket_metrics, key=lambda row: row.get("bucket_quality", 0.0))
255
+ summary.update({
256
+ "bucket_quality_top": top_bucket.get("bucket_quality"),
257
+ "bucket_quality_top_idx": top_bucket.get("feature"),
258
+ "bucket_quality_top_bucket": top_bucket.get("dominant_bucket"),
259
+ "bucket_quality_top_entropy": top_bucket.get("bucket_entropy"),
260
+ "bucket_quality_top_eta_squared": top_bucket.get("eta_squared"),
261
+ })
262
+ contrast_path = cdir / "contrast_delta.json"
263
+ if contrast_path.exists():
264
+ contrast = read_json(contrast_path)
265
+ features = contrast.get("features") or []
266
+ top_contrast = features[0] if features else {}
267
+ summary.update({
268
+ "contrast_pairs": contrast.get("n_pairs", 0),
269
+ "contrast_top_idx": top_contrast.get("feature"),
270
+ "contrast_top_effect": top_contrast.get("effect_score"),
271
+ "contrast_top_mean_delta": top_contrast.get("mean_delta"),
272
+ })
273
  write_json(cdir / "summary.json", summary)
274
 
275
  # 3. Per-head ingest
tests/test_analyze_layers_metrics.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from qwip_atlas.analyze_layers import (
6
+ compute_contrast_delta_scores,
7
+ compute_feature_bucket_metrics,
8
+ )
9
+
10
+
11
+ def test_feature_bucket_metrics_identifies_clean_dominant_bucket() -> None:
12
+ A = np.array(
13
+ [
14
+ [5.0, 4.0, 0.0, 0.0],
15
+ [1.0, 1.0, 1.0, 1.0],
16
+ [0.0, 0.0, 3.0, 4.0],
17
+ ],
18
+ dtype=np.float32,
19
+ )
20
+ buckets = ["auth", "auth", "corp", "corp"]
21
+
22
+ metrics = compute_feature_bucket_metrics(A, buckets)
23
+
24
+ assert metrics[0]["dominant_bucket"] == "auth"
25
+ assert metrics[0]["dominance_margin"] > 3.0
26
+ assert metrics[0]["bucket_entropy"] < 0.1
27
+ assert metrics[0]["eta_squared"] > 0.9
28
+
29
+ assert metrics[1]["dominant_bucket"] == "auth"
30
+ assert metrics[1]["dominance_margin"] == 0.0
31
+ assert metrics[1]["bucket_entropy"] > 0.9
32
+ assert metrics[1]["eta_squared"] == 0.0
33
+
34
+ assert metrics[2]["dominant_bucket"] == "corp"
35
+ assert metrics[2]["dominance_margin"] > 3.0
36
+ assert metrics[2]["bucket_entropy"] < 0.1
37
+ assert metrics[2]["eta_squared"] > 0.9
38
+
39
+
40
+ def test_contrast_delta_scores_use_exact_two_row_pairs() -> None:
41
+ A = np.array(
42
+ [
43
+ [1.0, 2.0, 2.0, 3.0, 100.0],
44
+ [5.0, 2.0, 0.0, 1.0, 100.0],
45
+ [2.0, 0.0, 5.0, 2.0, 100.0],
46
+ ],
47
+ dtype=np.float32,
48
+ )
49
+ records = [
50
+ {"id": "a1", "contrast_pair_id": "p1", "bucket": "auth"},
51
+ {"id": "c1", "contrast_pair_id": "p1", "bucket": "corp"},
52
+ {"id": "a2", "contrast_pair_id": "p2", "bucket": "auth"},
53
+ {"id": "c2", "contrast_pair_id": "p2", "bucket": "corp"},
54
+ {"id": "orphan", "contrast_pair_id": "bad", "bucket": "other"},
55
+ ]
56
+
57
+ report = compute_contrast_delta_scores(A, records)
58
+
59
+ assert report["n_pairs"] == 2
60
+ assert report["skipped_pair_ids"] == ["bad"]
61
+ assert report["direction"] == "lexical_bucket_order"
62
+
63
+ by_feature = {row["feature"]: row for row in report["features"]}
64
+ assert by_feature[0]["mean_delta"] == 1.0
65
+ assert by_feature[0]["positive_fraction"] == 1.0
66
+ assert by_feature[1]["mean_delta"] == -1.0
67
+ assert by_feature[1]["positive_fraction"] == 0.5
68
+ assert by_feature[2]["mean_delta"] == -2.5
69
+ assert by_feature[2]["positive_fraction"] == 0.0