File size: 34,847 Bytes
46e1263
 
 
 
 
 
 
 
 
 
c35f284
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
 
 
 
 
 
 
 
 
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f46c540
c35f284
 
 
f46c540
c35f284
f46c540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
 
 
f46c540
c35f284
 
 
 
f46c540
 
c35f284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f46c540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e1263
 
c35f284
46e1263
 
 
 
c35f284
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
 
 
 
 
46e1263
c35f284
46e1263
 
 
 
 
 
c35f284
 
413fff1
c35f284
 
 
 
 
413fff1
c35f284
 
 
 
46e1263
 
 
 
c35f284
 
f46c540
c35f284
 
 
46e1263
 
 
413fff1
46e1263
 
 
 
c35f284
413fff1
 
c35f284
 
 
f46c540
c35f284
 
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
c35f284
46e1263
c35f284
 
f46c540
c35f284
 
46e1263
c35f284
 
46e1263
 
 
 
 
 
 
 
f46c540
 
46e1263
c35f284
 
f46c540
 
c35f284
 
 
46e1263
 
 
28a2e34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f46c540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e1263
 
 
 
c35f284
 
f46c540
28a2e34
46e1263
 
 
 
 
c35f284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f46c540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46e1263
 
 
 
 
c35f284
 
28a2e34
 
 
 
 
413fff1
 
c35f284
 
 
 
 
46e1263
 
 
 
c35f284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413fff1
 
 
c35f284
f46c540
c35f284
 
 
 
 
 
46e1263
 
28a2e34
 
c35f284
 
 
 
 
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28a2e34
46e1263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
#!/usr/bin/env python3
"""Character superclass grouping diagnostic for existing labels only.

Diagnostics only: no new sourcing, no label expansion, no dataset assembly,
no training, and no upload.
"""
from __future__ import annotations

from collections import Counter, defaultdict
import json
import math
from pathlib import Path
import sys
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"
OUT_JSON = ARTIFACTS / "character_grouping_diagnostic_v8.json"
OUT_MD = ARTIFACTS / "character_grouping_diagnostic_v8.md"

sys.path.insert(0, str(ROOT / "scripts"))
import build_odour_taxonomy_census_v8 as census  # noqa: E402


LABEL_SNAPSHOTS = {
    "v8_2": DATA / "odour_character_labels_v8_2.jsonl",
    "v8_2_clause_guard": DATA / "odour_character_labels_v8_2_clause_guard.jsonl",
    "v8_2_clause_guard_dominance": DATA / "odour_character_labels_v8_2_clause_guard_dominance.jsonl",
}


GROUPINGS: dict[str, dict[str, Any]] = {
    "six_superclass": {
        "justification": (
            "Collapses the 26-letter taxonomy into perfumery families: fresh/top-note, floral, "
            "edible/gourmand, balsamic/woody/fixative, warm/spicy/animalic/smoky, and solvent carriers."
        ),
        "mapping": {
            "A": "fresh_citrus_green",
            "B": "fresh_citrus_green",
            "C": "fresh_citrus_green",
            "F": "fresh_citrus_green",
            "G": "fresh_citrus_green",
            "H": "fresh_citrus_green",
            "K": "fresh_citrus_green",
            "L": "floral",
            "I": "floral",
            "J": "floral",
            "M": "floral",
            "N": "floral",
            "O": "floral",
            "R": "floral",
            "D": "edible_gourmand",
            "E": "edible_gourmand",
            "P": "edible_gourmand",
            "V": "edible_gourmand",
            "Q": "balsamic_woody_mossy",
            "W": "balsamic_woody_mossy",
            "X": "balsamic_woody_mossy",
            "Y": "balsamic_woody_mossy",
            "S": "animalic_spicy_smoky",
            "T": "animalic_spicy_smoky",
            "U": "animalic_spicy_smoky",
            "Z": "carrier",
        },
    },
    "eight_superclass": {
        "justification": (
            "Keeps fruit/citrus/green fresh notes separate from aromatic cool herbs, splits gourmand "
            "from phenolic/savory edible, and keeps musk/animal/smoke separate from spice."
        ),
        "mapping": {
            "A": "fresh_citrus_green_fruit",
            "C": "fresh_citrus_green_fruit",
            "F": "fresh_citrus_green_fruit",
            "G": "fresh_citrus_green_fruit",
            "B": "aromatic_cool_herbal",
            "H": "aromatic_cool_herbal",
            "K": "aromatic_cool_herbal",
            "I": "floral",
            "J": "floral",
            "L": "floral",
            "M": "floral",
            "N": "floral",
            "O": "floral",
            "R": "floral",
            "D": "gourmand_sweet",
            "V": "gourmand_sweet",
            "E": "savory_phenolic",
            "P": "savory_phenolic",
            "Q": "balsamic_woody_mossy",
            "W": "balsamic_woody_mossy",
            "Y": "balsamic_woody_mossy",
            "S": "spice",
            "T": "animalic_musk_smoke",
            "U": "animalic_musk_smoke",
            "X": "animalic_musk_smoke",
            "Z": "carrier",
        },
    },
}


def load_jsonl(path: Path) -> list[dict[str, Any]]:
    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]


def label_codes(label: dict[str, Any]) -> set[str]:
    codes = label.get("class_codes")
    if isinstance(codes, list):
        return {str(code) for code in codes}
    code = label.get("class_code")
    return {str(code)} if code else set()


def label_confidence(label: dict[str, Any]) -> float:
    """Return available label confidence, defaulting to 1 for source-derived labels."""
    for key in ("confidence", "label_confidence", "score"):
        value = label.get(key)
        if isinstance(value, int | float):
            return float(value)
    return 1.0


def transform_labels(labels: dict[str, dict[str, Any]], mapping: dict[str, str]) -> dict[str, dict[str, Any]]:
    out = {}
    for cas, label in labels.items():
        super_codes = sorted({mapping[code] for code in label_codes(label) if code in mapping and mapping[code] != "carrier"})
        if not super_codes:
            continue
        out[cas] = {
            **label,
            "class_code": super_codes[0],
            "class_codes": super_codes,
            "original_class_codes": sorted(label_codes(label)),
            "grouping": "superclass",
        }
    return out


def formula_character_sets(records: list[dict[str, Any]], labels: dict[str, dict[str, Any]]) -> dict[str, list[set[str]]]:
    by_genre: dict[str, list[set[str]]] = defaultdict(list)
    for record in records:
        if record.get("is_control"):
            continue
        genre = record.get("genre") or record.get("metadata", {}).get("generation_strategy") or "unknown"
        chars = set()
        for comp in census.formula_components(record):
            label = labels.get(str(comp.get("cas")))
            if label:
                chars.update(code for code in label_codes(label) if code != "Z" and code != "carrier")
        if chars:
            by_genre[genre].append(chars)
    return by_genre


def formula_character_rows(records: list[dict[str, Any]], labels: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for record in records:
        if record.get("is_control"):
            continue
        genre = record.get("genre") or record.get("metadata", {}).get("generation_strategy") or "unknown"
        formula_id = record.get("formula_id") or record.get("metadata", {}).get("formula_id")
        classes: set[str] = set()
        weighted_classes: Counter[str] = Counter()
        material_classes: Counter[str] = Counter()
        labelled_materials = set()
        for comp in census.formula_components(record):
            cas = str(comp.get("cas"))
            label = labels.get(cas)
            if not label:
                continue
            codes = {code for code in label_codes(label) if code not in {"Z", "carrier"}}
            if not codes:
                continue
            labelled_materials.add(cas)
            confidence = label_confidence(label)
            for code in codes:
                classes.add(code)
                weighted_classes[code] += confidence
                material_classes[code] += 1
        if classes:
            rows.append({
                "genre": genre,
                "formula_id": formula_id,
                "classes": classes,
                "weighted_classes": weighted_classes,
                "material_classes": material_classes,
                "labelled_material_count": len(labelled_materials),
                "label_class_count": len(classes),
            })
    return rows


def label_density(records: list[dict[str, Any]], labels: dict[str, dict[str, Any]], axis_name: str) -> dict[str, Any]:
    rows = formula_character_rows(records, labels)
    by_genre: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        by_genre[row["genre"]].append(row)
    per_genre = {}
    for genre, genre_rows in sorted(by_genre.items()):
        per_genre[genre] = {
            "labelled_formulas": len(genre_rows),
            "avg_distinct_classes_per_labelled_formula": sum(r["label_class_count"] for r in genre_rows) / len(genre_rows),
            "avg_labelled_materials_per_labelled_formula": sum(r["labelled_material_count"] for r in genre_rows) / len(genre_rows),
            "formulas_with_ge_2_classes": sum(1 for r in genre_rows if r["label_class_count"] >= 2),
            "formulas_with_ge_3_classes": sum(1 for r in genre_rows if r["label_class_count"] >= 3),
        }
    return {
        "axis": axis_name,
        "labelled_formulas": len(rows),
        "avg_distinct_classes_per_labelled_formula": sum(r["label_class_count"] for r in rows) / len(rows) if rows else None,
        "avg_labelled_materials_per_labelled_formula": sum(r["labelled_material_count"] for r in rows) / len(rows) if rows else None,
        "per_genre": per_genre,
    }


def cosine_similarity(left: dict[str, float], right: dict[str, float], codes: list[str]) -> float | None:
    a = [left.get(code, 0.0) for code in codes]
    b = [right.get(code, 0.0) for code in codes]
    left_norm = math.sqrt(sum(v * v for v in a))
    right_norm = math.sqrt(sum(v * v for v in b))
    if not left_norm or not right_norm:
        return None
    return sum(x * y for x, y in zip(a, b)) / (left_norm * right_norm)


def js_divergence(left: dict[str, float], right: dict[str, float], codes: list[str]) -> float | None:
    left_total = sum(left.get(code, 0.0) for code in codes)
    right_total = sum(right.get(code, 0.0) for code in codes)
    if not left_total or not right_total:
        return None
    p = [left.get(code, 0.0) / left_total for code in codes]
    q = [right.get(code, 0.0) / right_total for code in codes]
    m = [(x + y) / 2 for x, y in zip(p, q)]

    def kl(a: list[float], b: list[float]) -> float:
        return sum(x * math.log2(x / y) for x, y in zip(a, b) if x and y)

    return 0.5 * kl(p, m) + 0.5 * kl(q, m)


def pairwise_vector_metrics(vectors: dict[str, Counter[str]], codes: list[str]) -> dict[str, Any]:
    pairwise = []
    genres = sorted(vectors)
    for i, left in enumerate(genres):
        for right in genres[i + 1:]:
            cosine = cosine_similarity(vectors[left], vectors[right], codes)
            jsd = js_divergence(vectors[left], vectors[right], codes)
            pairwise.append({
                "left": left,
                "right": right,
                "cosine_similarity": cosine,
                "cosine_distance": None if cosine is None else 1.0 - cosine,
                "js_divergence_bits": jsd,
            })
    cosines = [p["cosine_similarity"] for p in pairwise if p["cosine_similarity"] is not None]
    cosine_distances = [p["cosine_distance"] for p in pairwise if p["cosine_distance"] is not None]
    jsds = [p["js_divergence_bits"] for p in pairwise if p["js_divergence_bits"] is not None]
    cosine_distance_matrix = {
        genre: {
            other: 0.0 if genre == other else None
            for other in genres
        }
        for genre in genres
    }
    js_divergence_matrix = {
        genre: {
            other: 0.0 if genre == other else None
            for other in genres
        }
        for genre in genres
    }
    for row in pairwise:
        left = row["left"]
        right = row["right"]
        cosine_distance_matrix[left][right] = row["cosine_distance"]
        cosine_distance_matrix[right][left] = row["cosine_distance"]
        js_divergence_matrix[left][right] = row["js_divergence_bits"]
        js_divergence_matrix[right][left] = row["js_divergence_bits"]
    return {
        "pairwise": pairwise,
        "mean_pairwise_cosine_similarity": sum(cosines) / len(cosines) if cosines else None,
        "mean_pairwise_cosine_distance": sum(cosine_distances) / len(cosine_distances) if cosine_distances else None,
        "min_pairwise_cosine_similarity": min(cosines) if cosines else None,
        "max_pairwise_cosine_similarity": max(cosines) if cosines else None,
        "mean_pairwise_js_divergence_bits": sum(jsds) / len(jsds) if jsds else None,
        "max_pairwise_js_divergence_bits": max(jsds) if jsds else None,
        "cosine_distance_matrix": cosine_distance_matrix,
        "js_divergence_bits_matrix": js_divergence_matrix,
    }


def distributional_probe(
    records: list[dict[str, Any]],
    labels: dict[str, dict[str, Any]],
    axis_name: str,
    min_classes_per_formula: int = 1,
    weighted: bool = False,
) -> dict[str, Any]:
    rows = [row for row in formula_character_rows(records, labels) if row["label_class_count"] >= min_classes_per_formula]
    codes = sorted({code for label in labels.values() for code in label_codes(label) if code not in {"Z", "carrier"}})
    vectors: dict[str, Counter[str]] = defaultdict(Counter)
    kept_by_genre = Counter()
    for row in rows:
        kept_by_genre[row["genre"]] += 1
        vectors[row["genre"]].update(row["weighted_classes"] if weighted else row["material_classes"])
    metrics = pairwise_vector_metrics(vectors, codes)
    return {
        "method": (
            f"per-genre class-count vector cosine/JS over {axis_name}; "
            f"formulas require >= {min_classes_per_formula} distinct character classes; "
            f"{'label-confidence weighted' if weighted else 'material occurrence counted'}"
        ),
        "kept_labelled_formulas_by_genre": dict(sorted(kept_by_genre.items())),
        "class_count_vectors_by_genre": {
            genre: {code: vectors[genre].get(code, 0) for code in codes if vectors[genre].get(code, 0)}
            for genre in sorted(vectors)
        },
        **metrics,
    }


def prevalence_distributional_probe(
    records: list[dict[str, Any]],
    labels: dict[str, dict[str, Any]],
    axis_name: str,
) -> dict[str, Any]:
    """Unthresholded class prevalence profile by genre.

    Each vector entry is the fraction of labelled formulas in that genre
    containing the class. Magnitudes are retained; no active-set thresholding is
    applied.
    """
    rows = formula_character_rows(records, labels)
    codes = sorted({code for label in labels.values() for code in label_codes(label) if code not in {"Z", "carrier"}})
    by_genre: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        by_genre[row["genre"]].append(row)

    vectors: dict[str, Counter[str]] = defaultdict(Counter)
    kept_by_genre = {}
    for genre, genre_rows in sorted(by_genre.items()):
        kept_by_genre[genre] = len(genre_rows)
        counts = Counter(code for row in genre_rows for code in row["classes"])
        for code in codes:
            vectors[genre][code] = counts.get(code, 0) / len(genre_rows)

    return {
        "method": (
            f"per-genre unthresholded class-prevalence vectors over {axis_name}; "
            "entry = fraction of labelled formulas in the genre containing the class"
        ),
        "kept_labelled_formulas_by_genre": kept_by_genre,
        "class_prevalence_vectors_by_genre": {
            genre: {code: vectors[genre].get(code, 0.0) for code in codes}
            for genre in sorted(vectors)
        },
        **pairwise_vector_metrics(vectors, codes),
    }


def material_level_probe(records: list[dict[str, Any]], labels: dict[str, dict[str, Any]], axis_name: str) -> dict[str, Any]:
    codes = sorted({code for label in labels.values() for code in label_codes(label) if code not in {"Z", "carrier"}})
    material_classes_by_genre: dict[str, dict[str, set[str]]] = defaultdict(dict)
    for record in records:
        if record.get("is_control"):
            continue
        genre = record.get("genre") or record.get("metadata", {}).get("generation_strategy") or "unknown"
        for comp in census.formula_components(record):
            cas = str(comp.get("cas"))
            label = labels.get(cas)
            if not label:
                continue
            classes = {code for code in label_codes(label) if code not in {"Z", "carrier"}}
            if classes:
                material_classes_by_genre[genre][cas] = classes
    presence_profiles = {
        genre: sorted({code for classes in materials.values() for code in classes})
        for genre, materials in material_classes_by_genre.items()
    }
    pairwise_jaccard = []
    genres = sorted(presence_profiles)
    for i, left in enumerate(genres):
        for right in genres[i + 1:]:
            a, b = set(presence_profiles[left]), set(presence_profiles[right])
            pairwise_jaccard.append({
                "left": left,
                "right": right,
                "jaccard": len(a & b) / max(1, len(a | b)),
                "intersection": sorted(a & b),
                "union": sorted(a | b),
            })
    vectors = {}
    for genre, materials in material_classes_by_genre.items():
        vectors[genre] = Counter(code for classes in materials.values() for code in classes)
    metrics = pairwise_vector_metrics(vectors, codes)
    return {
        "method": f"unique-material-level character overlap for {axis_name}, not formula-level aggregation",
        "unique_labelled_materials_by_genre": {genre: len(materials) for genre, materials in sorted(material_classes_by_genre.items())},
        "presence_profiles_by_genre": dict(sorted(presence_profiles.items())),
        "pairwise_presence_jaccard": pairwise_jaccard,
        "mean_pairwise_presence_jaccard": (
            sum(p["jaccard"] for p in pairwise_jaccard) / len(pairwise_jaccard) if pairwise_jaccard else None
        ),
        "class_count_vectors_by_genre": {
            genre: {code: vectors[genre].get(code, 0) for code in codes if vectors[genre].get(code, 0)}
            for genre in sorted(vectors)
        },
        **metrics,
    }


def discrimination_probe(
    records: list[dict[str, Any]],
    labels: dict[str, dict[str, Any]],
    axis_name: str,
    threshold: float = 0.05,
) -> dict[str, Any]:
    by_genre = formula_character_sets(records, labels)
    genre_profiles: dict[str, set[str]] = {}
    prevalence_by_genre = {}
    for genre, sets in sorted(by_genre.items()):
        if len(sets) < 10:
            continue
        counts = Counter(code for s in sets for code in s)
        prevalence_by_genre[genre] = {code: count / len(sets) for code, count in sorted(counts.items())}
        genre_profiles[genre] = {code for code, count in counts.items() if count / len(sets) >= threshold}

    pairwise = []
    genres = sorted(genre_profiles)
    for i, left in enumerate(genres):
        for right in genres[i + 1:]:
            a, b = genre_profiles[left], genre_profiles[right]
            pairwise.append({
                "left": left,
                "right": right,
                "jaccard": len(a & b) / max(1, len(a | b)),
                "intersection": sorted(a & b),
                "union": sorted(a | b),
            })
    mean_jaccard = sum(p["jaccard"] for p in pairwise) / len(pairwise) if pairwise else None
    return {
        "method": (
            f"per-genre formula-level set Jaccard over {axis_name}; "
            f"class active if present in >={threshold:.0%} of labelled formulas for genre"
        ),
        "activity_threshold": threshold,
        "formula_labelled_by_genre": {k: len(v) for k, v in by_genre.items()},
        "class_prevalence_by_genre": prevalence_by_genre,
        "genre_profiles": {k: sorted(v) for k, v in genre_profiles.items()},
        "pairwise": pairwise,
        "mean_pairwise_jaccard": mean_jaccard,
    }


def threshold_sensitivity_probe(records: list[dict[str, Any]], labels: dict[str, dict[str, Any]], axis_name: str) -> list[dict[str, Any]]:
    rows = []
    for threshold in (0.05, 0.10, 0.15):
        probe = discrimination_probe(records, labels, axis_name, threshold=threshold)
        rows.append({
            "threshold": threshold,
            "mean_pairwise_jaccard": probe["mean_pairwise_jaccard"],
            "genre_profiles": probe["genre_profiles"],
            "prevalence_by_genre_before_thresholding": probe["class_prevalence_by_genre"],
        })
    return rows


def evaluate_snapshot(name: str, path: Path, records: list[dict[str, Any]]) -> dict[str, Any]:
    rows = load_jsonl(path)
    labels = {row["cas"]: row for row in rows}
    base = discrimination_probe(records, labels, "26-class character labels")
    base_density = label_density(records, labels, "26-class character labels")
    base_distribution = distributional_probe(records, labels, "26-class character labels")
    base_prevalence_distribution = prevalence_distributional_probe(records, labels, "26-class character labels")
    base_well_labelled_distribution = distributional_probe(records, labels, "26-class character labels", min_classes_per_formula=3)
    base_confidence_distribution = distributional_probe(records, labels, "26-class character labels", min_classes_per_formula=2, weighted=True)
    base_material = material_level_probe(records, labels, "26-class character labels")
    group_results = {}
    for grouping_name, spec in GROUPINGS.items():
        grouped = transform_labels(labels, spec["mapping"])
        discrimination_precheck = discrimination_probe(records, grouped, grouping_name)
        group_results[grouping_name] = {
            "justification": spec["justification"],
            "n_superclasses": len({v for v in spec["mapping"].values() if v != "carrier"}),
            "class_to_superclass": spec["mapping"],
            "label_density": label_density(records, grouped, grouping_name),
            "discrimination_precheck": discrimination_precheck,
            "superclass_prevalence_by_genre": discrimination_precheck["class_prevalence_by_genre"],
            "threshold_sensitivity": threshold_sensitivity_probe(records, grouped, grouping_name),
            "material_level_probe": material_level_probe(records, grouped, grouping_name),
            "distributional_probe": distributional_probe(records, grouped, grouping_name),
            "prevalence_distributional_metric": prevalence_distributional_probe(records, grouped, grouping_name),
            "well_labelled_distributional_probe_ge3": distributional_probe(records, grouped, grouping_name, min_classes_per_formula=3),
            "confidence_weighted_distributional_probe_ge2": distributional_probe(records, grouped, grouping_name, min_classes_per_formula=2, weighted=True),
        }
    best_group_name = min(
        group_results,
        key=lambda g: group_results[g]["discrimination_precheck"]["mean_pairwise_jaccard"]
        if group_results[g]["discrimination_precheck"]["mean_pairwise_jaccard"] is not None
        else float("inf"),
    )
    best_j = group_results[best_group_name]["discrimination_precheck"]["mean_pairwise_jaccard"]
    base_j = base["mean_pairwise_jaccard"]
    material_improvement = base_j is not None and best_j is not None and best_j <= base_j - 0.05
    return {
        "label_snapshot": name,
        "label_path": str(path.relative_to(ROOT)),
        "label_count": len(rows),
        "label_density_26_class": base_density,
        "twenty_six_class": base,
        "material_level_probe_26_class": base_material,
        "distributional_probe_26_class": base_distribution,
        "prevalence_distributional_metric_26_class": base_prevalence_distribution,
        "well_labelled_distributional_probe_26_class_ge3": base_well_labelled_distribution,
        "confidence_weighted_distributional_probe_26_class_ge2": base_confidence_distribution,
        "groupings": group_results,
        "best_grouping": best_group_name if material_improvement else None,
        "lowest_jaccard_grouping": best_group_name,
        "best_grouping_mean_pairwise_jaccard": best_j,
        "materially_better_than_26_class": material_improvement,
    }


def recommendation(primary: dict[str, Any]) -> str:
    base_j = primary["twenty_six_class"]["mean_pairwise_jaccard"]
    best_j = primary["best_grouping_mean_pairwise_jaccard"]
    base_distance = primary["prevalence_distributional_metric_26_class"]["mean_pairwise_cosine_distance"]
    base_jsd = primary["prevalence_distributional_metric_26_class"]["mean_pairwise_js_divergence_bits"]
    return (
        "Retract the superclass presence-Jaccard conclusion: the 1.000 is a collapse/presence artifact, "
        f"not proof that character is dead. Keep corrected distributional metrics as the circuit-breaker. "
        f"Under unthresholded prevalence vectors, 26-class character shows weak but nonzero profile "
        f"separation (mean cosine distance {base_distance:.3f}; mean JS divergence {base_jsd:.3f} bits; "
        f"presence-Jaccard baseline {base_j:.3f}, best collapsed presence-Jaccard {best_j:.3f}). "
        "Recommended direction: character may resume only at 26-class granularity under distributional "
        "and well-labelled filters; do not use superclass presence-Jaccard for go/no-go decisions."
    )


def character_verdict(primary: dict[str, Any]) -> dict[str, Any]:
    metric = primary["prevalence_distributional_metric_26_class"]
    return {
        "verdict": "discriminative-weak",
        "basis": "26-class unthresholded prevalence distributional metric",
        "mean_pairwise_cosine_distance": metric["mean_pairwise_cosine_distance"],
        "mean_pairwise_js_divergence_bits": metric["mean_pairwise_js_divergence_bits"],
        "interpretation": (
            "Character profiles are weakly but genuinely separated at 26-class granularity. "
            "Superclass collapse remains non-discriminative and threshold-driven. Character may resume "
            "only as a secondary axis gated by this distributional metric; presence-Jaccard must not be "
            "used again as the circuit-breaker."
        ),
        "next_action_policy": "present_for_human_decision_no_training_no_upload_no_sourcing_no_label_expansion",
    }


def matrix_markdown(title: str, matrix: dict[str, dict[str, float | None]]) -> list[str]:
    genres = sorted(matrix)
    rows = [
        f"### {title}",
        "",
        "| Genre | " + " | ".join(f"`{genre}`" for genre in genres) + " |",
        "| --- | " + " | ".join("---:" for _ in genres) + " |",
    ]
    for genre in genres:
        values = []
        for other in genres:
            value = matrix[genre][other]
            values.append("" if value is None else f"{value:.3f}")
        rows.append(f"| `{genre}` | " + " | ".join(values) + " |")
    rows.append("")
    return rows


def write_markdown(report: dict[str, Any]) -> None:
    primary = report["primary_snapshot_result"]
    rows = []
    base_j = primary["twenty_six_class"]["mean_pairwise_jaccard"]
    base_cos = primary["distributional_probe_26_class"]["mean_pairwise_cosine_similarity"]
    base_ge3_cos = primary["well_labelled_distributional_probe_26_class_ge3"]["mean_pairwise_cosine_similarity"]
    base_prevalence = primary["prevalence_distributional_metric_26_class"]
    verdict = report["character_verdict"]
    rows.append(f"| 26-class | 26 | {base_j:.3f} | baseline |")
    for name, result in primary["groupings"].items():
        j = result["discrimination_precheck"]["mean_pairwise_jaccard"]
        delta = j - base_j
        rows.append(f"| {name} | {result['n_superclasses']} | {j:.3f} | {delta:+.3f} |")
    density = primary["label_density_26_class"]
    density_rows = [
        "| 26-class | "
        f"{density['avg_distinct_classes_per_labelled_formula']:.2f} | "
        f"{density['avg_labelled_materials_per_labelled_formula']:.2f} |"
    ]
    for name, result in primary["groupings"].items():
        d = result["label_density"]
        density_rows.append(
            f"| {name} | {d['avg_distinct_classes_per_labelled_formula']:.2f} | "
            f"{d['avg_labelled_materials_per_labelled_formula']:.2f} |"
        )
    superclass_sets = []
    for name, result in primary["groupings"].items():
        profiles = result["discrimination_precheck"]["genre_profiles"]
        superclass_sets.append(f"### {name}")
        for genre, classes in sorted(profiles.items()):
            superclass_sets.append(f"- `{genre}`: {', '.join(classes)}")
        superclass_sets.append("")
    metric_rows = [
        "| 26-class | "
        f"{primary['distributional_probe_26_class']['mean_pairwise_cosine_similarity']:.3f} | "
        f"{primary['well_labelled_distributional_probe_26_class_ge3']['mean_pairwise_cosine_similarity']:.3f} | "
        f"{primary['material_level_probe_26_class']['mean_pairwise_cosine_similarity']:.3f} |",
    ]
    for name, result in primary["groupings"].items():
        metric_rows.append(
            f"| {name} | "
            f"{result['distributional_probe']['mean_pairwise_cosine_similarity']:.3f} | "
            f"{result['well_labelled_distributional_probe_ge3']['mean_pairwise_cosine_similarity']:.3f} | "
            f"{result['material_level_probe']['mean_pairwise_cosine_similarity']:.3f} |"
        )
    threshold_rows = []
    for name, result in primary["groupings"].items():
        for row in result["threshold_sensitivity"]:
            threshold_rows.append(f"| {name} | {row['threshold']:.0%} | {row['mean_pairwise_jaccard']:.3f} |")
    prevalence_metric_rows = [
        "| 26-class | 26 | "
        f"{base_prevalence['mean_pairwise_cosine_distance']:.3f} | "
        f"{base_prevalence['mean_pairwise_js_divergence_bits']:.3f} |"
    ]
    for name, result in primary["groupings"].items():
        metric = result["prevalence_distributional_metric"]
        prevalence_metric_rows.append(
            f"| {name} | {result['n_superclasses']} | "
            f"{metric['mean_pairwise_cosine_distance']:.3f} | "
            f"{metric['mean_pairwise_js_divergence_bits']:.3f} |"
        )
    distributional_matrices = [
        "## Unthresholded Prevalence Distributional Metric",
        "",
        "This is the corrected character discrimination test. Vectors retain the raw per-genre prevalence "
        "of each class before any active-set thresholding; cosine is reported as distance (`1 - similarity`) "
        "and JS divergence is reported in bits.",
        "",
        "| Axis | classes | mean cosine distance | mean JS divergence (bits) |",
        "| --- | ---: | ---: | ---: |",
        *prevalence_metric_rows,
        "",
        *matrix_markdown(
            "26-class cosine distance",
            base_prevalence["cosine_distance_matrix"],
        ),
        *matrix_markdown(
            "26-class JS divergence (bits)",
            base_prevalence["js_divergence_bits_matrix"],
        ),
    ]
    for name, result in primary["groupings"].items():
        metric = result["prevalence_distributional_metric"]
        distributional_matrices.extend(matrix_markdown(f"{name} cosine distance", metric["cosine_distance_matrix"]))
        distributional_matrices.extend(matrix_markdown(f"{name} JS divergence (bits)", metric["js_divergence_bits_matrix"]))
    md = [
        "# Character grouping diagnostic v8",
        "",
        f"Primary snapshot: `{primary['label_snapshot']}` (`{primary['label_path']}`).",
        "",
        "## Verdict",
        "",
        f"**Character verdict: {verdict['verdict']}.** Under the corrected 26-class unthresholded "
        f"prevalence metric, mean cosine distance is {verdict['mean_pairwise_cosine_distance']:.3f} "
        f"and mean JS divergence is {verdict['mean_pairwise_js_divergence_bits']:.3f} bits. This supports "
        "weak but genuine distributional separation, suitable only as a secondary axis pending human decision.",
        "",
        "**Artifact.** The perfect superclass Jaccard is mechanical: 1.000 = all genres have identical "
        "thresholded superclass active sets, NOT improved discrimination. After collapse, every genre profile "
        "contains every available superclass, so set-presence Jaccard has no room to differ. Distributional "
        f"class-count metrics are not perfect (26-class all-labelled mean cosine {base_cos:.3f}; "
        f"26-class >=3-label mean cosine {base_ge3_cos:.3f}), so the prior character-is-non-discriminative "
        "verdict is retracted.",
        "",
        "| Axis | classes | mean pairwise Jaccard | delta vs 26 |",
        "| --- | ---: | ---: | ---: |",
        *rows,
        "",
        "## Superclass Sets Compared",
        "",
        *superclass_sets,
        "## Label Density",
        "",
        "| Axis | avg distinct classes / labelled formula | avg labelled materials / labelled formula |",
        "| --- | ---: | ---: |",
        *density_rows,
        "",
        "## Corrected Distributional Metrics",
        "",
        "| Axis | mean cosine, all labelled | mean cosine, >=3 classes | material-level mean cosine |",
        "| --- | ---: | ---: | ---: |",
        *metric_rows,
        "",
        "Lower cosine means more genre-profile separation. The 26-class axis keeps the strongest separation; "
        "collapsing to superclasses weakens it and made presence-Jaccard unusable. `best_grouping` is null "
        "unless a grouped axis materially improves on 26-class; the lowest grouped Jaccard is only recorded "
        "as `lowest_jaccard_grouping` for audit.",
        "",
        *distributional_matrices,
        "## Threshold Sensitivity",
        "",
        "| Grouping | activity threshold | mean pairwise Jaccard |",
        "| --- | ---: | ---: |",
        *threshold_rows,
        "",
        f"Recommendation: **{report['recommendation']}**",
        "",
        f"Character verdict policy: **{verdict['interpretation']}**",
        "",
        "Substantivity remains confirmed and publishable independent of this character diagnostic. "
        "Next human decision: ship substantivity, resume character only under the corrected 26-class "
        "distributional circuit-breaker, or park character for a trajectory-proportion pivot.",
        "",
        "No new sourcing, label expansion, dataset assembly, training, or upload was performed.",
    ]
    OUT_MD.write_text("\n".join(md) + "\n", encoding="utf-8")


def main() -> None:
    records = census.load_formula_records()
    snapshots = {
        name: evaluate_snapshot(name, path, records)
        for name, path in LABEL_SNAPSHOTS.items()
        if path.exists()
    }
    primary_name = "v8_2_clause_guard_dominance"
    primary = snapshots[primary_name]
    report = {
        "pimt_version": "v8",
        "diagnostic": "character_grouping_existing_labels_only",
        "primary_snapshot": primary_name,
        "primary_snapshot_result": primary,
        "character_verdict": character_verdict(primary),
        "all_snapshot_results": snapshots,
        "recommendation": recommendation(primary),
        "stop": "NO_NEW_SOURCING_NO_LABEL_EXPANSION_NO_DATASET_ASSEMBLY_NO_TRAINING_NO_HF_UPLOAD",
    }
    OUT_JSON.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    write_markdown(report)
    print(json.dumps({
        "primary_snapshot": primary_name,
        "twenty_six_class_jaccard": primary["twenty_six_class"]["mean_pairwise_jaccard"],
        "best_grouping": primary["best_grouping"],
        "best_grouping_jaccard": primary["best_grouping_mean_pairwise_jaccard"],
        "materially_better_than_26_class": primary["materially_better_than_26_class"],
        "recommendation": report["recommendation"],
    }, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()