File size: 48,040 Bytes
439e7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Export a fine-tuned classifier for the Hugging Face model repo and the Gradio Space.

Subcommand ``run --checkpoint <finetune best dir>``:

1. load ``EncoderForClassification`` strictly (``num_labels`` from ``config.json["model"]``),
   cast to ``--dtype`` (bf16 by default: ~337M params x 2 B = ~675 MB, half the fp32 size,
   and the Space runs the encoder under bf16 anyway; the classifier has no tied weights,
   so a plain ``state_dict`` is safetensors-safe) and write ``model.safetensors`` with a
   text-only metadata header (format, dtype, architecture, config JSON) so a loader can
   rebuild the model from the file alone. The file is re-read and checked key-for-key.
2. ``tokenizer.json`` (copied) and ``config.json`` = model config + label names
   (``{"0": "Legitimate", "1": "Phishing", "2": "Spam"}`` for a 3-class head, binary names
   otherwise) + ``binary_rollup`` (which class indices sum to "unwanted") + max_seq_len +
   export dtype + source checkpoint + the evaluation metrics (reports/metrics.json for the
   TREC-06 binary table and reports/metrics_test3.json for the three-way table, when
   present) + the fine-tune dials and init descriptor + the pretraining summary (the
   pretrain run's final/config.json reached through the checkpoint's ``pretrained:<path>``
   descriptor or ``--pretrain-config``; always null for a scratch-initialised checkpoint,
   never guessed from the newest run).
3. ``README.md`` model card (huggingface_hub ModelCard / ModelCardData front matter) with
   BOTH metric tables, stating prominently that no pretrained weights were used and that
   the phishing / spam labels follow corpus provenance (Nazario + Nigerian fraud + synthetic
   lures = phishing; SpamAssassin + Enron-Spam spam folders = spam; TREC / CEAS positives
   were trained as "unwanted" only).
4. copy ``src/model.py`` -> ``<app-dir>/model.py`` byte-for-byte (sha1 asserted) and the
   three weight files into ``<app-dir>/`` so ``app.py`` can load locally when they exist
   and fall back to ``hf_hub_download`` otherwise. ``app.py`` and ``requirements.txt`` belong
   to another owner and are never written here.

Nothing is uploaded; ``upload_to_hf.py`` owns that step.
"""

from __future__ import annotations

import argparse
import json
import logging
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Sequence

sys.path.insert(0, str(Path(__file__).parent))

import torch  # noqa: E402
from safetensors import safe_open  # noqa: E402
from safetensors.torch import load_file, save_file  # noqa: E402

import common  # noqa: E402
from model import EncoderConfig, EncoderForClassification, count_parameters  # noqa: E402

LOG = logging.getLogger("export")

# Label contract (RUN_LOG entry 6): 0 legitimate, 1 phishing, 2 spam; the binary roll-up "unwanted" is
# the sum over every non-legitimate class. A 2-class head's class 1 IS the roll-up, hence its name.
UNWANTED_NAME = "Unwanted (phishing or spam)"
LABELS_3: dict[str, str] = {"0": "Legitimate", "1": "Phishing", "2": "Spam"}
LABELS_2: dict[str, str] = {"0": "Legitimate", "1": UNWANTED_NAME}
LABEL_PROVENANCE = ("Phishing = Nazario phishing corpus + Nigerian-fraud (advance-fee) corpus + synthetic BITS-style credential/BEC lures; "
                    "Spam = SpamAssassin and Enron-Spam spam folders; the TREC-05 / TREC-07 / CEAS-08 positives never separated spam "
                    "from phishing and were trained as 'unwanted' only (probability mass of phishing + spam), never as either class.")
DTYPES: dict[str, torch.dtype] = {"bf16": torch.bfloat16, "fp32": torch.float32}
WEIGHT_FILES: tuple[str, ...] = ("model.safetensors", "tokenizer.json", "config.json")
PROTECTED_APP_FILES: frozenset[str] = frozenset({"app.py", "requirements.txt"})
DATASET_IDS: tuple[str, str] = ("puyang2025/seven-phishing-email-datasets", "corbt/enron-emails")
AURA_DATASET_ID = "kudzaiprichard/aura-phishing-email-corpus"   # Champa et al. mirror: Nazario.csv + Nigerian_Fraud.csv (phishing)
CARD_TAGS: tuple[str, ...] = ("phishing", "email-security", "text-classification", "from-scratch")


def labels_for(num_labels: int) -> dict[str, str]:
    if num_labels == 3:
        return dict(LABELS_3)
    if num_labels == 2:
        return dict(LABELS_2)
    raise ValueError(f"num_labels={num_labels}: expected 2 or 3")


def binary_rollup_for(num_labels: int) -> dict[str, list[int]]:
    """Class indices whose probabilities sum to P(unwanted): [1, 2] for the 3-class head, [1] for binary."""
    return {"unwanted": list(range(1, num_labels))}


# ------------------------------------------------------------------------------ load
def load_classifier(ckpt_dir: Path) -> tuple[EncoderForClassification, EncoderConfig, dict]:
    cfg_json = common.read_json(ckpt_dir / "config.json")
    mcfg = EncoderConfig.from_dict(cfg_json["model"])
    mcfg.grad_checkpointing = False
    labels_for(mcfg.num_labels)   # raises on an unsupported head size before anything is written
    model = EncoderForClassification(mcfg)
    sd = load_file(str(ckpt_dir / "model.safetensors"), device="cpu")
    model.load_state_dict(sd, strict=True)
    model.eval()
    LOG.info("loaded %s: %d tensors, %s params, num_labels=%d", ckpt_dir, len(sd), f"{count_parameters(model)['total']:,}", mcfg.num_labels)
    return model, mcfg, cfg_json


def finetune_init(ckpt_cfg: dict) -> str | None:
    """finetune.py's init descriptor as stored in the checkpoint config: ``"scratch"`` or
    ``"pretrained:<path>"`` (top-level ``init`` or ``finetune.init``); None when absent."""
    v = ckpt_cfg.get("init") or (ckpt_cfg.get("finetune") or {}).get("init")
    return str(v) if v else None


def is_scratch_init(ckpt_cfg: dict) -> bool:
    return (finetune_init(ckpt_cfg) or "").strip().lower() == "scratch"


def find_pretrain_summary(ckpt_cfg: dict, explicit: str | None) -> tuple[dict | None, str | None]:
    """``training`` block of the pretraining run's final/config.json. Provenance has to be
    exact, so only two sources are accepted: an explicit ``--pretrain-config`` path, or a
    ``pretrained:<path>`` descriptor written by finetune.py into the checkpoint config.
    There is deliberately no "newest run" fallback: a classifier fine-tuned from random
    init (``init: scratch``) has no pretraining run, and a pretrained-init checkpoint
    whose path no longer resolves gets ``None`` (the card then says so) rather than
    another run's step counts. Passing ``--pretrain-config`` for a scratch checkpoint is
    an error, not a hint."""
    if is_scratch_init(ckpt_cfg):
        if explicit:
            raise ValueError(f"--pretrain-config given but the checkpoint was fine-tuned from scratch (init={finetune_init(ckpt_cfg)!r})")
        return None, None
    cands: list[Path] = []
    if explicit:
        cands.append(Path(explicit))

    def walk(o: Any) -> None:
        if isinstance(o, dict):
            for v in o.values():
                walk(v)
        elif isinstance(o, list):
            for v in o:
                walk(v)
        elif isinstance(o, str) and o.startswith("pretrained:"):
            p = Path(o[len("pretrained:"):])
            cands.append(p if p.is_absolute() else common.project_root() / p)

    walk(ckpt_cfg)
    for c in cands:
        for p in (c, c / "config.json", c.parent / "config.json", c / "final" / "config.json"):
            if p.is_file() and p.name == "config.json":
                try:
                    j = common.read_json(p)
                except (OSError, json.JSONDecodeError):
                    continue
                if "training" in j:
                    return j["training"], str(p)
    return None, None


# ----------------------------------------------------------------------------- export
def export_weights(model: EncoderForClassification, mcfg: EncoderConfig, dtype: str, path: Path) -> dict[str, Any]:
    model.to(DTYPES[dtype])
    sd = {k: v.contiguous() for k, v in model.state_dict().items()}
    meta = {"format": "pt", "dtype": dtype, "architecture": "EncoderForClassification",
            "config": json.dumps(mcfg.to_dict(), sort_keys=True), "labels": json.dumps(labels_for(mcfg.num_labels)),
            "exported": datetime.now().isoformat(timespec="seconds")}
    path.parent.mkdir(parents=True, exist_ok=True)
    save_file(sd, str(path), metadata=meta)
    # Verify by re-reading: same keys, same shapes, requested dtype, metadata intact.
    back = load_file(str(path), device="cpu")
    if back.keys() != sd.keys():
        raise RuntimeError("exported safetensors keys differ from the state_dict")
    bad = [k for k in sd if back[k].shape != sd[k].shape or back[k].dtype != DTYPES[dtype]]
    if bad:
        raise RuntimeError(f"exported tensors mismatch: {bad[:5]}")
    head = back["classifier.out.weight"].shape[0]
    if head != mcfg.num_labels:
        raise RuntimeError(f"classifier head has {head} outputs but config num_labels={mcfg.num_labels}")
    with safe_open(str(path), framework="pt") as f:
        if f.metadata().get("architecture") != "EncoderForClassification":
            raise RuntimeError("safetensors metadata missing")
    size = path.stat().st_size
    LOG.info("wrote %s: %d tensors, %s, %.1f MB", path, len(sd), dtype, size / 1e6)
    return {"tensors": len(sd), "bytes": size, "dtype": dtype, "params": count_parameters(model)["total"]}


def build_config(mcfg: EncoderConfig, ckpt: Path, ckpt_cfg: dict, dtype: str, metrics: dict | None, metrics_path: str | None,
                 metrics3: dict | None, metrics3_path: str | None, training: dict | None, training_path: str | None,
                 metrics_mismatch: Sequence[str] = (), metrics3_mismatch: Sequence[str] = ()) -> dict[str, Any]:
    scratch = is_scratch_init(ckpt_cfg)
    m3s = _metrics3_summary(metrics3) if metrics3 else None   # None when the file has no evaluated three-way block
    if scratch and training is not None:
        raise AssertionError("scratch-initialised checkpoint must not carry a pretraining summary")
    finetune = {k: v for k, v in ckpt_cfg.items() if k != "model"}
    k = mcfg.num_labels
    return {
        "architecture": "EncoderForClassification",
        "model": mcfg.to_dict(),
        "num_labels": k,
        "labels": labels_for(k),
        "binary_rollup": binary_rollup_for(k),
        "unwanted_name": UNWANTED_NAME,
        "label_provenance": LABEL_PROVENANCE if k == 3 else "class 1 = unwanted mail (phishing or spam; the public corpora do not separate them)",
        "max_seq_len": mcfg.max_seq_len,
        "export_dtype": dtype,
        "source_checkpoint": str(ckpt),
        "exported": datetime.now().isoformat(timespec="seconds"),
        "tokenizer": "tokenizer.json",
        "model_input": 'subject + "\\n\\n" + body',
        "finetune_checkpoint": finetune or None,
        "finetune_init": finetune_init(ckpt_cfg),
        "mlm_pretraining": "none (classifier trained from random init)" if scratch else ("summary below" if training else "yes, summary unavailable at export time"),
        "metrics": _metrics_summary(metrics) if metrics else None,
        "metrics_source": metrics_path,
        "metrics_checkpoint_matches": (not metrics_mismatch) if metrics else None,
        "metrics_mismatch": list(metrics_mismatch) or None,
        "metrics_threeway": m3s,
        # source only when a table is carried; otherwise say why (2-class head, or a split without phishing/spam ground truth)
        "metrics_threeway_source": metrics3_path if m3s is not None else None,
        "metrics_threeway_reason": None if m3s is not None else (((metrics3 or {}).get("threeway") or {}).get("reason")
                                                                  or ("2-class checkpoint: no phishing-vs-spam head" if k == 2 else None)),
        "metrics_threeway_checkpoint_matches": (not metrics3_mismatch) if m3s is not None else None,
        "metrics_threeway_mismatch": list(metrics3_mismatch) or None,
        "training": training,
        "training_source": training_path,
        "from_scratch": True,
        "external_pretrained_weights_used": "none",
    }


def read_splits_manifest(data_processed: Path) -> dict | None:
    """data_prep's splits_manifest.json, the single source for the dataset row counts quoted
    in the model card (never hard-coded, so a re-run of data_prep cannot stale the card)."""
    p = data_processed / "splits_manifest.json"
    if not p.is_file():
        return None
    try:
        return common.read_json(p)
    except (OSError, json.JSONDecodeError) as e:
        LOG.warning("could not read %s: %s", p, e)
        return None


def _metrics_summary(m: dict) -> dict[str, Any]:
    """Binary roll-up summary of an evaluate.py metrics.json (the TREC-06 table)."""
    at, tf, lat = m.get("at_threshold", {}), m.get("threshold_free", {}), m.get("latency", {})
    r1, r01 = tf.get("recall_at_fpr_1pct") or {}, tf.get("recall_at_fpr_0_1pct") or {}
    lure = (m.get("credential_lure_slice") or {}).get("metrics") or {}
    g, c = lat.get("gpu_bf16") or {}, lat.get("cpu_fp32") or {}
    tw = m.get("threeway") or {}
    bd = (tw.get("predicted_kind_breakdown") or {}).get("true_positives_flagged")
    return {
        "split": m.get("split"), "split_file": m.get("split_file"), "n_rows": (m.get("split_sizes") or {}).get("rows"),
        "dev_run": m.get("dev_run"), "threshold": m.get("threshold"), "timestamp": m.get("timestamp"), "num_labels": m.get("num_labels"),
        "positive_class": UNWANTED_NAME,
        "accuracy": at.get("accuracy"), "precision": at.get("precision"), "recall": at.get("recall"), "f1": at.get("f1"),
        "specificity": at.get("specificity"), "confusion_matrix": at.get("confusion_matrix"),
        "roc_auc": tf.get("roc_auc"), "pr_auc": tf.get("pr_auc"),
        "recall_at_fpr_1pct": r1.get("recall"), "recall_at_fpr_0_1pct": r01.get("recall"),
        "best_f1_threshold": (tf.get("best_f1") or {}).get("threshold"),
        "credential_lure_recall": lure.get("recall"), "credential_lure_precision": lure.get("precision"), "credential_lure_n": lure.get("n"),
        "predicted_kind_of_flagged_true_positives": bd,   # informational: no phishing-vs-spam ground truth on TREC-06
        "latency_ms": {"gpu_bf16_avg": g.get("avg_ms"), "gpu_bf16_p50": g.get("p50_ms"), "cpu_fp32_avg": c.get("avg_ms"),
                       "cpu_fp32_p50": c.get("p50_ms"), "cpu_threads": c.get("threads"),
                       "instructor_benchmark_ms": lat.get("instructor_benchmark_ms")},
    }


def _metrics3_summary(m: dict) -> dict[str, Any] | None:
    """Three-way summary of an evaluate.py metrics_test3.json; None when the file carries no
    evaluated three-way block (2-class checkpoint, or a split without label3 ground truth)."""
    tw = m.get("threeway") or {}
    if not tw.get("evaluated"):
        return None
    at = m.get("at_threshold", {})
    pvs = tw.get("phish_vs_spam_on_true_unwanted") or {}
    ko = pvs.get("kind_only") or {}
    return {
        "split": m.get("split"), "split_file": m.get("split_file"), "n_rows": (m.get("split_sizes") or {}).get("rows"),
        "dev_run": m.get("dev_run"), "threshold": m.get("threshold"), "timestamp": m.get("timestamp"),
        "rows_with_full_label": tw.get("rows_with_full_label"), "label3_counts": tw.get("label3_counts"),
        "decision_rule": tw.get("decision_rule"), "accuracy": tw.get("accuracy"), "macro_f1": tw.get("macro_f1"),
        "per_class": tw.get("per_class"), "confusion_matrix": tw.get("confusion_matrix"),
        "kind_accuracy_on_true_unwanted": ko.get("accuracy"), "phishing_as_spam": ko.get("phishing_as_spam"), "spam_as_phishing": ko.get("spam_as_phishing"),
        "n_true_unwanted": pvs.get("n_true_unwanted"),
        "per_source": {s: {"n": v.get("n"), "counts": v.get("counts"), "accuracy": v.get("accuracy")} for s, v in (tw.get("per_source") or {}).items()},
        "binary_rollup_on_this_split": {"recall": at.get("recall"), "precision": at.get("precision"), "f1": at.get("f1"), "accuracy": at.get("accuracy")},
    }


# --------------------------------------------------------------------------- model card
def _f(v: Any, nd: int = 4) -> str:
    return "n/a" if v is None else f"{v:.{nd}f}"


def _n(v: Any) -> str:
    """Thousands-separated integer or 'n/a' (manifest fields may be missing)."""
    return "n/a" if v is None else f"{int(v):,}"


def _ms(v: Any) -> str:
    """Latency cell: 'n/a' when that device was not measured (e.g. a CPU-only evaluation)."""
    return "n/a" if v is None else f"{v} ms"


def _binary_table_md(ms: dict[str, Any], metrics: dict) -> str:
    cm = ms.get("confusion_matrix") or {}
    lat = ms.get("latency_ms") or {}
    lines = [
        f"Evaluated by `src/evaluate.py` on the **{ms['split']}** split (`{Path(ms['split_file'] or '').name}`, {ms['n_rows']:,} rows, "
        f"threshold {ms['threshold']}){' — DEV RUN on a row subset, not the final number' if ms.get('dev_run') else ''}. "
        f"Positive class = **{UNWANTED_NAME}**"
        + (", P(unwanted) = P(phishing) + P(spam); these definitions are identical to the binary-era runs, so the numbers are comparable." if ms.get("num_labels") == 3 else "."), "",
        "| metric | value |", "|---|---:|",
        f"| **Recall (unwanted)** | **{_f(ms['recall'])}** ({cm.get('fn', 'n/a')} missed of {(cm.get('fn') or 0) + (cm.get('tp') or 0):,}) |",
        f"| Precision (unwanted) | {_f(ms['precision'])} |", f"| F1 (unwanted) | {_f(ms['f1'])} |",
        f"| Accuracy | {_f(ms['accuracy'])} |", f"| Specificity | {_f(ms['specificity'])} |",
        f"| ROC-AUC | {_f(ms['roc_auc'])} |", f"| PR-AUC | {_f(ms['pr_auc'])} |",
        f"| Recall at 1% FPR | {_f(ms['recall_at_fpr_1pct'])} |", f"| Recall at 0.1% FPR | {_f(ms['recall_at_fpr_0_1pct'])} |",
        f"| Credential-lure slice recall / precision (n={ms['credential_lure_n']}) | {_f(ms['credential_lure_recall'])} / {_f(ms['credential_lure_precision'])} |",
        f"| Latency, batch 1, GPU bf16 (avg / p50) | {_ms(lat.get('gpu_bf16_avg'))} / {_ms(lat.get('gpu_bf16_p50'))} |",
        f"| Latency, batch 1, CPU fp32, {lat.get('cpu_threads') or 'n/a'} threads (avg / p50) | {_ms(lat.get('cpu_fp32_avg'))} / {_ms(lat.get('cpu_fp32_p50'))} |",
        f"| Instructor benchmark | {lat.get('instructor_benchmark_ms')} ms per sample |", "",
        "Recall on the unwanted class is the headline number: in a defensive triage aid a missed phish is the "
        "costly error, while a false positive costs a human one glance.",
    ]
    bd = ms.get("predicted_kind_of_flagged_true_positives")
    if bd:
        lines += ["", f"TREC-06 carries no phishing-vs-spam ground truth, so the three-way head can only be *described* here: of the "
                  f"{bd['n']:,} true positives it flagged, the model called {bd['predicted_phishing']:,} phishing and {bd['predicted_spam']:,} spam "
                  "(informational, not a metric)."]
    return "\n".join(lines)


def _threeway_table_md(m3: dict[str, Any]) -> str:
    pc = m3.get("per_class") or {}
    cmx = m3.get("confusion_matrix") or {}
    labs = cmx.get("labels") or list(pc)
    lines = [
        f"Evaluated by `src/evaluate.py --split {m3['split']}` on `{Path(m3['split_file'] or '').name}` ({_n(m3['rows_with_full_label'])} rows with a "
        f"full three-way label, threshold {m3['threshold']}){' — DEV RUN on a row subset, not the final number' if m3.get('dev_run') else ''}. "
        f"Decision rule `{m3['decision_rule']}`: a row is unwanted iff P(phishing) + P(spam) >= threshold, then the kind is "
        "argmax(P(phishing), P(spam)), so this table collapses exactly onto the binary roll-up.", "",
        "| metric | value |", "|---|---:|",
        f"| **Macro-F1 (3 classes)** | **{_f(m3['macro_f1'])}** |", f"| Accuracy | {_f(m3['accuracy'])} |",
    ]
    for lab, v in pc.items():
        lines.append(f"| {lab}: precision / recall / F1 (n={v.get('support', 0):,}) | {_f(v.get('precision'))} / {_f(v.get('recall'))} / {_f(v.get('f1'))} |")
    if m3.get("kind_accuracy_on_true_unwanted") is not None:
        lines.append(f"| Phishing-vs-spam accuracy on the {_n(m3['n_true_unwanted'])} true unwanted rows (kind decision alone) | "
                     f"{_f(m3['kind_accuracy_on_true_unwanted'])} ({_n(m3['phishing_as_spam'])} phishing read as spam, {_n(m3['spam_as_phishing'])} spam read as phishing) |")
    br = m3.get("binary_rollup_on_this_split") or {}
    lines.append(f"| Binary roll-up on this split: recall / precision / F1 | {_f(br.get('recall'))} / {_f(br.get('precision'))} / {_f(br.get('f1'))} |")
    if cmx.get("matrix"):
        lines += ["", "Confusion matrix (rows = true label by corpus provenance, columns = predicted):", "",
                  "| true \\ predicted | " + " | ".join(labs) + " |", "|---|" + "---:|" * len(labs)]
        for i, lab in enumerate(labs):
            lines.append(f"| **{lab}** | " + " | ".join(f"{v:,}" for v in cmx["matrix"][i]) + " |")
    ps = m3.get("per_source") or {}
    if ps:
        lines += ["", "| source | rows | Legitimate | Phishing | Spam | accuracy |", "|---|---:|---:|---:|---:|---:|"]
        for s, v in ps.items():
            c = v.get("counts") or {}
            lines.append(f"| {s} | {_n(v.get('n'))} | {_n(c.get('Legitimate'))} | {_n(c.get('Phishing'))} | {_n(c.get('Spam'))} | {_f(v.get('accuracy'))} |")
    lines += ["", f"**Label provenance.** {LABEL_PROVENANCE} The three-way holdout is drawn from text the tokenizer never saw and the pretraining "
              "corpus does not contain (Nazario / Nigerian-fraud phishing, SpamAssassin / Enron-Spam spam, CEAS-08 / TREC-07 / SpamAssassin legitimate "
              "mail from the former validation split). Because the kind labels come from which corpus a message sits in, part of any phishing-vs-spam "
              "confusion measures corpus style rather than lure content."]
    return "\n".join(lines)


def model_card(mcfg: EncoderConfig, cfg: dict, ckpt_cfg: dict, metrics: dict | None, metrics3: dict | None, training: dict | None, dtype: str,
               params: int, weight_bytes: int, model_repo: str, manifest: dict | None = None,
               metrics_mismatch: Sequence[str] = (), metrics3_mismatch: Sequence[str] = ()) -> str:
    from huggingface_hub import EvalResult, ModelCard, ModelCardData

    model_name = model_repo.split("/")[-1]
    k = mcfg.num_labels
    labels = labels_for(k)
    ms = _metrics_summary(metrics) if metrics else None
    m3 = _metrics3_summary(metrics3) if metrics3 else None
    scratch = is_scratch_init(ckpt_cfg)
    if scratch and training is not None:
        raise AssertionError("scratch-initialised checkpoint must not carry a pretraining summary")
    man = manifest or {}
    totals = (man.get("counts") or {}).get("totals") or {}
    tr_rows, va_rows, te = totals.get("finetune_train") or {}, totals.get("finetune_val") or {}, totals.get("test_holdout") or {}
    te3 = totals.get("test_holdout3") or {}
    pt_man = man.get("pretrain") or {}
    decon = (((man.get("drops") or {}).get("decontamination_vs_holdout") or {}).get("by_reason") or {}).get("total")
    holdout_src = man.get("holdout_test_source") or (cfg or {}).get("data", {}).get("holdout_test_source") or "TREC-06"
    te_rate = f"{100 * te['base_rate']:.1f}% positive" if te.get("base_rate") is not None else "positive rate per metrics.json"
    eval_results = []
    if ms and ms.get("split") == "test" and not ms.get("dev_run"):
        eval_results += [
            EvalResult(task_type="text-classification", dataset_type=DATASET_IDS[0], dataset_name="TREC-06 holdout (puyang2025), binary roll-up",
                       metric_type=mt, metric_name=mn, metric_value=round(float(ms[kk]), 4))
            for kk, mt, mn in (("recall", "recall", "Recall (unwanted)"), ("precision", "precision", "Precision (unwanted)"),
                               ("f1", "f1", "F1 (unwanted)"), ("accuracy", "accuracy", "Accuracy"), ("roc_auc", "roc_auc", "ROC-AUC"))
            if ms.get(kk) is not None
        ]
    if m3 and m3.get("split") == "test3" and not m3.get("dev_run"):
        eval_results += [
            EvalResult(task_type="text-classification", dataset_type=AURA_DATASET_ID,
                       dataset_name="three-way holdout (Nazario + Nigerian Fraud phishing, SpamAssassin / Enron-Spam spam, CEAS-08 / TREC-07 / SpamAssassin ham)",
                       metric_type=mt, metric_name=mn, metric_value=round(float(m3[kk]), 4))
            for kk, mt, mn in (("macro_f1", "f1", "Macro-F1 (legitimate / phishing / spam)"), ("accuracy", "accuracy", "Accuracy (3-way)"))
            if m3.get(kk) is not None
        ]
    datasets = list(DATASET_IDS) + ([AURA_DATASET_ID] if k == 3 else [])
    data = ModelCardData(language="en", license="mit", library_name="pytorch", pipeline_tag="text-classification",
                         tags=list(CARD_TAGS) + (["spam-detection"] if k == 3 else []), datasets=datasets, model_name=model_name,
                         eval_results=eval_results or None)
    ft = ckpt_cfg.get("finetune") or (cfg or {}).get("finetune") or {}   # the checkpoint's own dials win
    pt = (cfg or {}).get("pretrain") or {}
    size_md = f"~{weight_bytes / 1e6:,.0f} MB"

    # Only reachable with --allow-metrics-mismatch: say on the card itself that the numbers were not produced on this checkpoint.
    def mismatch_md(problems: Sequence[str]) -> str:
        return (f"> **Warning:** this table was exported with `--allow-metrics-mismatch`; the metrics file does not belong to the "
                f"exported checkpoint ({'; '.join(problems)}).\n\n") if problems else ""

    if ms:
        metrics_md = mismatch_md(metrics_mismatch) + _binary_table_md(ms, metrics or {})
    else:
        metrics_md = "Not yet evaluated: run `src/evaluate.py run --checkpoint ... --split test` and re-export to fill this table."
    if k == 3:
        if m3:
            metrics3_md = mismatch_md(metrics3_mismatch) + _threeway_table_md(m3)
        else:
            metrics3_md = ("Not yet evaluated: run `src/evaluate.py run --checkpoint ... --split test3` (writes `reports/metrics_test3.json`) "
                           "and re-export to fill this table. " + LABEL_PROVENANCE)
        metrics3_section = f"\n### Three-way holdout (legitimate / phishing / spam)\n\n{metrics3_md}\n"
        classes_md = (f"to three classes: `0 = Legitimate`, `1 = Phishing`, `2 = Spam`. The binary verdict shown by the demo is the roll-up "
                      f"**{UNWANTED_NAME}** = P(phishing) + P(spam) (`config.json[\"binary_rollup\"]`). {LABEL_PROVENANCE}")
    else:
        metrics3_section = ""
        classes_md = (f"to two classes: `0 = Legitimate`, `1 = {UNWANTED_NAME}` (phishing **or** spam; the public corpora do not separate the two).")

    if scratch:
        pretrain_md = (f"1. **No masked-language-model pretraining.** This checkpoint was fine-tuned from random initialisation "
                       f"(`finetune.init = scratch`); it is the from-scratch control in the project's experiments comparison, so the "
                       f"encoder has seen only the labelled fine-tuning data below.")
    else:
        if training:
            tr_md = "\n".join(f"   - {kk}: {v}" for kk, v in training.items())
        else:
            tr_md = (f"   - pretraining summary not available at export time: the checkpoint's init descriptor "
                     f"`{finetune_init(ckpt_cfg) or 'pretrained'}` did not resolve to a `final/config.json` and no `--pretrain-config` "
                     f"was given; see RUN_LOG.md for the run record.")
        pretrain_md = "\n".join([
            f"1. **Masked-language-model pretraining from scratch** on the deduplicated Enron email corpus "
            f"(`{DATASET_IDS[1]}`: {_n(pt_man.get('rows_in'))} rows -> {_n(pt_man.get('rows_out'))} documents after exact/near-duplicate "
            f"removal and quote stripping), {int(100 * pt.get('mlm_probability', 0.15))}% dynamic masking "
            f"({'/'.join(str(int(100 * x)) for x in pt.get('mask_replace_probs', (0.8, 0.1, 0.1)))}), sequence packing to "
            f"{mcfg.max_seq_len} tokens, AdamW (betas {tuple(pt.get('betas', (0.9, 0.98)))}, weight decay {pt.get('weight_decay', 0.01)}), "
            f"peak LR {pt.get('lr', '1.5e-4')}, bf16 autocast on one RTX 3090 Ti. Run summary (from the pretraining run's "
            f"`final/config.json`):",
            tr_md])
    ft_md = "\n".join(f"   - {kk}: {v}" for kk, v in ft.items()) if ft else "   - see config.yaml `finetune`"
    if k == 3:
        ft_sources = (f"TREC-05, TREC-07 and CEAS-08 (positives trained as *unwanted* only), Enron-Spam and SpamAssassin (spam folders = `Spam`), "
                      f"the Nazario and Nigerian-fraud corpora of `{AURA_DATASET_ID}` (= `Phishing`) and synthetic BITS-style lures (= `Phishing`)")
        loss_md = ("Rows with a clean three-way label train with 3-class cross-entropy; TREC / CEAS positives train on the probability mass "
                   "of {phishing, spam} (partial-label loss: -log(P(phishing) + P(spam))), so they shape the legitimate-vs-unwanted decision "
                   "without asserting a kind they were never labelled with.")
        te3_md = (f" A second holdout, **test_holdout3** ({_n(te3.get('rows'))} rows), holds clean-provenance three-way rows never seen by the "
                  f"tokenizer or the pretraining corpus for the 3x3 confusion matrix.") if te3 else (
                  " A second holdout, **test_holdout3**, holds clean-provenance three-way rows never seen by the tokenizer or the pretraining "
                  "corpus for the 3x3 confusion matrix.")
    else:
        ft_sources = "TREC-05, TREC-07, CEAS-08, Enron-Spam and SpamAssassin rows"
        loss_md = ""
        te3_md = ""
    lat_ms = (ms or {}).get("latency_ms") or {}
    lat_dev = " — DEV RUN numbers." if (ms or {}).get("dev_run") else "."
    lat_head = "Measured single-email latency (batch 1, tokenize + forward + softmax, see the metrics table): "
    if lat_ms.get("gpu_bf16_avg") is not None:
        latency_md = (lat_head + f"GPU bf16 {lat_ms['gpu_bf16_avg']} ms avg on "
                      f"{(metrics or {}).get('latency', {}).get('gpu_bf16', {}).get('device', 'the evaluation GPU')}"
                      + (f", CPU fp32 ({lat_ms.get('cpu_threads')} threads) {lat_ms['cpu_fp32_avg']} ms avg" if lat_ms.get("cpu_fp32_avg") is not None else "")
                      + lat_dev)
    elif lat_ms.get("cpu_fp32_avg") is not None:
        # CPU-only evaluation (e.g. the GPU was busy): evaluate.py HAS run, so say what was measured
        latency_md = (lat_head + f"CPU fp32 ({lat_ms.get('cpu_threads')} threads) {lat_ms['cpu_fp32_avg']} ms avg; GPU latency was not "
                      "measured in this evaluation (CPU-only run)" + lat_dev)
    else:
        latency_md = "Single-email latency is reported in the metrics table once `src/evaluate.py` has run."
    rollup_py = ("p_unwanted = sum(float(probs[i]) for i in cfg[\"binary_rollup\"][\"unwanted\"])   # P(phishing) + P(spam)\n"
                 "print(\"unwanted\" if p_unwanted >= 0.5 else \"legitimate\", {cfg[\"labels\"][str(i)]: round(float(p), 4) for i, p in enumerate(probs)})")
    files_cfg = ("model config, num_labels, labels, binary_rollup, label provenance, max_seq_len, export dtype, source checkpoint, fine-tune init, "
                 "binary metrics (TREC-06), three-way metrics (test_holdout3), pretraining summary (null for scratch init)")

    body = f"""
# {model_name}: {'legitimate / phishing / spam' if k == 3 else 'phishing'} email classifier trained from scratch

> **No external pretrained weights were used.** The encoder, the tokenizer and every parameter in `model.safetensors`
> were trained from random initialisation inside this project, in plain PyTorch, without the `transformers`
> library. The only external inputs are the public email corpora listed below.
> Initialisation of this checkpoint: `{finetune_init(ckpt_cfg) or 'not recorded'}`.

## What it is

A {params:,}-parameter BERT-style **pre-LayerNorm Transformer encoder** ({mcfg.n_layers} layers, d_model {mcfg.d_model},
{mcfg.n_heads} heads, FFN {mcfg.d_ff}, byte-level BPE vocabulary of {mcfg.vocab_size:,}, {mcfg.max_seq_len} positions) with a
CLS-pooled classification head that maps an email (`subject + "\\n\\n" + body`) {classes_md}
The architecture is defined in `model.py`, which ships with the weights. Weights are stored in **{dtype}** safetensors
({size_md}); the header carries the architecture config and the label names as JSON.

## How it was trained

{pretrain_md}
2. **Fine-tuning** on `{DATASET_IDS[0]}` with our own split: {ft_sources} form train ({_n(tr_rows.get('rows'))}) / val ({_n(va_rows.get('rows'))}), grouped by
   near-duplicate cluster so no template straddles the two; **{holdout_src} ({_n(te.get('rows'))} rows, {te_rate}) is held out
   entirely** as the unseen binary test set and never touched by the tokenizer, pretraining or fine-tuning ({_n(decon)} pool rows that
   near-duplicate {holdout_src} templates were removed).{te3_md} Counts come from `data/processed/splits_manifest.json`.
   {loss_md}
   Fine-tune dials (from the checkpoint's config.json):
{ft_md}

## Metrics

### {holdout_src} binary holdout ({UNWANTED_NAME})

{metrics_md}
{metrics3_section}
## Intended use

A **defensive triage aid for small-business inboxes**: rank or flag incoming mail for a human to review, surface a
probability and the attention-highlighted tokens that drove it, and shorten the time between a phishing wave
landing and someone noticing. It is a classroom/final-project model and is not a substitute for a mail gateway.

## Limitations

- **Era**: the labelled data is 1998-2008 spam-track mail (TREC, CEAS, SpamAssassin, Enron){', plus Nazario phishing (1995-2022) and Nigerian-fraud mail' if k == 3 else ''}.
  Modern lures (OAuth consent phishing, QR codes, MFA fatigue, SaaS-brand impersonation) are under-represented; expect lower recall on them.
- **Labels follow corpus provenance**: {LABEL_PROVENANCE if k == 3 else 'the public corpora label "unwanted" mail, not phishing specifically; the credential-lure slice in the metrics is the closest proxy for phishing-proper recall.'}
  {'A phishing-vs-spam confusion therefore partly measures corpus style, and the credential-lure slice remains the closest proxy for phishing-proper recall.' if k == 3 else ''}
- **Length confound**: legitimate emails in these corpora average ~2x the tokens of positives. Inputs are truncated to
  {mcfg.max_seq_len} tokens, and the error analysis checks whether errors follow length, but a short legitimate note can
  still look suspicious.
- **English only**; byte-level BPE will tokenize other scripts but the model has not seen them.
- **Headers are not used** (TREC rewrote them); the model sees subject and body text only. Sender reputation,
  SPF/DKIM and URL reputation are out of scope and should be combined with this score in any real deployment.
- Attention highlights are a signal of what the model attended to, not an explanation of why it decided.

## How to load

`model.py` from this repository is the only code dependency (plain PyTorch).

```python
import json, torch
from safetensors.torch import load_file
from tokenizers import Tokenizer
from model import EncoderConfig, EncoderForClassification   # model.py, shipped with the weights

cfg = json.load(open("config.json"))
model = EncoderForClassification(EncoderConfig.from_dict(cfg["model"]))   # num_labels = {k}
model.load_state_dict(load_file("model.safetensors"), strict=True)   # bf16 weights cast into the fp32 module
model.eval()

tok = Tokenizer.from_file("tokenizer.json")
tok.enable_truncation(max_length=cfg["max_seq_len"])
enc = tok.encode("Your account has been suspended\\n\\nClick here to verify your identity within 24 hours.")
ids = torch.tensor([enc.ids])
mask = torch.ones_like(ids, dtype=torch.bool)
with torch.inference_mode():
    probs = model(ids, mask).float().softmax(-1)[0]
{rollup_py}
```

On a GPU wrap the forward in `torch.autocast("cuda", dtype=torch.bfloat16)`. {latency_md}

## Files

- `model.safetensors` — {dtype} weights, metadata `format=pt, dtype={dtype}, architecture=EncoderForClassification, config=<json>, labels=<json>`
- `config.json` — {files_cfg}
- `tokenizer.json` — byte-level BPE, NFKC, case preserved, specials `[PAD]=0 [UNK]=1 [CLS]=2 [SEP]=3 [MASK]=4`
- `README.md` — this card

## Provenance

Built for the Skillspire AI & Machine Learning course final project (BITS, 2026). Data decisions, measured VRAM /
throughput / latency and every challenge hit along the way are recorded in the project's RUN_LOG.md.
"""
    card = ModelCard(f"---\n{data.to_yaml()}\n---\n{body}")
    return card.content


# ------------------------------------------------------------------------------- app
def copy_to_app(out_dir: Path, app_dir: Path, src_model_py: Path) -> list[Path]:
    """model.py byte-for-byte (hash asserted) plus the weight files. app.py / requirements.txt
    belong to the app owner and are never touched."""
    app_dir.mkdir(parents=True, exist_ok=True)
    written: list[Path] = []
    dst = app_dir / "model.py"
    shutil.copyfile(src_model_py, dst)
    h_src, h_dst = common.sha1_hex(src_model_py.read_bytes().decode("utf-8")), common.sha1_hex(dst.read_bytes().decode("utf-8"))
    if h_src != h_dst or src_model_py.read_bytes() != dst.read_bytes():
        raise RuntimeError(f"{dst} is not byte-identical to {src_model_py}")
    LOG.info("copied %s -> %s (sha1 %s)", src_model_py, dst, h_src)
    written.append(dst)
    for name in WEIGHT_FILES:
        if name in PROTECTED_APP_FILES:  # defensive: never overwrite another owner's files
            continue
        shutil.copyfile(out_dir / name, app_dir / name)
        written.append(app_dir / name)
    return written


def tree(paths: Sequence[Path], root: Path) -> str:
    lines = [f"{root}/"]
    for p in sorted(paths):
        size = p.stat().st_size
        unit = f"{size / 1e6:,.1f} MB" if size >= 1e6 else f"{size / 1e3:,.1f} kB"
        lines.append(f"  {p.name:<20} {unit:>12}")
    return "\n".join(lines)


def check_metrics_match(m: dict | None, path: Path, what: str, ckpt: Path, k: int, expect_split: str, allow: bool) -> list[str]:
    """Refuse to quote another run's numbers on this checkpoint's card; returns the list of mismatches.

    evaluate.py writes every metrics file under a fixed name in reports/, so evaluating the
    cross_entropy / weighted / focal runs in sequence leaves the LAST run's numbers there, and an
    export of the best run with the default paths would put those numbers (and, since the card
    wording follows num_labels, a contradictory head description) on this model's card with no
    warning. A metrics file is accepted only when its ``checkpoint`` resolves to the exported one,
    its ``num_labels`` equals the head size and its ``split`` is the one the table describes
    (``test`` for the TREC-06 binary table, ``test3`` for the three-way table). A file without a
    ``checkpoint`` field cannot be attributed and counts as a mismatch. With
    ``--allow-metrics-mismatch`` the file is still used, the mismatch is logged, and
    ``metrics_checkpoint_matches: false`` is recorded in config.json / export_manifest.json."""
    if m is None:
        return []
    problems: list[str] = []
    mc = m.get("checkpoint")
    if mc is None or Path(str(mc)).resolve() != ckpt:
        problems.append(f"checkpoint {mc!r} != {ckpt}")
    mk = m.get("num_labels")
    if mk is None or int(mk) != k:
        problems.append(f"num_labels {mk!r} != {k}")
    if m.get("split") != expect_split:
        problems.append(f"split {m.get('split')!r} != {expect_split!r}")
    if problems:
        msg = f"{what}: {path} does not belong to the exported checkpoint: " + "; ".join(problems)
        if not allow:
            raise ValueError(msg + " (re-run src/evaluate.py on this checkpoint, point --metrics-json/--metrics3-json at its "
                                   "metrics files, or pass --allow-metrics-mismatch to export anyway with the mismatch recorded)")
        LOG.warning("%s; used anyway (--allow-metrics-mismatch), recorded as metrics_checkpoint_matches=false", msg)
    return problems


def _read_metrics(path: Path, what: str) -> dict | None:
    if not path.exists():
        LOG.info("%s: %s absent", what, path)
        return None
    try:
        m = common.read_json(path)
    except (OSError, json.JSONDecodeError) as e:
        LOG.warning("%s: could not read %s: %s", what, path, e)
        return None
    LOG.info("%s: %s (%s split, %s%s)", what, path, m.get("split"), m.get("timestamp"), ", DEV RUN" if m.get("dev_run") else "")
    return m


# -------------------------------------------------------------------------------- main
def run(args: argparse.Namespace) -> int:
    cfg = common.load_config(args.config)
    global LOG
    LOG = common.setup_logging("export", cfg["paths"]["logs"])
    common.set_seed(int(cfg["seed"]))
    ckpt = Path(args.checkpoint).resolve()
    run_name = ckpt.parent.name if ckpt.name in ("best", "last") else ckpt.name
    out_dir = Path(args.out_dir) if args.out_dir else common.project_root() / "model_export" / run_name
    app_dir = Path(args.app_dir) if args.app_dir else Path(cfg["paths"]["app_dir"])
    out_dir.mkdir(parents=True, exist_ok=True)
    LOG.info("checkpoint=%s run_name=%s out_dir=%s app_dir=%s dtype=%s", ckpt, run_name, out_dir, app_dir, args.dtype)

    model, mcfg, ckpt_cfg = load_classifier(ckpt)
    k = mcfg.num_labels
    LOG.info("labels: %s; binary roll-up: %s", labels_for(k), binary_rollup_for(k))
    weights = export_weights(model, mcfg, args.dtype, out_dir / "model.safetensors")

    tok_src = Path(cfg["paths"]["tokenizer"])
    shutil.copyfile(tok_src, out_dir / "tokenizer.json")
    LOG.info("copied %s (%.1f kB)", tok_src, tok_src.stat().st_size / 1e3)

    reports = Path(cfg["paths"]["reports"])
    metrics_path = Path(args.metrics_json) if args.metrics_json else reports / "metrics.json"
    metrics = _read_metrics(metrics_path, "binary metrics (TREC-06)")
    mismatch = check_metrics_match(metrics, metrics_path, "binary metrics (TREC-06)", ckpt, k, "test", args.allow_metrics_mismatch)
    if args.metrics3_json:
        metrics3_path = Path(args.metrics3_json)
    else:
        m3cfg = (cfg.get("eval") or {}).get("model_card_metrics3")
        metrics3_path = (common.project_root() / m3cfg) if m3cfg else reports / "metrics_test3.json"
    metrics3 = _read_metrics(metrics3_path, "three-way metrics (test_holdout3)") if k == 3 else None
    mismatch3 = check_metrics_match(metrics3, metrics3_path, "three-way metrics (test_holdout3)", ckpt, k, "test3", args.allow_metrics_mismatch)
    if k == 2:
        LOG.info("2-class checkpoint: three-way metrics table skipped")
    elif metrics3 and not (metrics3.get("threeway") or {}).get("evaluated"):
        LOG.warning("%s has no evaluated three-way block (%s); card table left as 'not yet evaluated'", metrics3_path,
                    (metrics3.get("threeway") or {}).get("reason"))
    training, training_path = find_pretrain_summary(ckpt_cfg, args.pretrain_config)
    LOG.info("fine-tune init: %s; pretraining summary: %s", finetune_init(ckpt_cfg), training_path or ("none (scratch init)" if is_scratch_init(ckpt_cfg) else "absent"))
    manifest = read_splits_manifest(Path(cfg["paths"]["data_processed"]))
    LOG.info("splits manifest: %s", "loaded" if manifest else "absent (card quotes n/a for dataset counts)")

    export_cfg = build_config(mcfg, ckpt, ckpt_cfg, args.dtype, metrics, str(metrics_path) if metrics else None,
                              metrics3, str(metrics3_path) if metrics3 else None, training, training_path,
                              metrics_mismatch=mismatch, metrics3_mismatch=mismatch3)
    common.write_json(export_cfg, out_dir / "config.json")
    card = model_card(mcfg, cfg, ckpt_cfg, metrics, metrics3, training, args.dtype, weights["params"], weights["bytes"], cfg["hf"]["model_repo"], manifest,
                      metrics_mismatch=mismatch, metrics3_mismatch=mismatch3)
    (out_dir / "README.md").write_text(card, encoding="utf-8")
    LOG.info("wrote %s (%d chars)", out_dir / "README.md", len(card))

    app_written = copy_to_app(out_dir, app_dir, Path(__file__).resolve().parent / "model.py")

    summary = {
        "timestamp": datetime.now().isoformat(timespec="seconds"), "run_name": run_name, "checkpoint": str(ckpt),
        "out_dir": str(out_dir), "app_dir": str(app_dir), "dtype": args.dtype, "weights": weights,
        "num_labels": k, "labels": labels_for(k), "binary_rollup": binary_rollup_for(k),
        "files": {p.name: p.stat().st_size for p in out_dir.iterdir()},
        "app_files": {p.name: p.stat().st_size for p in app_written},
        "model_py_sha1": common.sha1_hex((Path(__file__).resolve().parent / "model.py").read_text(encoding="utf-8")),
        "metrics_included": metrics is not None, "metrics_threeway_included": bool(metrics3 and export_cfg["metrics_threeway"]),
        "metrics_checkpoint_matches": export_cfg["metrics_checkpoint_matches"], "metrics_mismatch": export_cfg["metrics_mismatch"],
        "metrics_threeway_checkpoint_matches": export_cfg["metrics_threeway_checkpoint_matches"], "metrics_threeway_mismatch": export_cfg["metrics_threeway_mismatch"],
        "training_included": training is not None,
        "finetune_init": finetune_init(ckpt_cfg), "manifest_included": manifest is not None,
    }
    common.write_json(summary, out_dir / "export_manifest.json")
    print("\n" + tree(list(out_dir.iterdir()), out_dir))
    print(tree(app_written, app_dir))
    print(f"\nmodel.py sha1 {summary['model_py_sha1']} (src == app, asserted); weights {weights['bytes'] / 1e6:,.1f} MB {args.dtype}, "
          f"{weights['params']:,} params; num_labels {k} {labels_for(k)}; init {finetune_init(ckpt_cfg)}; "
          f"binary metrics {'included' if metrics else 'absent'}; three-way metrics "
          f"{'included' if summary['metrics_threeway_included'] else ('n/a (2-class)' if k == 2 else 'absent')}; "
          f"pretraining summary {'included' if training else ('n/a (scratch)' if is_scratch_init(ckpt_cfg) else 'absent')}")
    return 0


def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--config", default="config.yaml")
    sub = ap.add_subparsers(dest="cmd", required=True)
    r = sub.add_parser("run", help="export a fine-tune best/ dir to model_export/<run>/ and app/")
    r.add_argument("--checkpoint", required=True, help="fine-tune best/ dir with model.safetensors + config.json")
    r.add_argument("--out-dir", default=None, help="default model_export/<run_name>")
    r.add_argument("--app-dir", default=None, help="default config.paths.app_dir")
    r.add_argument("--dtype", choices=sorted(DTYPES), default="bf16")
    r.add_argument("--metrics-json", default=None, help="binary TREC-06 metrics; default <reports>/metrics.json")
    r.add_argument("--metrics3-json", default=None, help="three-way holdout metrics; default config eval.model_card_metrics3 or <reports>/metrics_test3.json")
    r.add_argument("--pretrain-config", default=None,
                   help="pretrain final/config.json (default: the checkpoint's pretrained:<path> descriptor; none for init=scratch)")
    r.add_argument("--allow-metrics-mismatch", action="store_true",
                   help="export even if a metrics file names another checkpoint / num_labels / split (logged; recorded as "
                        "metrics_checkpoint_matches=false in config.json and export_manifest.json). Default: refuse.")
    return ap.parse_args(argv)


def main(argv: Sequence[str] | None = None) -> int:
    return run(parse_args(argv))


if __name__ == "__main__":
    raise SystemExit(main())