File size: 79,162 Bytes
439e7b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
07cdb81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
# RUN_LOG β€” BITS Phishing Detector

Running record of what was run, with what settings, what happened, and what was fixed.
Newest entries at the bottom. This becomes the "challenges" section of the presentation.

---

## 0. 2026-10-07 β€” Plan confirmation, environment, pins

**Machine.** Fedora 44 KDE, RTX 3090 Ti 24 GB (sm_86), driver 615.71.09 (CUDA 13.4), Ryzen 9 5950X,
40 GB RAM, 741 GB free. System Python 3.14.7, uv 0.12.5. ~1.9 GB VRAM already used by the desktop.

**Decision: Python 3.14 stays.** Checked live: torch 2.14.1 and every other pinned package ship
cp314 (or abi3 / pure) Linux wheels; torch.compile is allowed on 3.14 (only 3.15 raises); triton 3.8.0
requires <3.15. No Python update needed. (uv also downloaded a managed CPython 3.12.14 as a side
effect of dry-resolving the Space requirements; harmless, kept for testing the Space stack locally.)

**Decision: torch 2.14.1, CUDA 13.0 build.** The plain PyPI Linux wheel *is* the cu130 build
(cuda-toolkit 13.0.3, cuDNN 9.24, NCCL 2.30.7). Driver 615 (CUDA 13.4) runs it by backward compatibility.
Install with `uv pip install --torch-backend=cu130`; never `--torch-backend=auto` (uv 0.12.5 would pick
cu132 on this driver).

**Constraint found: ZeroGPU pins the Space stack.** ZeroGPU supports torch 2.8.0 – 2.13.0 and Python
3.12.12 / 3.10.13 only, and does not support torch.compile. So the Space uses torch 2.13.0 on Python
3.12.12 while training uses 2.14.1 on 3.14. Weights are safetensors, so this is fine. Also: a private
model repo cannot be preloaded by the Space; weights download at startup with an HF_TOKEN secret.

**Constraint found: huggingface_hub must stay on 1.x.** tokenizers 0.23.2 requires huggingface-hub<2.0,
so huggingface_hub 1.33.0 (ships the `hf` CLI) everywhere.

**Data problems found (before downloading anything):**
- `it4lia/PhishingEmailCuratedDatasets_Cleaned` has **no body text** (subjects/headers + 53 numeric
  features). Unusable for a text model. Excluded.
- `zefang-liu/phishing-email-dataset` is a lowercased re-packaging already 91.9% contained in
  `puyang2025/seven-phishing-email-datasets` (one 17 MB row, 533 literal "empty" rows, and 20% of its
  "Safe" rows contain the token "enron" vs 0% of phishing: a shortcut). Excluded.
- The primary set's own train/test split leaks: 5.1% of test rows are exact duplicates of train rows,
  ~40% have a near-duplicate. We build our own split.
- Two sub-sources are Enron-derived: 89% of TREC-05 ham and 86% of Enron-Spam ham appear in the
  `corbt/enron-emails` pretraining corpus. Neither can be the test set.
- `corbt/enron-emails` is 52.7% exact duplicates (517,401 -> 244,496 distinct); ~117M tokens remain,
  about 230k packed 512-token sequences per epoch, so one epoch is ~1,144 optimizer steps at batch 256.
- No dataset separates "spam" from "phishing". Every label-1 row comes from a spam-track corpus.
  Mapping spam->0 would leave ~0 positives. **Proposed:** positive class = "phishing/spam (unwanted)",
  plus a credential-lure slice of the test set reported separately. PENDING Nathan's confirmation.

**Decision: hold out TREC-06 entirely as the test set** (16,259 rows after dropping 141 near-empty
rows; 12,394 legitimate / 3,865 positive = 23.8%). It is Enron-clean (5 rows mention "enron", 0
enron.com addresses). Caveats: ~47% of its ham is academic mailing-list traffic; its spam headers were
rewritten by TREC, so the model input is subject + body only. 1,766 training rows that near-duplicate
TREC-06 templates get removed from the training pool.

**Model math.** 24L / d1024 / 16 heads / FFN 4096 / vocab 32768 / seq 512 = 337,476,608 params
(pretraining) / 337,443,842 (classifier). AdamW state 5.0 GiB. Micro-batch 16 without checkpointing
is predicted at ~16.6 GiB total (7.4 GiB headroom) -> inside the 14–18 GB budget. Predicted
throughput 12k–18k tokens/s (30–45% MFU of 80 TFLOPS bf16) -> 2.3–3.5 h per 150M-token epoch.
Inference: ~14 ms per 512-token email on the 3090 Ti; ~0.3 s on the CPU in fp32 (the instructor's
0.074 s/sample benchmark is met on GPU, not on CPU at full length).

**Status.** Project scaffolded, venv created on 3.14.7, pinned install started (logs/install.log).
Next: verify GPU from torch, then data_prep -> tokenizer -> model -> smoke test -> pause for numbers.

**Step 2 result (22:45).** Pinned install succeeded (180 packages, all binary wheels; venv 6.1 GB).
From inside the venv: torch 2.14.1+cu130, CUDA 13.0 build, cuDNN 9.24; device NVIDIA GeForce RTX
3090 Ti, sm_86, 23.60 GiB, bf16 supported; SDPA Flash / memory-efficient / math backends all run
(Flash with no mask, memory-efficient with a bool padding mask); bf16 4096^3 matmul measured
63.9 TFLOPS (spec ~80); torch.compile works on Python 3.14 (triton built its driver stub with a
harmless _POSIX_C_SOURCE warning); free VRAM at idle with the desktop up: 21.5 GiB. All pinned
libraries import; config.yaml parses. `hf` CLI present, not logged in yet (Nathan to run `hf auth login`).

**Agent policy (per Nathan, 21:14).** Model and effort are set per agent from here on: mechanical
lookups -> Sonnet/low; judgment-heavy research -> Opus or Fable/medium-high; math, core code and
adversarial verification -> Fable/high. The step-1 research run inherited Fable for all 7 agents.

## 1. 2026-10-07 23:41 β€” Data build (data_prep.py build + check)

**Run.** `.venv/bin/python src/data_prep.py build` then `check`. 2 min 20 s wall, peak RSS 6.9 GB,
HF cache 1.5 GB (puyang2025 @ b7f3f8d9, corbt @ cfc06c75). Every asserted leakage check is 0 in
both the build-time recount and the independent `check` over the written files.

| split | rows | positives | base rate | sources |
|---|---|---|---|---|
| finetune_train | 157,113 | 75,675 | 48.2% | TREC-05 46,386 / TREC-07 47,233 / CEAS-08 34,178 / Enron 24,223 / Assassin 5,093 |
| finetune_val | 17,643 | 8,642 | 49.0% | same five, grouped by near-dup cluster so no template straddles train/val |
| test_holdout (TREC-06) | 16,302 | 3,894 | 23.9% | never touched by tokenizer, pretraining, or fine-tuning |
| pretrain (MLM) | 226,273 docs | - | - | corbt/enron-emails after dedup + quote stripping; 408M chars |

**What was removed and why.** 715 labelled rows with <20 alphanumerics (91% of them TREC-05/TREC-06
garbage spam rows). Ling excluded (2,859 academic rows). 18 rows in 8 groups where the identical body
carries both labels (Enron ham vs TREC-05 spam newsletters) β€” dropped as label noise. 6,715 exact
duplicates in the pool. **1,652 training rows removed as near-duplicates of TREC-06 templates**
(TREC-05 spam 863, Enron spam 456, TREC-07 spam 292): these are spam templates shared across the
TREC years; keeping them would let the model memorise test-set templates. corbt: 517,401 rows ->
272,905 exact duplicates removed (52.7%), 14,027 too short after quote stripping, 4,004 matching a
val email removed, 85 near-duplicates of holdout emails removed, 35,172 overlapping fine-tune TRAIN
emails kept (allowed: those are Enron ham). Quote stripping cut 21,832 bodies (kept 89% of all
characters; the >=200-alnum guard left 70,907 marked bodies intact).

**Deviation from the research estimate.** Holdout is 16,302 rows (research: 16,259) because the
<20-alnum filter is applied to subject+body rather than body alone: 43 TREC-06 rows have a real
subject and a near-empty body, which is a legitimate model input. Pretraining corpus is 408M chars
(research: ~466M) because quote stripping and the val screen remove more than the estimate assumed.

**Problem noted.** The val split has 5,336 near-duplicate links into the pretraining corpus
(883 val emails, Enron boilerplate). Counted, not removed: val is only used for model selection
and the holdout is the real gate. Exact val matches (4,004) were removed from pretraining.

## 2. 2026-10-07 23:43 β€” Tokenizer (tokenizer.py train + stats)

**Run.** Byte-level BPE, NFKC only (case preserved on purpose: ALL-CAPS is a spam signal), vocab 32,768,
min_frequency 2, specials [PAD]=0 [UNK]=1 [CLS]=2 [SEP]=3 [MASK]=4. Trained on pretrain.parquet
(226,273 docs, 408M chars) + finetune_train.parquet (157,113 docs, 278M chars) only; val and test are
refused by name and an exact-hash tripwire against the holdout found 0 collisions. Training took 28 s
(49 s wall incl. the tripwire), 3.6 GB RSS. Saved data/processed/tokenizer.json.

| split | rows | mean tok | median | p95 | chars/tok | % > 512 | mean tok legit / positive |
|---|---:|---:|---:|---:|---:|---:|---|
| pretrain (20k sample) | 20,000 | 476 | 198 | 1,560 | 3.76 | 20.5% | - |
| finetune_train | 157,113 | 487 | 227 | 1,540 | 3.63 | 24.1% | 621 / 343 |
| finetune_val | 17,643 | 454 | 200 | 1,421 | 3.58 | 22.3% | 594 / 307 |
| test_holdout | 16,302 | 709 | 244 | 1,254 | 3.43 | 21.9% | 842 / 283 |

**Observations.** [UNK] rate is 0 by construction (full 256-byte alphabet). Round trips are exact
except for NFKC folding (full-width "Microsoft" -> "Microsoft", a deliberate anti-evasion
property). About 22% of emails are longer than 512 tokens and get truncated; the pretraining corpus
is ~108M tokens at 3.76 chars/token. **Length is a confound:** legitimate emails average ~2x the
tokens of positives in every split (holdout 842 vs 283). Truncation at 512 blunts this, and the
error analysis must check whether the classifier leans on length. Outliers exist (one 886,635-token
email in train) and are harmless under truncation.

## 3. 2026-10-07 23:46 β€” Smoke test (smoke_test.py, real packed text, reports/smoke_test.json)

**Model.** 337,476,608 params (MLM, tied head) / 337,443,842 (classifier) β€” exactly the research
figure. 3,000 real pretraining docs -> 1,592,926 tokens -> 3,111 packed 512-token sequences; in-code
no-leak check: 0 of 3,000 docs match a holdout email.

**VRAM (bf16 autocast, fused AdamW, real gradient-accumulation pattern, desktop using 2.08 GiB):**

| micro-batch | checkpointing | peak reserved | total device used | tokens/s |
|---|---|---|---|---|
| 8 | off | 9.71 GiB | 11.85 GiB | 21,493 |
| 12 | off | 11.78 GiB | 13.92 GiB | 23,487 |
| **16** | off | **13.65 GiB** | 15.80 GiB | **23,642** |
| 24 | off | 17.65 GiB | 19.80 GiB | 25,227 |
| 24 | on | 8.06 GiB | 10.21 GiB | 19,156 |

The finder picked 24 (largest in the 14–18 GiB band) but total device use then sits at 19.8 GiB,
0.2 GiB under the 20 GiB "never exceed" line. Micro-batch 16 is 6% slower, gives an exact effective
batch of 256 (24 x 11 = 264), and leaves ~8 GiB of headroom for desktop spikes. **Recommendation:
micro-batch 16 x 16 accumulation for the long run.** Checkpointing is not needed for memory and
costs ~25% throughput; keep it off.

**Throughput / ETA.** 25,187 tok/s at B=24 (23,642 at B=16). Measured 0.2595 tokens/char on the
packed sample -> the 408M-char corpus is ~105.8M tokens/epoch = ~807 optimizer steps at 256 x 512.
About 1.2–1.3 h per epoch; 3 epochs ~3.75 h, 6 epochs ~7.5 h. Prediction was 2.3–3.5 h/epoch at
12k–18k tok/s: the real machine is ~1.5x faster than the 30–45% MFU assumption (25k tok/s β‰ˆ 63% MFU
of the 80 TFLOPS peak).

**SDPA.** Flash runs on the mask-free packed path; memory-efficient runs with the bool padding mask;
Flash correctly refuses a mask (no silent math fallback).

**Convergence.** 30 steps at B=24, LR 1e-4, 10-step warmup, real text: loss 10.58 -> 7.67
(ln V = 10.40). The pipeline learns.

**Inference latency (batch 1, 512 tokens).** GPU bf16: 10.7 ms (0.14x the instructor's 74 ms).
CPU fp32, 16 threads: 699 ms at 512 tokens, 319 ms at 128 tokens (9.4x / 4.3x the benchmark).
The research's 336 ms / 79 ms CPU estimate was 2–4x too optimistic; CPU inference is per-op-overhead
bound, so truncation barely helps. **The 74 ms benchmark is met on GPU only -> the Space must be
ZeroGPU, not CPU-basic.**

**Challenges found by the reviewers before this run (fixed):** the first finder measured peak
VRAM with a single fwd/bwd/opt step and under-reported the real accumulation peak by one fp32
gradient copy (1.26 GiB); the first MLM-corpus decontamination used exact keys only and missed
near-duplicate (quoted/forwarded, 419-template) variants of holdout emails; floor-division
accumulation would have reported effective batch 256 while training at 240 for B=24;
safetensors refuses the tied state_dict unless the alias key is dropped (helper added);
the tied decoder leaks gradient into the [PAD] embedding row (post-accumulate hook added).

## 4. 2026-10-08 00:40 β€” Step-4 code build (pretrain / finetune / evaluate / export / app / upload / synthetic)

**What was built** (each file by one agent, then an independent adversarial reviewer who ran it, then
a fix pass): `src/pretrain.py` (resumable MLM trainer, 1,019 lines), `src/finetune.py` (classifier
trainer with the instructor's dials as flags; experiments.jsonl record per run), `src/evaluate.py`
(the only script allowed to open the TREC-06 holdout; metrics, latency, plots, error analysis),
`src/export.py` (bf16 safetensors + model card + byte-identical app/model.py), `app/app.py` +
`requirements.txt` + `README.md` (Gradio 6 / ZeroGPU app with saliency and rule-based signals),
`scripts/upload_to_hf.py` (dry-run by default, `--yes` required), `src/make_synthetic.py`
(2,000 template-generated rows, 8 positive + 8 legitimate business categories, source=synthetic).

**Verified by running.** pretrain: fresh run -> kill -> `--resume` reproduces the exact data order
(window indices checked against the seeded permutation), optimizer step counter and LR; SIGINT saves
a checkpoint and exits 0; SIGKILL between checkpoints is recovered on resume (orphan metrics rows
dropped); a forced OOM saves and exits 3; a 5 GiB test cap trips the VRAM guard; `--max-hours` stops
cleanly; resume refuses a changed plan or model shape. The full token cache was built as a side
effect: 107,498,500 train tokens + 1,021,888 val tokens (0.266 tok/char), 42.8 s. finetune: resume
reproduces batch order; `--n-layers 4` loads only the first four pretrained layers; focal(gamma=0)
== cross-entropy; the holdout parquet is never opened (inotify-verified). 14 agents, 451 tool calls.

**Bugs the reviewers caught and the fixers closed (for the challenges slide):** a `--lr` override on
`--resume` was silently discarded by the restored optimizer state but recorded in the experiment log
as if applied; resuming a finished fine-tune run trained one extra step at lr 0; changing frozen
layers on resume crashed with a raw torch error; metrics CSVs kept orphan rows after a hard crash;
evaluate/export and app each had 2–3 majors (details in docs/research/ and the agents' reports).

**Changes I made myself after reading pretrain.py:** (1) the OOM handler now covers the optimizer
step and the eval pass too, not just the forward/backward loop, so a desktop VRAM spike during the
500-step eval cannot lose 45 minutes of work; (2) a resume with nothing left to do no longer
overwrites run_summary.json; (3) `--resume` compares `--limit-docs` with the checkpoint before
tokenising anything; (4) **every packed window now starts with [CLS]** (511 stream tokens follow),
as RoBERTa prepends `<s>`: the reviewer found 0 occurrences of [CLS] in the 107M-token stream,
which would have left the classifier's pooled position untrained until fine-tuning.

**Known gaps going into the long run:** the real 337M fine-tune, evaluate and export have only run on
tiny dev models/subsets; `--synthetic on` path untested end to end; ZeroGPU runtime behaviour
untested until the first Space build. Dev checkpoints from the self-tests sit in
checkpoints/{pretrain,finetune}/dev* (~15 GB) pending Nathan's OK to delete.

## 5. 2026-10-08 12:28 β€” Pretraining run `large-24L` launched (Nathan's settings)

**Decisions (Nathan, 2026-10-08).** Micro-batch 24 (17.65 GiB reserved measured; total GPU use ~20 GB
with the desktop). 12 epochs: ~14 h at 25k tok/s, 9,552 optimizer steps, warmup 573 steps, cosine to
10% of 1.5e-4. Rationale: dynamic masking makes every pass different and data-constrained scaling
results show repeated passes keep helping with diminishing returns; val MLM loss (64 fixed windows
from the held-out last 1% of documents) is evaluated every 500 steps and the run is stopped if it
rises. Positive class is named **"Spam/Phishing"** in the app, metrics and model card (the sources do
not separate spam from phishing). Hugging Face login confirmed (BuildITSystems).

**Launch.** `EPOCHS=12 MICRO_BATCH=24 scripts/launch_pretrain.sh` -> tmux session `phish-pretrain`,
stdout in logs/pretrain_large-24L_20261008-122800.out. Trainer: 210,368 packed windows of
[CLS] + 511 tokens (107.7M tokens/epoch), 796 steps/epoch, micro-batch 24 x 11 accumulation =
**effective batch 264** (256 is not divisible by 24; the realised batch is used in every number),
checkpoints every 500 steps (keep 3, ~3.8 GB each) + final/. Token cache reused (0 s). GPU at
launch: 20.3 GB used, 98% util, 365 W.

## 6. 2026-10-08 ~13:00 β€” Scope change: three-way labels (legitimate / phishing / spam)

**Nathan's decision.** The classifier should output legitimate, phishing, or spam. Our current positives
cannot be split (TREC-05/07 and CEAS-08 labelled everything unwanted as "spam"), so phishing-labelled text
from separate provenance is being added: the Champa et al. raw corpus mirror on the Hub
(`kudzaiprichard/aura-phishing-email-corpus`, CC-BY-4.0, byte-identical to Zenodo 8339691) with
`Nazario.csv` (1,565 phishing, 1995–2022) and `Nigerian_Fraud.csv` (3,332 advance-fee fraud), plus our
synthetic BITS-style lures. Spam class from provenance: SpamAssassin, Enron-Spam and (if included) Ling
spam folders. The 250k-row `locuoco/the-biggest-spam-ham-phish-email-dataset-300000` (labels ham/phish/
spam, provenance undocumented) is inspected first and used only if its labels agree with known provenance
and it adds rows we do not have, after deduplication against every split and the TREC-06 holdout.

**Design.** One encoder, one 3-class head, partial-label loss: rows with clean provenance train with
3-class cross-entropy; the ~70k TREC/CEAS positives train on the probability mass of {phishing, spam}
("unwanted") so they still shape the legitimate-vs-unwanted decision. Binary roll-up P(unwanted) =
P(phishing)+P(spam) keeps TREC-06 as the clean binary test; a new `test_holdout3` slice of clean-
provenance rows (drawn from sources never seen by the tokenizer or the pretraining corpus) gives the
3x3 confusion matrix. `num_labels` lives under `finetune:` in config.yaml, not under `model:`, because
the live pretraining run refuses to resume if its model snapshot changes.

## 7. 2026-10-08 13:21 β€” Three-way data build (build3) and the 300k-set verdict

**300k set: rejected after inspection** (`docs/research/bigshp_research.md`). `locuoco/the-biggest-spam-ham-phish-
email-dataset-300000` is 365,448 rows, not deduplicated (51.5% within-file duplicates), and is a union of
Kaggle re-packagings of corpora we already hold: 97.6% of its ham, 92.5% of its phish and 90.2% of its
spam rows match our text. Its ham/spam labels agree with our provenance 99.9%/99.8% because they are our
labels copied back; its **phish label agrees only 60.5%**: 14,660 "phish" rows are SpamAssassin/Enron-Spam
spam-folder mail, 17,714 are TREC/CEAS spam-track rows, and only 4,784 are confirmed phishing provenance
(Nazario/Nigerian, which we take raw from the Champa mirror). **36,020 rows (9.9%) are copies of our frozen
TREC-06 test set.** Every "phish" row went through a stopword-stripping pipeline that ham/spam rows did
not, a preprocessing artifact that is a near-perfect label proxy and would not transfer to real inbound
mail. Decision: not one row ingested; `data.threeway.bigshp.enabled: false` with the verdict recorded.

**Champa mirror ingested** (`kudzaiprichard/aura-phishing-email-corpus`, CC-BY-4.0): Nazario 1,565 rows ->
1,507 kept; Nigerian_Fraud 3,332 -> 2,504 kept. Drops worth knowing: 235 Nigerian-fraud rows are near-
duplicates of TREC-06 spam (419 letters recur across corpora) and 13 match holdout keys exactly: all
removed so the TREC-06 test stays clean; 424 more were dropped because their clusters touched both train
and val.

**Three-way splits (label3: 0 legit / 1 phishing / 2 spam / -1 unwanted-unsplit):**

| split | rows | legit | phishing | spam | unwanted (TREC/CEAS positives) |
|---|---|---|---|---|---|
| finetune_train | 160,263 | 81,438 | 3,150 | 12,870 | 62,805 |
| finetune_val | 15,284 | 7,002 | 340 | 763 | 7,179 |
| test_holdout (TREC-06, unchanged rows) | 16,302 | 12,408 | - | - | 3,894 |
| **test_holdout3 (new, clean provenance)** | 3,220 | 1,999 | 521 | 700 | - |

test_holdout3 legit = CEAS-08 736 + TREC-07 1,099 + Assassin 164 (never Enron: no pretraining overlap);
spam = Enron-Spam 628 + Assassin 72; phishing = Nazario 222 + Nigerian 299. All of it came from
finetune_val (never seen by the tokenizer) or the new sources (never seen by anything). finetune_val
shrank from 17,643 to 15,284 rows, so new val F1 values are not strictly comparable with the binary runs.
Leakage: every asserted check is 0 in `check3` and the binary `check` still passes on the rewritten files.
Existing rows in train/test_holdout are byte-identical to the binary build (data/processed/binary_snapshot).

**Honest caveat.** Only 3,150 fully-labelled phishing training rows (plus 1,000 synthetic lures) carry the
phishing-vs-spam distinction; 62,805 positives are trained as "unwanted" only. Expect the three-way split
to be the weaker decision; the binary roll-up on TREC-06 stays the headline.

## 8. 2026-10-08 13:55 β€” Fine-tune batching moved into torch.utils.data.DataLoader

The instructor's written spec requires PyTorch's DataLoader for batching and shuffling; finetune.py had
its own numpy sampler. Every batch now goes through `torch.utils.data.DataLoader` (`make_loader`): a
row-index Dataset, the seeded length-bucketed batch order as `batch_sampler`, a collate function that
pads each micro-batch to its own longest row. Verified on the CPU fixture: losses and metrics identical
to the previous code at every step. One problem found on the way: a DataLoader draws its worker base
seed from the global torch RNG when iterated, which shifted the dropout stream; passing an explicit
`generator` removed the side effect (first run differed at step 1, 1.1093 vs 1.1125; identical after).

## 9. 2026-10-08 14:15 β€” Dev checkpoints removed; fine-tuning queue armed

Nathan approved both. Deleted the agents' self-test checkpoints (checkpoints/pretrain/dev,
checkpoints/finetune/dev, checkpoints/finetune/dev-focal; 15.3 GB) and their two dev lines in
reports/experiments.jsonl (backup in the session scratchpad). `scripts/run_experiments.sh` is running in
tmux session `phish-finetune`, waiting for checkpoints/pretrain/large-24L/final/model.safetensors; it then
runs ft-ce (3 epochs) and the five ablations (weighted CE, focal, synthetic on, 12 layers, scratch; 2 epochs
each), evaluates every best/ on TREC-06 + the three-way holdout in one call, and exports ft-ce to
model_export/ft-ce + app/. Pretraining at step 1,100 / 9,552, loss 5.96.

## 10. 2026-10-08 14:30 β€” Presentation notebook and README

`FinalProject_PhishingDetector.ipynb` (generated by `scripts/build_notebook.py`, 48 cells) mirrors the
instructor's nine "Step N" headers verbatim and in order, builds real `torch.utils.data.DataLoader`s from the
processed splits in Step 2, instantiates the 337M model and prints its parameter table in Step 3, plots the
live pretraining curve in Step 4 (shows "RUN IN PROGRESS" until final/ exists), times 100 single emails
instructor-style in Step 5, shows the app files, runs the app's predict on built-in synthetic examples,
checks the HF login and dry-runs the upload. It executes top to bottom on the CPU in ~70 s with zero errors
today and prints "not available yet β€” produced by <command>" for the 12 artifacts the training queue will
create; re-executing it after the queue fills everything in. Reviewer catches fixed before acceptance: one
header lacked the instructor's "(Optional)", a dev-checkpoint caption stated a wrong step count, a
Challenges bullet misdescribed the uv torch-backend issue, and nothing kept the notebook off the GPU when
opened from a plain shell (now forced to CPU while the pretraining run owns the GPU).
`README.md` documents setup, every phase's commands (verified with --help), config dials, data provenance
and licences, the leakage policy, the experiment queue, deployment, the instructor-requirement mapping and
the limitations. Reviewer catches fixed: it had documented a fictional experiment grid and a notebook path
that did not exist.

## 11. 2026-10-08 19:15 β€” Hugging Face upload approved (deferred until the export exists)

Nathan asked for the upload. Dry run at 19:14: the plan is a private model repo
`BuildITSystems/phishing-detector-encoder` (model.safetensors, config.json, tokenizer.json, README.md) and a
private ZeroGPU Space `BuildITSystems/phishing-detector-demo` (app.py, model.py, requirements.txt, README.md,
.gitattributes + bundled weights, so no token secret is needed). It refuses to run today because
model_export/ft-ce does not exist yet (the fine-tuning queue produces it tomorrow morning). The upload is
scheduled to run automatically once the export lands and the dry run reports no problems. Pretraining at
19:14: step 4,280 / 9,552 (epoch 6 of 12), train MLM loss 2.10, on schedule for ~02:00.

## 12. 2026-10-09 02:45 β€” Pretraining status check (scheduled)

Run `large-24L` at step 9,020 / 9,552 (epoch 12 of 12), ETA ~03:30: about an hour behind the 14 h
estimate because each 500-step validation and 3.8 GB checkpoint write pauses training. Throughput
steady at ~24.1k tokens/s, reserved VRAM 17.94 GiB, device total 20.2 GB with the desktop, GPU 86 C.
No watchdog action, no OOM, no warnings since launch. Validation MLM loss on the held-out 1% of
documents fell monotonically through all twelve passes (6.54 -> 6.38 -> 5.81 -> 5.25 -> 3.91 -> 3.16 ->
2.78 -> 2.58 -> 2.44 -> 2.36 -> 2.27 -> 2.20 -> 2.14 -> 2.11 -> 2.07 -> 2.04 -> 2.03 -> 2.01), masked-token
accuracy 14.7% -> 62.9%, so twelve passes over the same corpus did not overfit; the biggest drop came
between steps 2,000 and 3,000 (epochs 3-4), when the model stopped predicting frequent tokens and started
using context. Train loss 1.55 (per-step, with dropout). The fine-tuning queue is waiting for final/.

## 13. 2026-10-09 03:31 β€” MLM pretraining run `large-24L` (complete)

**Run.** `python src/pretrain.py train --run-name large-24L`; 337,476,608 parameters (EncoderForMLM, tied head). micro-batch 24 x accumulation 11 = 264 sequences x 512 tokens per step; bf16 autocast, fused AdamW [0.9, 0.98] eps 1e-06 wd 0.01 (no decay on biases/LayerNorm), clip 1.0; cosine schedule peak 1.5e-04 -> 10% over 9552 planned steps (12 epochs x 796 steps) with 573 warmup steps; dynamic 15% masking 80/10/10, loss on masked positions only; 210,368 packed 512-token windows (226,273 docs), 1,999 val windows.

**Result.** 9552 optimizer steps (12.00 epochs), 1291.1M tokens seen, total training time 15h 03m 05s (54,186 s) over 1 invocation(s), mean 23,889 tokens/s; peak VRAM reserved 17.94 GiB / allocated 17.49 GiB; final train MLM loss 1.5237, val MLM loss 1.9863 (masked-token accuracy 62.9%).

**Artifacts.** checkpoints/pretrain/large-24L/ (metrics.csv, run_summary.json, step-*/ and final/); log /home/nathank/BITS/dev/Phishing Model/logs/pretrain-20261008-122802.log.

## 14. 2026-10-09 04:07 β€” Hand-off to fine-tuning (scheduled check)

Pretraining `large-24L` finished at 03:31 after 15 h 03 m: 9,552 optimizer steps, 1.29 B tokens seen
(12 passes over 107.5 M), mean 23,889 tokens/s, peak VRAM reserved 17.94 GiB, final train MLM loss 1.52,
validation MLM loss 1.99 with 62.9% masked-token accuracy (from 10.4 / ~0% at random init). final/ holds
the fp32 encoder (1.35 GB) + tokenizer + config. The watchdog saw final/ and exited without ever acting.
The queue started `ft-ce` at 03:36: 3 epochs x 5,009 steps at batch 32 (micro-batch 16), 13.1 GiB reserved,
~75 examples/s, ETA ~05:30 for this run. First validation at step 3,750 (0.75 epoch), 15,284 rows:
unwanted-vs-legitimate F1 0.9855, ROC-AUC 0.9995, PR-AUC 0.9996;
three-way slice (n=8105) macro-F1 0.9775, accuracy 0.9921, per-class F1 [legit, phishing, spam] = [0.996 0.967 0.970];
phishing-vs-spam on true unwanted rows [pp ps sp ss] = [340 0 6 757]. Val F1 is not comparable with the earlier binary dev numbers (val shrank by the 2,699 rows moved to test_holdout3).
Queue estimate: ft-ce done ~05:30, then five 2-epoch ablations (~1 h each + evaluation) -> ~11:00-11:30.

## Fine-tune run `ft-ce` β€” 2026-10-09 04:28
**Dials.** init pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final; 3 classes; 24 layers, 0 frozen; loss cross_entropy; lr 3e-05; batch 32 (micro 16); epochs 3 planned; synthetic off (0 rows); selection val_f1_unwanted; train rows 160,263, val rows 15,284; params 337,444,867 (337,444,867 trainable).
**Result.** 6250 steps, stopped by early_stopping; training time 51.5 min (eval 4.8 min of it); peak reserved 13.13 GiB. Best val at step 5000 (epoch 0), Unwanted (phishing or spam) roll-up: F1 0.9929, precision 0.9938, recall 0.9920, accuracy 0.9923, ROC-AUC 0.9996, PR-AUC 0.9996; F1-optimal threshold 0.318. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9782, accuracy 0.9922; per-class F1 Legitimate 0.9958, Phishing 0.9672, Spam 0.9716; phishing-vs-spam on true unwanted (n=1,103): phish->phish 340, phish->spam 0, spam->phish 4, spam->spam 759. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-ce/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-ce/last.

## 15. 2026-10-09 04:28 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-ce`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-ce/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 285.1 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9404** (232 missed of 3,894) |
| precision | 0.8610 (591 false alarms) |
| F1 | 0.8990 |
| accuracy | 0.9495 |
| specificity | 0.9524 |
| ROC-AUC | 0.9875 |
| PR-AUC | 0.9626 |
| recall @ 1% FPR | 0.7745 |
| recall @ 0.1% FPR | 0.3529 |
| best-F1 threshold | 0.821 (F1 0.9090) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.8996 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,662 flagged true positives the model called 1,294 phishing and 2,368 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 240.9 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9853** (18 missed of 1,221) |
| precision | 0.9877 (15 false alarms) |
| F1 | 0.9865 |
| accuracy | 0.9898 |
| specificity | 0.9925 |
| ROC-AUC | 0.9992 |
| PR-AUC | 0.9985 |
| recall @ 1% FPR | 0.9869 |
| recall @ 0.1% FPR | 0.8690 |
| best-F1 threshold | 0.411 (F1 0.9869) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 0.9925, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9863, **macro-F1 0.9822**; Legitimate P 0.991 / R 0.992 / F1 0.992 (n=1,999); Phishing P 0.981 / R 0.973 / F1 0.977 (n=521); Spam P 0.977 / R 0.979 / F1 0.978 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,984/4/11; Phishing 9/507/5; Spam 9/6/685. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9894 (7 phishing->spam, 6 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9449 | 0.9464 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9905 | 0.9952 |
| Enron | 628 | 0 | 0 | 628 | 0.9889 | 0.9944 |
| Nazario | 222 | 0 | 222 | 0 | 0.9505 | 0.9746 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9900 | 0.9950 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9973 | 0.9986 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 9.95 ms avg / 8.72 ms p50 over 100 emails (0.13x). CPU fp32 (16 threads): 514.07 ms avg / 526.19 ms p50 over 100 emails (6.95x).

**Length confound (test).** FN mean 256 tokens vs TP 287; FP 763 vs TN 848 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-ce`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## Fine-tune run `ft-weighted` β€” 2026-10-09 05:55
**Dials.** init pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final; 3 classes; 24 layers, 0 frozen; loss weighted_cross_entropy; lr 3e-05; batch 32 (micro 16); epochs 2 planned; synthetic off (0 rows); selection val_f1_unwanted; train rows 160,263, val rows 15,284; params 337,444,867 (337,444,867 trainable).
**Result.** 10018 steps, stopped by epochs_done; training time 83.6 min (eval 8.0 min of it); peak reserved 13.13 GiB. Best val at step 10000 (epoch 1), Unwanted (phishing or spam) roll-up: F1 0.9937, precision 0.9911, recall 0.9964, accuracy 0.9932, ROC-AUC 0.9998, PR-AUC 0.9998; F1-optimal threshold 0.899. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9775, accuracy 0.9903; per-class F1 Legitimate 0.9944, Phishing 0.9798, Spam 0.9583; phishing-vs-spam on true unwanted (n=1,103): phish->phish 339, phish->spam 1, spam->phish 2, spam->spam 761. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-weighted/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-weighted/last.

## 16. 2026-10-09 05:55 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-weighted`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-weighted/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 283.4 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9818** (71 missed of 3,894) |
| precision | 0.8307 (779 false alarms) |
| F1 | 0.9000 |
| accuracy | 0.9479 |
| specificity | 0.9372 |
| ROC-AUC | 0.9943 |
| PR-AUC | 0.9833 |
| recall @ 1% FPR | 0.8767 |
| recall @ 0.1% FPR | 0.6335 |
| best-F1 threshold | 0.989 (F1 0.9383) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9565 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,823 flagged true positives the model called 801 phishing and 3,022 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 238.3 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9934** (8 missed of 1,221) |
| precision | 0.9790 (26 false alarms) |
| F1 | 0.9862 |
| accuracy | 0.9894 |
| specificity | 0.9870 |
| ROC-AUC | 0.9993 |
| PR-AUC | 0.9988 |
| recall @ 1% FPR | 0.9910 |
| recall @ 0.1% FPR | 0.7060 |
| best-F1 threshold | 0.728 (F1 0.9878) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 1.0000, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9876, **macro-F1 0.9852**; Legitimate P 0.996 / R 0.987 / F1 0.991 (n=1,999); Phishing P 0.988 / R 0.987 / F1 0.988 (n=521); Spam P 0.964 / R 0.990 / F1 0.977 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,973/5/21; Phishing 2/514/5; Spam 6/1/693. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9943 (6 phishing->spam, 1 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9619 | 0.9603 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9823 | 0.9911 |
| Enron | 628 | 0 | 0 | 628 | 0.9936 | 0.9968 |
| Nazario | 222 | 0 | 222 | 0 | 0.9820 | 0.9909 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9900 | 0.9950 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9936 | 0.9968 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 10.12 ms avg / 8.95 ms p50 over 100 emails (0.14x). CPU fp32 (16 threads): 411.57 ms avg / 397.57 ms p50 over 100 emails (5.56x).

**Length confound (test).** FN mean 310 tokens vs TP 284; FP 1779 vs TN 782 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-weighted`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## Fine-tune run `ft-focal` β€” 2026-10-09 07:21
**Dials.** init pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final; 3 classes; 24 layers, 0 frozen; loss focal (gamma 2.0); lr 3e-05; batch 32 (micro 16); epochs 2 planned; synthetic off (0 rows); selection val_f1_unwanted; train rows 160,263, val rows 15,284; params 337,444,867 (337,444,867 trainable).
**Result.** 10018 steps, stopped by early_stopping; training time 83.9 min (eval 8.0 min of it); peak reserved 13.13 GiB. Best val at step 8750 (epoch 1), Unwanted (phishing or spam) roll-up: F1 0.9951, precision 0.9954, recall 0.9948, accuracy 0.9947, ROC-AUC 0.9998, PR-AUC 0.9998; F1-optimal threshold 0.421. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9869, accuracy 0.9947; per-class F1 Legitimate 0.9971, Phishing 0.9868, Spam 0.9768; phishing-vs-spam on true unwanted (n=1,103): phish->phish 339, phish->spam 1, spam->phish 1, spam->spam 762. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-focal/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-focal/last.

## 17. 2026-10-09 07:22 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-focal`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-focal/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 282.8 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9741** (101 missed of 3,894) |
| precision | 0.8794 (520 false alarms) |
| F1 | 0.9243 |
| accuracy | 0.9619 |
| specificity | 0.9581 |
| ROC-AUC | 0.9946 |
| PR-AUC | 0.9848 |
| recall @ 1% FPR | 0.8955 |
| recall @ 0.1% FPR | 0.6497 |
| best-F1 threshold | 0.682 (F1 0.9397) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9758 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,793 flagged true positives the model called 974 phishing and 2,819 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 238.7 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9836** (20 missed of 1,221) |
| precision | 0.9893 (13 false alarms) |
| F1 | 0.9864 |
| accuracy | 0.9898 |
| specificity | 0.9935 |
| ROC-AUC | 0.9993 |
| PR-AUC | 0.9989 |
| recall @ 1% FPR | 0.9869 |
| recall @ 0.1% FPR | 0.8788 |
| best-F1 threshold | 0.475 (F1 0.9877) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 1.0000, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9885, **macro-F1 0.9867**; Legitimate P 0.990 / R 0.993 / F1 0.992 (n=1,999); Phishing P 0.996 / R 0.987 / F1 0.991 (n=521); Spam P 0.979 / R 0.976 / F1 0.977 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,986/1/12; Phishing 4/514/3; Spam 16/1/683. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9959 (4 phishing->spam, 1 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9534 | 0.9458 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9905 | 0.9952 |
| Enron | 628 | 0 | 0 | 628 | 0.9841 | 0.9920 |
| Nazario | 222 | 0 | 222 | 0 | 0.9865 | 0.9932 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9866 | 0.9933 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9982 | 0.9991 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 10.34 ms avg / 9.01 ms p50 over 100 emails (0.14x). CPU fp32 (16 threads): 357.98 ms avg / 340.34 ms p50 over 100 emails (4.84x).

**Length confound (test).** FN mean 322 tokens vs TP 284; FP 1237 vs TN 827 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-focal`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## Fine-tune run `ft-synthetic` β€” 2026-10-09 08:16
**Dials.** init pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final; 3 classes; 24 layers, 0 frozen; loss cross_entropy; lr 3e-05; batch 32 (micro 16); epochs 2 planned; synthetic on (2000 rows); selection val_f1_unwanted; train rows 162,263, val rows 15,284; params 337,444,867 (337,444,867 trainable).
**Result.** 6250 steps, stopped by early_stopping; training time 52.0 min (eval 4.8 min of it); peak reserved 13.13 GiB. Best val at step 5000 (epoch 0), Unwanted (phishing or spam) roll-up: F1 0.9927, precision 0.9904, recall 0.9949, accuracy 0.9920, ROC-AUC 0.9996, PR-AUC 0.9997; F1-optimal threshold 0.609. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9665, accuracy 0.9891; per-class F1 Legitimate 0.9940, Phishing 0.9352, Spam 0.9704; phishing-vs-spam on true unwanted (n=1,103): phish->phish 340, phish->spam 0, spam->phish 6, spam->spam 757. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-synthetic/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-synthetic/last.

## 18. 2026-10-09 08:16 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-synthetic`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-synthetic/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 282.3 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9679** (125 missed of 3,894) |
| precision | 0.8402 (717 false alarms) |
| F1 | 0.8995 |
| accuracy | 0.9483 |
| specificity | 0.9422 |
| ROC-AUC | 0.9908 |
| PR-AUC | 0.9716 |
| recall @ 1% FPR | 0.7871 |
| recall @ 0.1% FPR | 0.4779 |
| best-F1 threshold | 0.962 (F1 0.9186) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9528 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,769 flagged true positives the model called 1,486 phishing and 2,283 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 238.3 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9885** (14 missed of 1,221) |
| precision | 0.9861 (17 false alarms) |
| F1 | 0.9873 |
| accuracy | 0.9904 |
| specificity | 0.9915 |
| ROC-AUC | 0.9993 |
| PR-AUC | 0.9988 |
| recall @ 1% FPR | 0.9885 |
| recall @ 0.1% FPR | 0.8395 |
| best-F1 threshold | 0.508 (F1 0.9873) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 1.0000, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9860, **macro-F1 0.9814**; Legitimate P 0.993 / R 0.991 / F1 0.992 (n=1,999); Phishing P 0.968 / R 0.987 / F1 0.977 (n=521); Spam P 0.980 / R 0.970 / F1 0.975 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,982/6/11; Phishing 4/514/3; Spam 10/11/679. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9869 (5 phishing->spam, 11 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9280 | 0.9296 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9851 | 0.9925 |
| Enron | 628 | 0 | 0 | 628 | 0.9873 | 0.9936 |
| Nazario | 222 | 0 | 222 | 0 | 0.9820 | 0.9909 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9900 | 0.9950 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9982 | 0.9991 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 10.05 ms avg / 8.9 ms p50 over 100 emails (0.14x). CPU fp32 (16 threads): 481.35 ms avg / 464.28 ms p50 over 100 emails (6.50x).

**Length confound (test).** FN mean 256 tokens vs TP 286; FP 686 vs TN 854 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-synthetic`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## Fine-tune run `ft-12L` β€” 2026-10-09 08:54
**Dials.** init pretrained:/home/nathank/BITS/dev/Phishing Model/checkpoints/pretrain/large-24L/final; 3 classes; 12 layers, 0 frozen; loss cross_entropy; lr 3e-05; batch 32 (micro 16); epochs 2 planned; synthetic off (0 rows); selection val_f1_unwanted; train rows 160,263, val rows 15,284; params 186,290,179 (186,290,179 trainable).
**Result.** 8750 steps, stopped by early_stopping; training time 35.3 min (eval 3.1 min of it); peak reserved 6.97 GiB. Best val at step 6250 (epoch 1), Unwanted (phishing or spam) roll-up: F1 0.9925, precision 0.9936, recall 0.9914, accuracy 0.9919, ROC-AUC 0.9995, PR-AUC 0.9996; F1-optimal threshold 0.171. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9774, accuracy 0.9916; per-class F1 Legitimate 0.9954, Phishing 0.9695, Spam 0.9673; phishing-vs-spam on true unwanted (n=1,103): phish->phish 338, phish->spam 2, spam->phish 2, spam->spam 761. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-12L/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-12L/last.

## 19. 2026-10-09 08:54 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-12L`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-12L/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 591.7 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 1.7 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9461** (210 missed of 3,894) |
| precision | 0.8120 (853 false alarms) |
| F1 | 0.8739 |
| accuracy | 0.9348 |
| specificity | 0.9313 |
| ROC-AUC | 0.9836 |
| PR-AUC | 0.9513 |
| recall @ 1% FPR | 0.6608 |
| recall @ 0.1% FPR | 0.4073 |
| best-F1 threshold | 0.955 (F1 0.8852) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9030 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,684 flagged true positives the model called 802 phishing and 2,882 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 501.6 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 1.7 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9787** (26 missed of 1,221) |
| precision | 0.9860 (17 false alarms) |
| F1 | 0.9823 |
| accuracy | 0.9866 |
| specificity | 0.9915 |
| ROC-AUC | 0.9991 |
| PR-AUC | 0.9983 |
| recall @ 1% FPR | 0.9795 |
| recall @ 0.1% FPR | 0.7748 |
| best-F1 threshold | 0.169 (F1 0.9849) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 0.9888, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9842, **macro-F1 0.9807**; Legitimate P 0.987 / R 0.991 / F1 0.989 (n=1,999); Phishing P 0.988 / R 0.973 / F1 0.981 (n=521); Spam P 0.973 / R 0.971 / F1 0.972 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,982/5/12; Phishing 7/507/7; Spam 19/1/680. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9910 (10 phishing->spam, 1 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9619 | 0.9556 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9864 | 0.9932 |
| Enron | 628 | 0 | 0 | 628 | 0.9793 | 0.9895 |
| Nazario | 222 | 0 | 222 | 0 | 0.9685 | 0.9840 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9766 | 0.9882 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9955 | 0.9977 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 5.05 ms avg / 4.42 ms p50 over 100 emails (0.07x). CPU fp32 (16 threads): 246.62 ms avg / 242.67 ms p50 over 100 emails (3.33x).

**Length confound (test).** FN mean 260 tokens vs TP 286; FP 1105 vs TN 825 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-12L`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## Fine-tune run `ft-scratch` β€” 2026-10-09 09:57
**Dials.** init scratch; 3 classes; 24 layers, 0 frozen; loss cross_entropy; lr 3e-05; batch 32 (micro 16); epochs 2 planned; synthetic off (0 rows); selection val_f1_unwanted; train rows 160,263, val rows 15,284; params 337,444,867 (337,444,867 trainable).
**Result.** 7500 steps, stopped by early_stopping; training time 60.9 min (eval 5.5 min of it); peak reserved 13.13 GiB. Best val at step 5009 (epoch 0), Unwanted (phishing or spam) roll-up: F1 0.9875, precision 0.9864, recall 0.9885, accuracy 0.9864, ROC-AUC 0.9985, PR-AUC 0.9986; F1-optimal threshold 0.601. Three-way on 8,105 fully-labelled val rows: macro-F1 0.9538, accuracy 0.9832; per-class F1 Legitimate 0.9915, Phishing 0.9398, Spam 0.9300; phishing-vs-spam on true unwanted (n=1,103): phish->phish 322, phish->spam 18, spam->phish 1, spam->spam 762. Checkpoints: /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-scratch/best, /home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-scratch/last.

## 20. 2026-10-09 09:57 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-scratch`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-scratch/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 282.4 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9165** (325 missed of 3,894) |
| precision | 0.7752 (1,035 false alarms) |
| F1 | 0.8400 |
| accuracy | 0.9166 |
| specificity | 0.9166 |
| ROC-AUC | 0.9755 |
| PR-AUC | 0.9456 |
| recall @ 1% FPR | 0.7773 |
| recall @ 0.1% FPR | 0.4954 |
| best-F1 threshold | 0.941 (F1 0.8772) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9308 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,569 flagged true positives the model called 332 phishing and 3,237 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 237.5 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9771** (28 missed of 1,221) |
| precision | 0.9763 (29 false alarms) |
| F1 | 0.9767 |
| accuracy | 0.9823 |
| specificity | 0.9855 |
| ROC-AUC | 0.9983 |
| PR-AUC | 0.9974 |
| recall @ 1% FPR | 0.9672 |
| recall @ 0.1% FPR | 0.8354 |
| best-F1 threshold | 0.366 (F1 0.9776) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 0.9925, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9727, **macro-F1 0.9634**; Legitimate P 0.986 / R 0.985 / F1 0.986 (n=1,999); Phishing P 0.990 / R 0.927 / F1 0.957 (n=521); Spam P 0.925 / R 0.970 / F1 0.947 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,970/3/26; Phishing 9/483/29; Spam 19/2/679. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9713 (32 phishing->spam, 3 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9407 | 0.9330 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9796 | 0.9897 |
| Enron | 628 | 0 | 0 | 628 | 0.9777 | 0.9887 |
| Nazario | 222 | 0 | 222 | 0 | 0.8964 | 0.9454 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9498 | 0.9743 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9936 | 0.9968 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 10.06 ms avg / 8.92 ms p50 over 100 emails (0.14x). CPU fp32 (16 threads): 354.05 ms avg / 338.14 ms p50 over 100 emails (4.78x).

**Length confound (test).** FN mean 334 tokens vs TP 280; FP 728 vs TN 855 (see error_analysis.md for the per-bucket table).

**Artifacts.** `reports/eval_ft-scratch`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## 21. 2026-10-09 09:59 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-ce`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-ce/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 285.2 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9404** (232 missed of 3,894) |
| precision | 0.8610 (591 false alarms) |
| F1 | 0.8990 |
| accuracy | 0.9495 |
| specificity | 0.9524 |
| ROC-AUC | 0.9875 |
| PR-AUC | 0.9626 |
| recall @ 1% FPR | 0.7745 |
| recall @ 0.1% FPR | 0.3529 |
| best-F1 threshold | 0.821 (F1 0.9090) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.8996 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,662 flagged true positives the model called 1,294 phishing and 2,368 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 239.6 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9853** (18 missed of 1,221) |
| precision | 0.9877 (15 false alarms) |
| F1 | 0.9865 |
| accuracy | 0.9898 |
| specificity | 0.9925 |
| ROC-AUC | 0.9992 |
| PR-AUC | 0.9985 |
| recall @ 1% FPR | 0.9869 |
| recall @ 0.1% FPR | 0.8690 |
| best-F1 threshold | 0.411 (F1 0.9869) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 0.9925, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9863, **macro-F1 0.9822**; Legitimate P 0.991 / R 0.992 / F1 0.992 (n=1,999); Phishing P 0.981 / R 0.973 / F1 0.977 (n=521); Spam P 0.977 / R 0.979 / F1 0.978 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,984/4/11; Phishing 9/507/5; Spam 9/6/685. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9894 (7 phishing->spam, 6 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9449 | 0.9464 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9905 | 0.9952 |
| Enron | 628 | 0 | 0 | 628 | 0.9889 | 0.9944 |
| Nazario | 222 | 0 | 222 | 0 | 0.9505 | 0.9746 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9900 | 0.9950 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9973 | 0.9986 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 9.89 ms avg / 8.93 ms p50 over 100 emails (0.13x). CPU fp32 (16 threads): 513.57 ms avg / 485.83 ms p50 over 100 emails (6.94x).

**Length confound (test).** FN mean 256 tokens vs TP 287; FP 763 vs TN 848 (see error_analysis.md for the per-bucket table).

**Artifacts.** `/home/nathank/BITS/dev/Phishing Model/reports`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## 22. 2026-10-09 10:46 β€” Evaluation on the test + test3 splits (evaluate.py run, `ft-focal`)

**Checkpoint.** `/home/nathank/BITS/dev/Phishing Model/checkpoints/finetune/ft-focal/best` (3-class head; three-way label contract of RUN_LOG entry 6). Binary roll-up metrics use the same definitions as the binary-era runs so they stay comparable.

### test split (TREC-06 binary holdout): `test_holdout.parquet`, 16,302 rows (3,894 unwanted, 23.9%; label3 legit 12,408, unwanted 3,894). Inference 284.1 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9741** (101 missed of 3,894) |
| precision | 0.8794 (520 false alarms) |
| F1 | 0.9243 |
| accuracy | 0.9619 |
| specificity | 0.9581 |
| ROC-AUC | 0.9946 |
| PR-AUC | 0.9848 |
| recall @ 1% FPR | 0.8955 |
| recall @ 0.1% FPR | 0.6497 |
| best-F1 threshold | 0.682 (F1 0.9397) |
| credential-lure slice | 398 rows (61.1% labelled positive), recall 0.9959, precision 0.9758 |

**Three-way:** no phishing-vs-spam ground truth on this split (no rows labelled phishing or spam: every positive is unwanted-but-unsplit (no phishing-vs-spam ground truth)); informational: of the 3,793 flagged true positives the model called 974 phishing and 2,819 spam.

### test3 split (three-way holdout): `test_holdout3.parquet`, 3,220 rows (1,221 unwanted, 37.9%; label3 legit 1,999, phish 521, spam 700). Inference 237.6 rows/s on cuda (bf16 autocast, batch 64, length-bucketed), peak VRAM 2.27 GB.

| binary roll-up metric (P(unwanted) = P(phishing) + P(spam)) | value |
|---|---:|
| **recall (unwanted)** | **0.9836** (20 missed of 1,221) |
| precision | 0.9893 (13 false alarms) |
| F1 | 0.9864 |
| accuracy | 0.9898 |
| specificity | 0.9935 |
| ROC-AUC | 0.9993 |
| PR-AUC | 0.9989 |
| recall @ 1% FPR | 0.9869 |
| recall @ 0.1% FPR | 0.8788 |
| best-F1 threshold | 0.475 (F1 0.9877) |
| credential-lure slice | 275 rows (97.5% labelled positive), recall 1.0000, precision 1.0000 |

**Three-way** on the 3,220 rows with label3 in {0,1,2} (0 unsplit rows enter the roll-up only; rule: rollup_then_argmax): accuracy 0.9885, **macro-F1 0.9867**; Legitimate P 0.990 / R 0.993 / F1 0.992 (n=1,999); Phishing P 0.996 / R 0.987 / F1 0.991 (n=521); Spam P 0.979 / R 0.976 / F1 0.977 (n=700). 3x3 (rows true, cols predicted Legitimate/Phishing/Spam): Legitimate 1,986/1/12; Phishing 4/514/3; Spam 16/1/683. Phishing-vs-spam on the 1,221 true unwanted rows: kind accuracy 0.9959 (4 phishing->spam, 1 spam->phishing).

| source | n | Legitimate | Phishing | Spam | accuracy | macro-F1 (present classes) |
|---|---:|---:|---:|---:|---:|---:|
| Assassin | 236 | 164 | 0 | 72 | 0.9534 | 0.9458 |
| CEAS-08 | 736 | 736 | 0 | 0 | 0.9905 | 0.9952 |
| Enron | 628 | 0 | 0 | 628 | 0.9841 | 0.9920 |
| Nazario | 222 | 0 | 222 | 0 | 0.9865 | 0.9932 |
| Nigerian_Fraud | 299 | 0 | 299 | 0 | 0.9866 | 0.9933 |
| TREC-07 | 1,099 | 1,099 | 0 | 0 | 0.9982 | 0.9991 |

**Latency (batch 1, tokenize + forward + softmax, timeit.default_timer) vs the instructor's 74 ms.** GPU bf16: 10.08 ms avg / 8.9 ms p50 over 100 emails (0.14x). CPU fp32 (16 threads): 458.57 ms avg / 445.01 ms p50 over 100 emails (6.20x).

**Length confound (test).** FN mean 322 tokens vs TP 284; FP 1237 vs TN 827 (see error_analysis.md for the per-bucket table).

**Artifacts.** `/home/nathank/BITS/dev/Phishing Model/reports`: metrics.json, latency.json, confusion_matrix.png, roc_curve.png, pr_curve.png, error_analysis.md; metrics_test3.json, latency_test3.json, confusion_matrix_test3.png, confusion_matrix3_test3.png, roc_curve_test3.png, pr_curve_test3.png, error_analysis_test3.md (training curves: reports/pretrain_curves.png and finetune_curves.png from pretrain.py / finetune.py `plot`).

## 23. 2026-10-12 11:30 β€” Fine-tune queue results, model selection (`ft-focal`), export

**Queue** (`scripts/run_experiments.sh`, tmux `phish-finetune`): finished 2026-10-09 10:02:22, every step exit 0
(`ft-ce` 03:36-04:31, `ft-weighted` -05:57, `ft-focal` -07:24, `ft-synthetic` -08:19, `ft-12L` -08:56,
`ft-scratch` -09:59, then top-level evaluate + export of the pre-designated main run `ft-ce`). Log
`logs/experiments_20261008-141528.out`. Per-run evaluations in `reports/eval_<run>/` (entries 15-20, 21).

**Selection rule and result.** The queue's main run was `ft-ce` by default, but the rule stated in README section 9
was "if another run wins on val F1 of the unwanted roll-up, it becomes the main run". `ft-focal` (focal loss,
gamma 2.0) won on the validation set on both selection metrics: val F1 unwanted 0.9951 vs 0.9929 (`ft-ce`), val
three-way macro-F1 0.9869 vs 0.9782; it also had the best val phishing precision/recall (0.985 / 0.988). The
decision was taken from the validation numbers; the holdouts were evaluated once per run for reporting and
confirm it (TREC-06 F1 0.9243 vs 0.8990, recall at 1% FPR 0.8955 vs 0.7745; test3 macro-F1 0.9867 vs 0.9822).
Top-level evaluate was re-run for `ft-focal` on 2026-10-09 10:46 (entry 22, `reports/metrics*.json`,
`latency.json`, plots, `error_analysis*.md`).

**Held-out results of the deployed run `ft-focal`:**

| split | numbers |
|---|---|
| TREC-06 (16,302 rows, 23.9% unwanted, whole corpus unseen) | recall 0.9741 (101 missed), precision 0.8794 (520 false alarms, FPR 4.2%), F1 0.9243, accuracy 0.9619, ROC-AUC 0.9946, PR-AUC 0.9848, recall at 1% / 0.1% FPR 0.8955 / 0.6497 |
| test_holdout3 (3,220 rows: 1,999 / 521 / 700) | macro-F1 0.9867, accuracy 0.9885; F1 legit / phishing / spam 0.9918 / 0.9913 / 0.9771; binary roll-up F1 0.9864, ROC-AUC 0.9993; phishing-vs-spam on true unwanted 0.9959 (4 phishing->spam, 1 spam->phishing); 3x3 [[1986, 1, 12], [4, 514, 3], [16, 1, 683]] |
| latency, batch 1, 100 TREC-06 emails (mean 280 tokens) | GPU bf16 10.08 ms mean / 8.90 p50 / 13.72 p95 (0.14x the 74 ms benchmark); CPU fp32 16 threads 458.6 ms mean / 445.0 p50 / 701.1 p95 (6.20x) |

**What the ablations say** (all six on the same holdouts, README 9.1): class-weighted CE trades precision for
recall (TREC-06 recall 0.982 but FPR 6.3%); focal wins on every metric; the 2,000-row synthetic supplement
changes nothing measurable (TREC-06 F1 0.8995 vs 0.8990; val macro-F1 lower, because its 1,000 phishing lures
are template-generated); 12 layers lose ~5 F1 points on TREC-06 (0.8739) for ~2x faster inference; scratch
init loses 8 points (0.8400) and 1.7 ROC-AUC points, which is what the 15 h of MLM pretraining buys.

**Challenge recorded: TREC-06 labelling conventions.** The TREC-06 numbers are the honest, cross-corpus ones and
sit well below the in-distribution validation numbers (F1 0.92 vs 0.995). `reports/error_analysis.md` shows the
two causes: (1) TREC-06 labels as *ham* a lot of mail that looks like spam to any text model (spam quoted inside
mailing-list discussions, "please remove me from your list" replies, sweepstakes mail, "57 Million Email
Addresses - ONLY $149!!" on a developer list), which accounts for the most confident false positives; (2) its
largest block of *spam* is "ScanMail Message: virus found" gateway notices, which every run misses. Legitimate
TREC-06 mail is also ~3x longer than its spam and 24% of it is truncated at 512 tokens; false positives are
truncated 31.5% of the time vs 23.6% for true negatives. Neither is a leakage problem, but both cap what any
text-only classifier can score on this corpus, and they are the right thing to show in the presentation when
explaining the gap between 0.995 validation and 0.92 TREC-06.

**Export.** 2026-10-12 11:28: `export.py run --checkpoint checkpoints/finetune/ft-focal/best --out-dir
model_export/ft-focal --app-dir app` -> `model_export/ft-focal/` (bf16 safetensors 674.9 MB, config.json with
labels + metrics + dials, tokenizer.json, model-card README.md, export_manifest.json) and the same three files
into `app/`; `app/model.py` is byte-identical to `src/model.py` (sha1 39b3773c, asserted). This replaced the
`ft-ce` weights that the queue had copied into `app/` on 10-09 10:02; `model_export/ft-ce/` is kept for
comparison. README (sections 2.1, 2.2, 5.6-5.8, 9, 10, 11), `app/README.md` (Space card: bundled weights,
training recipe, real model-card link) and `scripts/build_notebook.py` were updated to name `ft-focal` as the
deployed run; the notebook was rebuilt.

**Gap in the record.** The 2026-10-09 10:43 scheduled check-in completed the queue review and the `ft-focal`
top-level evaluation (entry 22) but stalled before export/upload; resumed on 2026-10-12 11:27 at Nathan's
request. `find -newermt` confirmed nothing in the project changed in between.

## 24. 2026-10-12 11:35 β€” Hugging Face upload (private model repo + private ZeroGPU Space)

Approved by Nathan on 2026-10-08 19:15 ("can you upload the hugging face?"); deferred until an export existed.
Account: `BuildITSystems` via the `hf auth login` cache (token never printed or written anywhere).

**Dry run** 11:30 (`scripts/upload_to_hf.py --model-dir model_export/ft-focal --bundle-weights`): no problems;
plan = 4 files / 645.9 MiB to the model repo, 8 files / 645.9 MiB to the Space (weights bundled, so no
`HF_TOKEN` secret), hardware `zero-a10g`, both private.

**Live run 1** 11:31 (`--yes`): `create_repo` + `upload_folder` for the model repo succeeded (commit
`80bd85c5`: README.md model card, config.json, model.safetensors 674.9 MB bf16, tokenizer.json). The Space was
created and its `PHISH_MODEL_REPO` variable set, but `upload_folder(app/)` was refused by the Hub's README
validation: `Invalid YAML in README.md: bad indentation of a mapping entry (11:45)` β€” the unquoted
`short_description: From-scratch 337M encoder: legitimate / phishing / spam` contains a colon. The dry run does
not validate the card's YAML, so this was invisible until the live call. Exit 1.

**Fix** 11:34: quoted the value in `app/README.md` (55 chars, under the 60-char limit); verified with
`yaml.safe_load` and `huggingface_hub.RepoCard`. Lesson added to README 5.9.

**Live run 2** 11:35 (`--yes` again, idempotent): model-repo files already present (LFS content dedupe; commit
unchanged), Space `upload_folder` succeeded (commit `98f90d7b`). Verified read-only afterwards:

| repo | private | files |
|---|---|---|
| https://huggingface.co/BuildITSystems/phishing-detector-encoder | yes | .gitattributes, README.md, config.json, model.safetensors 674.9 MB, tokenizer.json 2.3 MB |
| https://huggingface.co/spaces/BuildITSystems/phishing-detector-demo | yes | .gitattributes, README.md (card), app.py, model.py, requirements.txt, config.json, model.safetensors 674.9 MB, tokenizer.json 2.3 MB |

Space runtime at 11:36: stage `BUILDING`, requested hardware `zero-a10g`; variable
`PHISH_MODEL_REPO=BuildITSystems/phishing-detector-encoder`; no secrets. Deployed weights = `ft-focal`
(entry 23). Before class Nathan flips both repos to public (README section 10 one-liner) and opens the Space
once so ZeroGPU is warm.

**Build and live check (2026-10-12 11:37-11:39).** Space stages: BUILDING 11:35 -> APP_STARTING 11:36 -> RUNNING
on `zero-a10g` 11:37 (about 2 minutes end to end). Verified through the private Space's Gradio API
(`gradio_client`, endpoint `/predict(subject, body)`, token from the login cache): a sample credential-lure
email ("verify your account within 24 hours", lookalike PayPal link) returned verdict **Phishing** 95.6%,
Spam 3.5%, Legitimate 0.9%, roll-up Unwanted 99.1%, in-app "Prediction Time (s)" 0.253 (cold ZeroGPU first
call), round-trip 3.6 s, with the token highlights and the rule-based signals populated. The deployment is
working end to end.

## 25. 2026-10-12 11:58 β€” Presentation bundle on the Hub + Colab variant of the notebook

**Why.** Nathan will present from Google Colab, where none of the project files exist and the local GPU is
irrelevant. Design: the notebook is narrative only; every prediction runs on the ZeroGPU Space
(`gr.load(..., src="spaces")` to embed the Space UI, `gradio_client` for the timing loop), and the evidence the
notebook renders (metrics, curves, experiments table, data report, run log, code) is fetched from the model repo.

**Bundle upload (approved by Nathan 2026-10-12 11:50).** `presentation/` in the private model repo
`BuildITSystems/phishing-detector-encoder`, commit `439e7b1a`: 85 files, 3.6 MB, relative paths mirrored from
the project (`reports/` JSON/PNG/MD incl. `eval_<run>/`, `checkpoints/pretrain/large-24L/{run_summary.json,
metrics.csv,final/config.json}`, `checkpoints/finetune/<run>/{metrics.csv,epochs.csv}`,
`data/processed/{splits_manifest,tokenizer_report,pretrain_tokens}.json` + `data_report.md`,
`docs/research/research_model-math.md`, `src/*.py`, `app/{app.py,model.py,requirements.txt,README.md}`,
`config.yaml`, `requirements-train.txt`, `scripts/*`, `model_export/ft-focal/{config.json,README.md,
export_manifest.json}`, `RUN_LOG.md`, `README.md`). Excluded on purpose: every parquet/npy/safetensors/log.
Scanned before upload for tokens (`hf_...`), `HF_TOKEN=` values, Nathan's e-mail address, private keys and
cloud keys: clean. The quoted emails in `error_analysis*.md` are rows of public corpora (TREC-06 etc.), as the
files themselves state. The Hub warned that `presentation/README.md` has no YAML front matter (it is the
project README, not a model card): harmless.

**Colab notebook build (workflow wf_4200da59, 8 agents, 53 min, 12:00-12:53).** `scripts/build_notebook.py`
gained `--colab` and `--out PATH`; cells are tagged `local` / `colab` / shared so both notebooks come from one
source (default mode proven byte-identical in cell sources against the executed deliverable, which was never
rewritten: `cmp` identical before and after). The Colab variant (49 cells, 36 code) keeps the nine instructor
headers and the story; Steps 2-4 and the Results read the Hub bundle; Step 5 calls the Space through
`gradio_client`; Step 7 embeds the Space with `gr.load`; Step 8 logs in only when the token is not already
active (huggingface_hub 1.33 `login(token=...)` otherwise rewrites the token files unconditionally); Step 9
quotes entry 24. Two adversarial reviews found 6 items, all fixed: per-call error handling around the Space
calls (one ZeroGPU abort must not kill the cell), the `login()` behaviour above, `analytics_enabled=False` on
the client (the env var alone does not disable gradio_client telemetry), a note that `gr.load` forwards the token
to the Space (use a fine-grained read token as the Colab secret), the login-cache fallback made opt-in outside
Colab (`NOTEBOOK_ALLOW_LOGIN_CACHE=1`), and silencing the doomed first `gr.load` attempt.

**Challenge recorded: `gr.load` bug in gradio 6.29.1.** `gr.load("BuildITSystems/phishing-detector-demo",
src="spaces")` raises `TypeError: Textbox.__init__() got an unexpected keyword argument 'class_id'`:
`Blocks.from_config` rebuilds the Interface's examples `Dataset`, whose serialised child components are
`{"name", "class_id"}` dicts, and `gradio.components.base.get_component_instance` pops only `name`. Any Space
whose `gr.Interface` has `examples=` triggers it. Reproduced independently on the workstation. Fix in the
notebook: catch the error, install an idempotent shim that drops `class_id`, retry `gr.load`; keep a hand-built
proxy `gr.Interface` over `gradio_client` as the last fallback. Verified: `gr.load` succeeds after the shim.

**Headless Colab-mimic test (clean uv venv, CPython 3.12.14, torch 2.14.1+cpu, gradio 6.29.1, gradio_client
2.7.2, huggingface_hub 1.33.0, executed from an empty scratch directory, `HF_TOKEN` unset, token only from the
login cache via the opt-in flag).** Round 1 ran 36/36 cells with no errors but `gr.load` had fallen back to the
proxy (flagged as a failure by the harness); after the shim fix round 2 passed every check: 36/36 cells, 0
errors, 18 s wall, bundle 86 files / 3.4 MiB, six live Space predictions (in-app times 0.229 s cold then 0.009-
0.011 s; e.g. "Microsoft 365 password expires" -> Phishing 93.7%, "FINAL HOURS: 70% OFF" -> Spam 99.0%, "Agenda
for Thursday" -> Legitimate 98.6%), `gr.load` served and closed, whoami = BuildITSystems, zero token-shaped
strings in outputs or raw file. Final verification: TREC-06 F1 0.9243 / recall 0.9741 / ROC-AUC 0.9946 and the
binary confusion 11888/520/101/3793, three-way macro-F1 0.9867 and the 3x3 matrix all match `reports/`.
Presenter notes, non-blocking: the iframe cell prints "needs a public Space" until the Space is public; the
`gr.load` cell shows the shim trace line; some outputs echo workstation absolute paths that live inside the
mirrored JSON files.

**gradio 6.30.0 checked (released after the pin):** `gr.load` fails identically (`class_id`), tested in a
throwaway venv against the Space, so bumping the pin would not remove the shim. Keep 6.29.1 (= the Space's
`sdk_version`) in the Colab notebook until a release fixes `get_component_instance`.

**Bundle refresh 2026-10-12 13:05:** re-uploaded `presentation/RUN_LOG.md`, `presentation/README.md` and
`presentation/scripts/build_notebook.py` (all changed by this entry) and added
`presentation/FinalProject_PhishingDetector_Colab.ipynb` so the notebook can be downloaded from the model repo
and uploaded to Colab in one step. Same approval as the bundle; same scan; nothing else changed on the Hub.