Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
2675 lines (2552 loc) · 141 KB
/
Copy pathMakefile
File metadata and controls
2675 lines (2552 loc) · 141 KB
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
996
997
998
999
1000
SHELL := /bin/bash
.SHELLFLAGS := -o pipefail -c
# `_comma` lets us pass a literal comma into $(subst) where the function-call
# syntax would otherwise interpret it as an argument separator. Used to convert
# autocollie's csv-joined env values (e.g. `pe=0.5,zip=2.0`) back into the
# space-separated form make's $(foreach) expects.
_comma := ,
.PHONY: azoth-backtest azoth-build-allowlist azoth-allowlist-monitor azoth-allowlist-tune azoth-full-train azoth-fast-train azoth-publish-train _azoth-train azoth-general azoth-general-fold-a azoth-general-fold-b azoth-oof-merge-general evaluate explain inspect errors scan traits thresholds thresholds-refresh filetype-matrix elf-model-benchmark elf-route-optimization azoth-specialists azoth-specialists-fold-a azoth-specialists-fold-b azoth-prefill-specialist-features azoth-oof-route-scores azoth-calibrate azoth-diagnostics azoth-policies azoth-deploy azoth-deploy-final azoth-check-bundle-layout azoth-shap false-positives false-negatives near-false-positives near-false-negatives false-positives-archive false-negatives-archive near-false-positives-archive near-false-negatives-archive false-positives-triage false-negatives-triage near-false-positives-triage mislabeled-triage benchmark build-splits experiment ablate ablation demo-db test lint clean deploy verify-litmus venv help repin azoth-clean-bundle
VENV_DIR ?= .venv
PYTHON ?= $(VENV_DIR)/bin/python
DB ?= postgres://hopper@localhost:5432/hopper
PROFILE_PERCENT ?= 1
PROFILE_WORKERS ?= 48
PROFILE_PROFILER ?= cprofile
PROFILE_STOP_STAGE ?= 9
PROFILE_TARGET_MINUTES ?= 15
PROFILE_FIT_MEMORY_GB ?=
PROFILE_EXTRACT_WORKERS_PER_FIT ?=
PROFILE_SCORING_WORKERS ?= 6 12 6
MODEL ?= azoth
LEARNER ?= $(if $(filter azoth%,$(MODEL)),azoth,$(MODEL))
OUT_ROOT ?= out/models
OUT_DIR ?= $(if $(filter azoth,$(MODEL)),$(OUT_ROOT)/azoth/general,$(OUT_ROOT)/$(MODEL))
# Resolve the bundle's model file. Single-seed bundles ship `model.txt`
# directly under $(OUT_DIR); multi-seed bundles ship `models/seed_NN.txt`
# (the deployed Azoth general is 3-seed). Auto-pick whichever is on disk:
# `model.txt` if present, else the lowest-numbered seed, else fall back
# to the legacy `model.txt` name (callers get a clear FileNotFoundError
# rather than a misleading "missing arg" failure).
MODEL_FILE_EXT := $(if $(filter azoth,$(LEARNER)),txt,json)
MODEL_FILE ?= $(patsubst $(OUT_DIR)/%,%,$(or \
$(wildcard $(OUT_DIR)/model.$(MODEL_FILE_EXT)), \
$(firstword $(sort $(wildcard $(OUT_DIR)/models/seed_*.$(MODEL_FILE_EXT)))), \
$(OUT_DIR)/model.$(MODEL_FILE_EXT)))
LOG_DIR ?= $(OUT_DIR)/logs
EXP_OUT_DIR ?= out/experiments/$(MODEL)
AZOTH_FOLD_A_EXP_OUT_DIR ?= out/experiments/azoth.oof-fold-a
AZOTH_FOLD_B_EXP_OUT_DIR ?= out/experiments/azoth.oof-fold-b
EXP_LOG_DIR ?= $(EXP_OUT_DIR)/logs
THRESHOLD_SCORES ?= $(OUT_DIR)/threshold_scores.npz
THRESHOLD_MAX_ID ?=
THRESHOLD_MAX_ID_ARG := $(if $(THRESHOLD_MAX_ID),--max-id $(THRESHOLD_MAX_ID),)
TOP_ERRORS ?= 250
SKIP ?= 0
THRESHOLD_TOP_ERRORS ?= 0
FILETYPE_MATRIX_OUTPUT ?= $(OUT_DIR)/filetype_metrics.json
FILETYPE_MATRIX_CSV ?= $(OUT_DIR)/filetype_metrics.csv
FILETYPE_MATRIX_MIN_COUNT ?= 25
ELF_BENCHMARK_OUTPUT ?= $(OUT_ROOT)/elf_model_benchmark.json
ELF_BENCHMARK_GENERAL_DIR ?= $(OUT_ROOT)/azoth/general
ELF_BENCHMARK_BINARY_DIR ?= $(OUT_ROOT)/azoth-binary-cpu
ELF_BENCHMARK_ELF_DIR ?= $(OUT_ROOT)/azoth-elf-cpu
ELF_BENCHMARK_FOLDS ?= 2
ELF_BENCHMARK_ESTIMATORS ?= 400
ELF_BENCHMARK_MAX_DEPTH ?= 12
ELF_BENCHMARK_LEARNING_RATE ?= 0.05
ELF_BENCHMARK_EARLY_STOPPING ?= 50
ELF_BENCHMARK_NUM_LEAVES ?= 96
ELF_BENCHMARK_MIN_CHILD_SAMPLES ?= 100
AZOTH_ROOT ?= $(OUT_ROOT)/azoth
# Run-isolation layout. Each fresh train allocates a directory under
# $(AZOTH_RUNS_ROOT)/ and writes the entire bundle there; on success
# azoth-publish atomically updates $(OUT_ROOT)/azoth to point at it.
# See scripts/azoth_publish_run.py and the azoth-run-new / azoth-publish
# targets below.
AZOTH_RUNS_ROOT ?= $(OUT_ROOT)/azoth-runs
# Successful run dirs to retain under $(AZOTH_RUNS_ROOT) after a publish;
# older ones (and legacy-* archives) are pruned. The live symlink target is
# always kept regardless of age.
AZOTH_KEEP_RUNS ?= 5
AZOTH_SPECIALISTS_SUMMARY ?= $(AZOTH_ROOT)/specialists.json
AZOTH_GENERAL_DIR ?= $(AZOTH_ROOT)/general
AZOTH_GENERAL_SCORES ?= $(AZOTH_GENERAL_DIR)/threshold_scores.npz
AZOTH_CONFIG ?= $(AZOTH_ROOT)/config.json
AZOTH_SCORE_TABLE ?= $(AZOTH_ROOT)/score_table.npz
AZOTH_DIAGNOSTICS ?= $(AZOTH_ROOT)/route_diagnostics.md
AZOTH_DIAGNOSTICS_CSV ?= $(AZOTH_ROOT)/route_diagnostics.csv
AZOTH_SLICE_METRICS ?= $(AZOTH_ROOT)/slice_metrics.md
AZOTH_SLICE_METRICS_CSV ?= $(AZOTH_ROOT)/slice_metrics.csv
AZOTH_ROUTE_POLICIES ?= $(AZOTH_ROOT)/route_policies.json
AZOTH_ROUTE_POLICIES_CSV ?= $(AZOTH_ROOT)/route_policies.csv
# Discover features from each route's training rows. Frequency cutoffs and
# per-family caps bound the vocabulary; a deployed model is not required.
# A fixed allowlist is an optional optimization, explicitly selected for both
# experiments and full training. Empty means no additional name filter.
AZOTH_ALLOWED_FEATURES_FILE ?=
export COLLIMATOR_ALLOWED_FEATURES_FILE := $(AZOTH_ALLOWED_FEATURES_FILE)
# Optional size optimization: discover candidates from current training rows,
# then retain frequent columns plus names used by existing tree splits.
# AZOTH_ALLOWLIST_SIZE is the aggressiveness knob (memory vs tail) that autocollie
# should sweep + validate against FP/100M; it is NOT pinned here.
AZOTH_ALLOWLIST_SIZE ?= 15000
AZOTH_ALLOWLIST_SAMPLE_PER_TYPE ?= 6000
AZOTH_ALLOWLIST_SAMPLING_WORKERS ?= 4
AZOTH_ALLOWLIST_MAX_DB_WORKERS ?= 24
AZOTH_ALLOWLIST_CACHE_DIR ?= out/cache/azoth-allowlist
AZOTH_ALLOWLIST_CACHE_MAX_AGE ?= 3600
AZOTH_ALLOWLIST_REFRESH ?= 0
azoth-build-allowlist: venv check-db
$(EXPERIMENT_FEATURE_ENV) $(PYTHON) scripts/azoth_build_feature_allowlist.py \
--bundle $(AZOTH_DEPLOY_DIR) \
--db $(DB) \
--workers $(if $(WORKERS),$(WORKERS),16) \
--target-size $(AZOTH_ALLOWLIST_SIZE) \
--sample-per-type $(AZOTH_ALLOWLIST_SAMPLE_PER_TYPE) \
--sampling-workers $(AZOTH_ALLOWLIST_SAMPLING_WORKERS) \
--max-db-workers $(AZOTH_ALLOWLIST_MAX_DB_WORKERS) \
--cache-dir $(AZOTH_ALLOWLIST_CACHE_DIR) \
--cache-max-age $(AZOTH_ALLOWLIST_CACHE_MAX_AGE) \
$(if $(filter 1 true yes,$(AZOTH_ALLOWLIST_REFRESH)),--refresh,) \
--output out/models/azoth/allowed_features.json
@echo "to use it for the next retrain:"
@echo " make azoth-full-train AZOTH_ALLOWED_FEATURES_FILE=out/models/azoth/allowed_features.json"
@echo "then gate before deploy: scripts/check_azoth_regression.py (already in the deploy flow)"
# Non-blocking drift monitor: re-extracts the proxy routes from the deployed
# bundle + current corpus, then scores the CURRENT pin ($(AZOTH_ALLOWED_FEATURES_FILE))
# full-vs-pruned. Read-only — it never changes the pin or deploys; it just
# reports worst_dR@1e-3 and an OK / RE-TUNE verdict so you (or autocollie) know
# when the committed level has drifted. Run it after a deploy cycle. The proxy
# screens coarse quality only (R@1e-3); the L50 tail stays owned by the gate.
AZOTH_MONITOR_ROUTES ?= general,elf,source
azoth-allowlist-monitor: venv check-db
@test -n "$(AZOTH_ALLOWED_FEATURES_FILE)" || { echo "error: set AZOTH_ALLOWED_FEATURES_FILE to the allowlist to monitor"; exit 1; }
-$(PYTHON) scripts/azoth_allowlist_experiment.py \
--prepare --routes $(AZOTH_MONITOR_ROUTES) --db $(DB) \
--workers $(if $(WORKERS),$(WORKERS),16) \
--committed $(AZOTH_ALLOWED_FEATURES_FILE) \
--out out/allowlist_exp/monitor.json
@echo "monitor report: out/allowlist_exp/monitor.json (non-blocking; never changes the pin)"
# The allowlist "virtual route": sweep candidate levels on the proxy routes and
# write the SMALLEST allowlist whose worst proxy R@1e-3 drop stays within
# AZOTH_TUNE_TOLERANCE. Produces a CANDIDATE pin only — promotion is gated
# (validate via a check_azoth_regression'd full-train, never auto-adopted).
# This is the collimator-side entry point autocollie drives for the level knob;
# the proxy screens coarse quality, the gate owns the L50 tail.
AZOTH_TUNE_LEVELS ?= 8000,11000,14000,17000,20000
AZOTH_TUNE_TOLERANCE ?= -0.002
azoth-allowlist-tune: venv check-db
-$(PYTHON) scripts/azoth_allowlist_experiment.py \
--prepare --routes $(AZOTH_MONITOR_ROUTES) --db $(DB) \
--workers $(if $(WORKERS),$(WORKERS),16) \
--tune $(AZOTH_TUNE_LEVELS) --tolerance $(AZOTH_TUNE_TOLERANCE) \
--out out/models/azoth/allowed_features_candidate.json
@echo "candidate pin: out/models/azoth/allowed_features_candidate.json (NOT adopted — gated)"
@echo "validate via gated retrain:"
@echo " make azoth-full-train AZOTH_ALLOWED_FEATURES_FILE=out/models/azoth/allowed_features_candidate.json EXP_RERUN=1"
AZOTH_ROUTE_POLICIES_MD ?= $(AZOTH_ROOT)/route_policies.md
AZOTH_GLOBAL_POLICY_METRICS ?= $(AZOTH_ROOT)/global_policy_metrics.json
AZOTH_GLOBAL_POLICY_METRICS_MD ?= $(AZOTH_ROOT)/global_policy_metrics.md
AZOTH_ROUTED_METRICS_ARGS ?=
# When set, compute_routed_metrics and azoth_route_policy_search carry
# forward unchanged-filetype entries from this bundle's previous outputs
# instead of recomputing them. Honest invalidation: any model/feature
# spec hash diff triggers recomputation for the affected filetypes.
# Major wall-time savings on single-route promotes (~3-5 min off each
# of policy_search and compute_routed_metrics).
#
# Defaults to $(OUT_ROOT)/azoth — the SOURCE training bundle is what
# the candidate was cloned from (autocollie promote does
# cloneTreeFast(baselineRoot, candidateRoot) → only the changed route's
# files differ from this baseline). Using the live deployed bundle as
# the comparison point fails: it may not have been refreshed in days,
# so its model files genuinely differ on many routes from the training
# baseline, defeating the impact detection.
#
# Empty disables the optimization.
AZOTH_PREVIOUS_BUNDLE ?= $(OUT_ROOT)/azoth
AZOTH_PREVIOUS_BUNDLE_ARG = $(if $(AZOTH_PREVIOUS_BUNDLE),--previous-bundle $(AZOTH_PREVIOUS_BUNDLE),)
# Carry exact auxiliary artifacts from the preceding training bundle into a
# fresh run. Their scores and policies are recalibrated; existing gates remain.
AZOTH_RETAIN_AUXILIARIES_ARG = $(if $(and $(AZOTH_PREVIOUS_BUNDLE),$(wildcard $(AZOTH_PREVIOUS_BUNDLE)/config.json)),--retain-auxiliaries-from "$(AZOTH_PREVIOUS_BUNDLE)",)
# Impact filtering is valid for a candidate cloned from another bundle. A full
# build compared with itself must still check every filetype against deployed.
AZOTH_REGRESSION_SOURCE_ARG = $(if $(and $(AZOTH_PREVIOUS_BUNDLE),$(filter-out $(realpath $(AZOTH_ROOT)),$(realpath $(AZOTH_PREVIOUS_BUNDLE)))),--source-bundle $(AZOTH_PREVIOUS_BUNDLE),)
AZOTH_VALIDATE_ROUTED_METRICS_ARGS ?= --no-ci --no-stacked
AZOTH_VALIDATE_DIAGNOSTICS ?= 0
# Whether azoth-deploy emits route_diagnostics.{md,csv} and slice_metrics.{md,csv}.
# Diagnostics are human-debugging artifacts that nothing downstream consumes —
# regression check, policy search, and routed metrics all read directly from
# config.json + score_table.npz. Generating them on every deploy adds many
# minutes to the critical path for output most deploys never read. Off by
# default; run `make azoth-diagnostics` standalone when you actually need
# the report (the deployed bundle has everything it needs to regenerate them
# on demand). Set to 1/true/yes to opt back in.
AZOTH_DEPLOY_DIAGNOSTICS ?= 0
# Low-water-mark for the regression gate: a pinned reference bundle's
# route_policy_eval_oof.json that no future deploy is allowed to fall more
# than --lwm-tolerance below (per filetype). Lives outside $(AZOTH_ROOT)
# so azoth-clean-bundle doesn't wipe it. Captured via
# `make azoth-set-low-water-mark`. If the file doesn't exist the
# regression gate silently skips the LWM check, so this is fully
# opt-in — set the LWM once you have a deploy you want to lock in.
AZOTH_LOW_WATER_MARK_DIR ?= $(OUT_ROOT)/azoth_low_water_mark
# Check all saved levels; only L0–L25 can block. Higher levels are report only.
# Extra args appended to the check_azoth_regression.py deploy gate. Default is
# --net-improvement-fallback: a net-positive deploy (malware-weighted TP delta
# > 0) ships even with per-filetype regressions, PROVIDED no high-volume route
# (>= --catastrophe-min-mal malware) craters past --max-net-route-regression.
# Small-route craters are dataset-level noise (sampling variance on a few
# hundred samples) and don't block; a real regression on a route big enough to
# move the average still does. Override to e.g.
# AZOTH_REGRESSION_ARGS="--recall-tolerance 0 --lwm-tolerance 0"
# for a stricter per-filetype gate, or set AZOTH_ALLOW_REGRESSION=1 to bypass
# entirely. litmus validate + severity-sync stay on regardless.
AZOTH_REGRESSION_ARGS ?= --net-improvement-fallback
# Report held-out FP counts and per-filetype allowance overruns at L25.
# Installation is gated by detection regressions and runtime validation;
# the development FP allowance is not a mandatory held-out install budget.
# Operators can opt into the stricter count gate explicitly:
# AZOTH_BUDGET_GATE_ARGS=--fail-on-budget
AZOTH_BUDGET_GATE_ARGS ?=
AZOTH_POLICY_OVERRIDE_ROUTE ?=
# scan v2 reads its default bundle from <data>/atomdrift/scan/models/<bundle>,
# where <bundle> is the last segment of SCAN_MODELS_REPO (default repo
# github.com/atomdrift-project/azoth.git -> "azoth"). Deploy there so `ascan scan`
# picks up the bundle at runtime without SCAN_MODELS_DIR.
AZOTH_DEPLOY_DIR ?= $(XDG_DATA_HOME)/atomdrift/scan/models/azoth
ELF_ROUTE_OUTPUT_DIR ?= $(AZOTH_ROOT)/elf_route_optimization
ELF_ROUTE_OUTPUT ?= $(AZOTH_ROOT)/elf_route_optimization.json
ELF_ROUTE_TEACHER_DIR ?= $(AZOTH_ROOT)/filetypes/elf
AZOTH_REFRESH_SCORES ?= 0
AZOTH_REFRESH_SCORES_ARG := $(if $(filter 1 true yes,$(AZOTH_REFRESH_SCORES)),--refresh,)
AZOTH_REFRESH_ROUTE ?=
AZOTH_SKIP_LEVEL_CALIBRATION ?= 0
AZOTH_SKIP_LEVEL_CALIBRATION_ARG := $(if $(filter 1 true yes,$(AZOTH_SKIP_LEVEL_CALIBRATION)),--skip-level-calibration,)
AZOTH_SKIP_LITMUS_VALIDATE ?= 0
AZOTH_FEATURE_CACHE_DIR ?= out/cache/azoth-route-features
# Days of route-feature matrices to retain under $(AZOTH_FEATURE_CACHE_DIR).
# Entries are keyed route+max_id+spec_hash+rows_hash, and max_id is the live
# max(id) at run time, so every run mints a generation that can never be hit
# again unless THRESHOLD_MAX_ID pins that exact value. Only same-day reuse
# (general -> specialists -> calibrate) matters; older generations are dead
# weight at ~20G apparent each. Pruned by azoth-prune-feature-cache.
AZOTH_KEEP_CACHE_DAYS ?= 2
AZOTH_SPECIALIST_FOLDS ?= 0
AZOTH_SPECIALIST_ESTIMATORS ?= 400
AZOTH_SPECIALIST_MAX_DEPTH ?= 12
AZOTH_SPECIALIST_LEARNING_RATE ?= 0.05
AZOTH_SPECIALIST_EARLY_STOPPING ?= 50
AZOTH_SPECIALIST_NUM_LEAVES ?= 96
AZOTH_SPECIALIST_MIN_CHILD_SAMPLES ?= 100
AZOTH_SPECIALIST_MIN_BAD ?= 50
AZOTH_SPECIALIST_MIN_GOOD ?= 50
# Multi-seed averaging (item A): K extra seeds trained against the same matrix.
# Default 2 → 3 trained seeds, averaged at predict time. Reduces seed-driven
# prediction variance by ~3× at 3× training cost; matrix extraction (the
# slow part) is shared across seeds. K=2 is the sweet spot — most independent
# variance is captured in the first 3 samples; K>2 hits diminishing returns
# because tree boosters share enough hyperparam-driven structure that the
# noise terms don't actually decorrelate to zero. Setting K=0 reverts to the
# legacy single-model layout (model.txt) — useful for debugging, since
# averaged bundles smear blame across members when something regresses.
#
# 2026-05 update: the default is now 0 (single seed). Observed seed-to-seed
# variance on holdout is ~3pp F1 (verified on go/python/pe specialists from a
# k=3 fold run); averaging trims that to ~0.1-0.5pp at the headline recall,
# which is smaller than the per-pipeline-run noise our eval harness can
# resolve. Setting K=2 burns ~18 GPU hours per publish-train for an unverified
# fraction-of-a-percent of recall — not worth it now that we have honest
# OOF + recall-monotone floor + learned_blend in the pipeline. Bump back to
# K=2 if you measure a real regression on test partition; the infrastructure
# is in place to A/B this cleanly.
AZOTH_SPECIALIST_N_SEED_EXTRAS ?= 2
# Reject pathological seed fits before they enter the averaged specialist
# bundle. A rejected member is replaced with the next deterministic seed, up
# to this per-route retry budget; exhaustion omits the route so the runtime
# falls back to its filegroup/general ensemble instead of shipping bad scores.
AZOTH_SPECIALIST_SEED_HEALTH_MIN_ROC_AUC ?= 0.5
AZOTH_SPECIALIST_SEED_RETRY_BUDGET ?= 3
# OOF specialist training (fold-A / fold-B / route-score merge) doesn't ship
# the resulting bundles to deploy — only their OOF predictions get merged.
# Variance reduction from multi-seed averaging doesn't compound through the
# merge (the OOF prediction for each row already comes from a single fold
# model), so the extra seeds add training cost for no calibration benefit.
# Default to 0 extras (single seed) for OOF runs; override to 2 if you want
# parity with production training for an apples-to-apples comparison.
AZOTH_OOF_SEED_EXTRAS ?= 2
# Companion knob for the fold GENERAL trainings: the standard
# azoth_train_best.py invocation sets EXP_SEED_SEARCH_K=3 (train 3 seeds,
# ship the best). Fold bundles aren't shipped — only their OOF predictions
# merge into general/threshold_scores.npz — so the K-1 discarded seeds
# are pure waste during OOF. Production training keeps K=3.
AZOTH_OOF_SEED_SEARCH_K ?= 1
# Skip the per-specialist benchmark extract+score during fold training.
# benchmark.json's diagnostic metrics are only consumed by
# write_azoth_readmes (which only sees the production bundle), so the
# fold pair's benchmark passes are pure overhead. Saves ~30-60min per
# fold pair. Production specialists.json keeps its full metrics.
AZOTH_OOF_SKIP_BENCHMARK ?= 1
AZOTH_OOF_SKIP_BENCHMARK_ARG := $(if $(filter 1 true yes,$(AZOTH_OOF_SKIP_BENCHMARK)),--skip-benchmark,)
# Feature cache for cross-fold sharing. azoth-prefill-specialist-features
# pre-populates this directory; both fold trainings then read from it via
# --feature-cache-dir, skipping per-fold extraction. Set empty to disable.
AZOTH_SPECIALIST_FEATURE_CACHE_DIR ?= out/cache/azoth-route-features
AZOTH_SPECIALIST_FEATURE_CACHE_ARG := $(if $(AZOTH_SPECIALIST_FEATURE_CACHE_DIR),--feature-cache-dir $(AZOTH_SPECIALIST_FEATURE_CACHE_DIR),)
AZOTH_SPECIALIST_ONLY ?=
AZOTH_SPECIALIST_MASK_SPEC ?=
# Explicit operator overrides win over discovered configurations. Keep these
# empty by default so historical experiments actually determine each route.
AZOTH_SPECIALIST_TRAIN_OVERRIDE ?=
AZOTH_SPECIALIST_FEATURE_ENV ?=
# Where autocollie-driven experiments write run JSONs. Pointing the suite at
# this dir lets it auto-pick each route's best supported historical experiment
# and replay its train_config + feature_env, so `make azoth-specialist-suite`
# uses autocollie's wins by default. Unset to revert to pure CLI/Makefile
# defaults (legacy behavior, autocollie discoveries are dropped on the floor).
AZOTH_AUTOCOLLIE_RUNS_DIR ?= out/experiments/azoth/runs
# The promoted baselines that pick each route's config. Passed explicitly to
# every suite: the suite's fallback looks under its own --output-root, which
# for the fold suites is azoth.oof-fold-{a,b} — so folds silently trained ~15
# routes with a different config than prod, and those OOF scores set prod's
# thresholds.
AZOTH_PROMOTED_BASELINES ?= $(AZOTH_ROOT)/.autocollie/promoted_baselines.json
# Both fold suites fit the same routes on half the rows each, so they share
# one peak-memory file: fold-B is packed by what fold-A measured that night.
AZOTH_FOLD_FIT_MEM_FILE ?= out/cache/fit-mem/fold.json
AZOTH_SPECIALIST_FIT_MEM_FILE ?=
AZOTH_SPECIALIST_SKIP_EXISTING ?= 1
AZOTH_SPECIALIST_SKIP_EXISTING_ARG := $(if $(filter 1 true yes,$(AZOTH_SPECIALIST_SKIP_EXISTING)),--skip-existing,)
# Concurrent specialist trainings. Default derived with the other auto-
# saturation knobs further down (NPROC / EXP_LGBM_THREADS). Set to 1 when
# training on GPU (``--device cuda``) so concurrent jobs don't fight for
# memory.
AZOTH_FILEGROUP_SCORE_FILTER ?= 0
AZOTH_FILEGROUP_SCORE_FILTER_ARG := $(if $(filter 1 true yes,$(AZOTH_FILEGROUP_SCORE_FILTER)),--filegroup-score-filter,)
SAMPLES_DIR ?= /data/samples
FALSE_POSITIVES_ARCHIVE ?= /tmp/false-positives.tgz
FALSE_NEGATIVES_ARCHIVE ?= /tmp/false-negatives.tgz
NEAR_FALSE_POSITIVES_ARCHIVE ?= /tmp/near-false-positives.tgz
NEAR_FALSE_NEGATIVES_ARCHIVE ?= /tmp/near-false-negatives.tgz
NEAR_FALSE_POSITIVES_TRIAGE_DIR ?= /var/tmp/near-false-positives
# {FALSE_POSITIVES,FALSE_NEGATIVES,MISLABELED}_TRIAGE_DIR are now derived
# inside the mislabeled-triage target block from SCOPE/LEVEL/SEVERITY so
# parallel triages under different scopes don't collide on /tmp paths.
SAMPLE ?=
FILE ?=
CLEAVE ?= cleave
DEMO_DB ?= out/demo.db
# Auto-saturate cores. One principle: each LightGBM fit uses 8 threads
# (the empirical sweet spot — past that returns diminish), and concurrent
# fits fill the rest. So total active threads ≈ NPROC. Override any of
# these on the command line if a specific run needs different shape.
NPROC := $(shell nproc 2>/dev/null || echo 4)
EXP_LGBM_THREADS ?= 8
# Scoring is limited by corpus/matrix memory and DB throughput, not fit cores.
# Keep training concurrency independent of the conservative calibration default.
AZOTH_CALIBRATE_PARALLELISM ?= 2
AZOTH_POLICY_PARALLELISM ?= 0
AZOTH_SPECIALIST_PARALLELISM ?= $(shell expr $(NPROC) / $(EXP_LGBM_THREADS))
WORKERS ?= $(NPROC)
EXP_WORKERS ?= $(WORKERS)
AZOTH_THRESHOLD_WORKERS ?= $(EXP_WORKERS)
WORKERS_ARG := $(if $(WORKERS),--workers $(WORKERS),)
EXP_WORKERS_ARG := $(if $(EXP_WORKERS),--workers $(EXP_WORKERS),)
AZOTH_THRESHOLD_WORKERS_ARG := $(if $(AZOTH_THRESHOLD_WORKERS),--workers $(AZOTH_THRESHOLD_WORKERS),)
SEED ?= 42
# Training device. `auto` runs the pick_device() heuristic in
# src/collimator/model.py: it picks CUDA only when the workload is dense
# and large enough to benefit (LightGBM's CUDA path produces garbage on
# sparse high-dim inputs — constant-prediction models, SIGFPE in fit).
# Today every collimator route is sparse + narrow-N, so this resolves to
# CPU in practice. Override with DEVICE=cuda to force-test the GPU path
# or DEVICE=cpu to skip the heuristic.
DEVICE ?= auto
DROP_FEATURE_PREFIXES ?=
# Default azoth screening profile: a probe-sized run for bulk iteration.
# Confirm winners with a different seed, an explicit larger sample, or make azoth-full-train.
EXP_TRAIN_SAMPLES ?= 150000
EXP_MAX_TEST_SAMPLES ?= 40000
EXP_TOTAL_LIMIT ?= 0
EXP_MAX_ID ?=
EXP_REFRESH_CACHE_SNAPSHOT ?= 0
EXP_REFRESH_CACHE_SNAPSHOT_ARG := $(if $(filter 1 true yes,$(EXP_REFRESH_CACHE_SNAPSHOT)),--refresh-cache-snapshot,)
EXP_RERUN ?= 0
EXP_RERUN_ARG := $(if $(filter 1 true yes,$(EXP_RERUN)),--rerun-existing,)
EXP_ROUTE ?= general
EXP_IDEA ?= $(if $(EXP_TAG),$(patsubst _%,%,$(EXP_TAG)),adhoc)
# Match full training's explicit filter, if any. Otherwise each experiment
# discovers its vocabulary from its training rows and feature knobs.
EXP_ALLOWED_FEATURES_FILE ?= $(abspath $(AZOTH_ALLOWED_FEATURES_FILE))
EXP_FOLDS ?= 0
EXP_HOLDOUT_FRACTION ?= 0.12
EXP_ESTIMATORS ?= 180
EXP_MAX_DEPTH ?= 12
EXP_LEARNING_RATE ?= 0.05
EXP_EARLY_STOPPING ?= 25
EXP_NUM_LEAVES ?= $(if $(filter azoth,$(LEARNER)),96,)
EXP_MIN_CHILD_SAMPLES ?= $(if $(filter azoth,$(LEARNER)),100,)
EXP_MIN_CHILD_WEIGHT ?=
EXP_COLSAMPLE_BYTREE ?= 0.8
EXP_SUBSAMPLE ?= 0.8
EXP_GAMMA ?= 0.0
EXP_REG_ALPHA ?= 0.0
EXP_REG_LAMBDA ?= 1.0
EXP_THRESHOLD_MODE ?= fbeta
EXP_THRESHOLD_FPR_TARGET ?=
EXP_HARD_NEGATIVE_FRACTION ?= 0.0
EXP_HARD_NEGATIVE_WEIGHT ?= 1.0
EXP_BENIGN_FILETYPE_WEIGHT ?=
EXP_MONOTONE_JSON ?=
EXP_SCALE_POS_WEIGHT_MULT ?= 1.0
EXP_MAX_DELTA_STEP ?= 0
EXP_BOOSTING_TYPE ?= gbdt
EXP_EXTRA_TREES ?= 0
EXP_SEED_SEARCH_K ?= 1
EXP_ENSEMBLE_SEEDS ?=
# When EXP_SEED_SEARCH_K>1, set EXP_SAVE_ALL_SEEDS=1 to deploy the averaged
# ensemble (item A) instead of picking-best. Bundle layout becomes
# models/seed_<S>.txt; litmus averages at predict time.
EXP_SAVE_ALL_SEEDS ?= 0
EXP_TEST_NATURAL_PREVALENCE ?= 0
EXP_BETA ?= 2.0
# Asymmetric label-quality gate: keep ALL benigns (EXP_MIN_SAMPLE_SCORE=0) but
# require malware to clear a higher bar. A low-score benign is still benign, and
# score=0 is where the clean curated corpus lives (foraged/harvest/distro/pypi,
# ~2.1M benigns — files cleave found nothing notable in). Excluding them starves
# benign-poor routes into collapse: at floor 1, filetypes/rtf kept only 76
# benigns vs ~5000 malware and went to AUC 0.50 / 100% benign-FP; at floor 0
# (449 benigns) the same route is perfect. Malicious git checkouts / multi-file
# samples label every file `bad`, but most are benign filler that scores low —
# they don't separate from real benigns and poison weak routes (e.g. filetypes/c
# had 2,367 of 6,599 "malware" at score=1). Dropping malware below this score
# from TRAINING (test set unchanged) focuses the model on confident malware.
# autocollie tunes this (knobs.json: min_malware_score, 0-9). Conceptually the
# non-archive analog of skip-benign-archive-item.
# 2026-06-05: this is now the PRIMARY label-quality gate (benigns dropped to
# the >= 1 floor via COLLIMATOR_MIN_SAMPLE_SCORE — filtering them starves
# routes into FPs). A score-sweep (scripts/sweep_malware_score.py) over go/c/
# ruby showed the score=1 malware is genuine noise: flooring go's hostiles at
# 4 lifts recall@0.1%FP from 0.645 -> 0.94 (+30pp at ship operating point),
# helps c slightly, and leaves ruby's tail recall flat. 4 is the global
# default; autocollie tunes per-route (go wants ~5, ruby ~2).
EXP_MIN_MALWARE_SCORE ?= 4
# A Make default must not overwrite autocollie's selected per-route cutoff.
# An explicit command-line/environment value still overrides every route.
AZOTH_MALWARE_SCORE_OVERRIDE = $(if $(filter file default undefined,$(origin EXP_MIN_MALWARE_SCORE)),,$(EXP_MIN_MALWARE_SCORE))
# Ablation 2026-04-10: silent_packer (Exp 43) and mtime_kurtosis (Exp 44) were
# net-negative at 75k experiment scale. air_gap_signal (Exp 46) and the
# extreme-features bundle (Exps 48/49/54/55/56) are kept ON.
EXP_SILENT_PACKER_SIGNAL ?= 0
EXP_MTIME_KURTOSIS ?= 0
EXP_AIR_GAP_SIGNAL ?= 1
EXP_EXTREME_FEATURES ?= 1
# Per-feature toggles within EXTREME_FEATURES bundle (Exps 48/49/51/54/55/56).
# Each defaults to inheriting from EXTREME_FEATURES; override individually for ablations.
EXP_ANACHRONISTIC_INJECTION ?=
EXP_CODE_ENTROPY_SPIKE ?=
EXP_FOREIGN_BINARY_SIGNAL ?=
EXP_EXTENSION_MISMATCH_SIGNAL ?=
EXP_HOSTILE_FINDING_DENSITY ?=
EXP_HOSTILE_DEPTH_WEIGHT ?=
# v16 default OFF: drops 163k inter:{ft}*{element/skeleton} features that contribute essentially nothing.
EXP_FILETYPE_INTERACTIONS ?= 0
EXP_FORMAT_HINTS ?= 0
# Default-on toggles in features.py — overridable for ablations.
EXP_BLINDFOLD ?= 1
EXP_SCORE_WEIGHTED_TRAITS ?= 1
EXP_SOFT_PRESENCE ?= 1
EXP_REPETITION_PENALTY_FEATURES ?= 1
EXP_FILE_SEVERITY_DISTRIBUTION ?= 1
# Size-invariant crit-tier severity fractions (opt-in; autocollie A/B candidate).
EXP_SEVERITY_FRACTION_FEATURES ?= 0
EXP_HOSTILE_WEIGHTED_DENSITY ?= 1
EXP_HOSTILE_ESCALATION_FEATURES ?= 1
EXP_SUSPICIOUS_BREADTH_DENSITY ?= 1
EXP_STRUCT_FILE_RISK_COVERAGE ?= 1
EXP_TOP_K_RISK_FILES ?= 1
EXP_MIN_SAMPLE_SCORE ?= 0
EXP_TAXONOMY_FEATURES ?= 0
EXP_EXTENDED_METRICS ?= 1
EXP_METRIC_MIN_FREQ_PCT ?= 5
EXP_EMBER_LITE_FEATURES ?= 0
EXP_BIGRAM_MAX ?= 5000
EXP_BIGRAM_MIN_FREQ ?= 5
EXP_TRIGRAM_MAX ?= 500
EXP_TRIGRAM_MAX_BENIGN_FRAC ?= 0.01
EXP_CONFIDENCE_WEIGHTED_NGRAMS ?= 0
EXP_OBJECTIVE_TRIGRAMS ?= 0
EXP_SUSPICIOUS_TRIGRAMS ?= 0
EXP_ATTACK_NGRAMS ?= 0
# Previously hardcoded to 1 in the experiment recipe; now toggleable so
# autocollie can ablate them. Defaults preserve historical behavior.
EXP_ATTACK_FEATURES ?= 1
EXP_ATTACK_CODE_NGRAMS ?= 1
EXP_CRIT_CATEGORY_NGRAMS ?= 1
EXP_TRAIT_PATH_DEPTH ?= 3
EXP_TIERED_CRIT_BIGRAMS ?= 1
EXP_TIERED_BIGRAM_MIN_CRIT ?= 3
EXP_TIERED_BIGRAM_MAX ?= 5000
EXP_TIERED_BIGRAM_MIN_FREQ ?= 5
EXP_TIERED_CRIT_TRIGRAMS ?= 0
EXP_TIERED_TRIGRAM_MIN_CRIT ?= 3
EXP_TIERED_TRIGRAM_MAX ?= 5000
EXP_TIERED_TRIGRAM_MIN_FREQ ?= 5
EXP_SYMBOL_VOCAB ?= 0
EXP_SYMBOL_VOCAB_MAX ?= 5000
EXP_SYMBOL_MIN_FREQ ?= 5
EXP_KV_VOCAB ?= 0
EXP_KV_VOCAB_MAX ?= 5000
EXP_KV_MIN_FREQ ?= 5
EXP_KV_SHAPE_FEATURES ?= 0
EXP_RULE_KEYWORDS ?= 0
EXP_DESCRIPTION_KEYWORDS ?= 0
EXP_FULL_RULE_KEYWORDS ?= 0
EXP_RULE_DESCRIPTION_KEYWORDS ?= 0
EXP_CONTEXT_KEYWORDS ?= 0
EXP_ARCHIVE_MEMBER_CONTEXT ?= 0
EXP_KEYWORD_VOCAB_MAX ?= 10000
EXP_KEYWORD_MIN_FREQ ?= 5
EXP_KEYWORD_SELECTION ?= frequency
EXP_KEYWORD_CANDIDATE_MAX ?= 100000
EXP_RULE_KEYWORD_VOCAB_MAX ?= 0
EXP_RULE_KEYWORD_MIN_FREQ ?= 0
EXP_RULE_KEYWORD_SELECTION ?= inherit
EXP_DESCRIPTION_KEYWORD_VOCAB_MAX ?= 0
EXP_DESCRIPTION_KEYWORD_MIN_FREQ ?= 0
EXP_DESCRIPTION_KEYWORD_SELECTION ?= inherit
EXP_FULL_RULE_KEYWORD_VOCAB_MAX ?= 0
EXP_FULL_RULE_KEYWORD_MIN_FREQ ?= 0
EXP_FULL_RULE_KEYWORD_SELECTION ?= inherit
EXP_RULE_DESCRIPTION_KEYWORD_VOCAB_MAX ?= 0
EXP_RULE_DESCRIPTION_KEYWORD_MIN_FREQ ?= 0
EXP_RULE_DESCRIPTION_KEYWORD_SELECTION ?= inherit
EXP_CONTEXT_KEYWORD_VOCAB_MAX ?= 0
EXP_CONTEXT_KEYWORD_MIN_FREQ ?= 0
EXP_CONTEXT_KEYWORD_SELECTION ?= inherit
# Batch 1 — cheap metric extracts (default off; autocollie can toggle each).
EXP_PE_FORMAT_FLAGS ?= 0
EXP_PE_TEMPORAL_ANOMALY ?= 0
EXP_TEXT_METRICS_FULL ?= 0
EXP_OVERLAY_SIGNAL ?= 0
EXP_METRIC_RATIO_FEATURES ?= 0
EXP_SIZE_NORMALIZED_METRICS ?= 0
EXP_NONSTANDARD_SECTION_SIGNAL ?= 0
EXP_LINE_LENGTH_BUCKETS ?= 0
# Batch 2 — allowlist + filter knobs (default off / no filter).
EXP_EXTENDED_METRICS_INCLUDE ?=
EXP_TOP_K_RISK_FILES_MIN_CRIT ?= 0
EXP_METRIC_CORRELATION_PAIRS ?=
EXP_KV_VALUE_SPLIT ?= 0
# Batch 3 — symbol & string n-grams (default off; trigram_min_freq is the
# symmetry knob and defaults to the previously-hardcoded value of 5).
EXP_SYMBOL_BIGRAMS ?= 0
EXP_SYMBOL_BIGRAM_MAX ?= 5000
EXP_SYMBOL_MIN_FREQ_BIGRAM ?= 10
EXP_SYMBOL_TRIGRAMS ?= 0
EXP_SYMBOL_TRIGRAM_MAX ?= 2000
EXP_SYMBOL_MIN_FREQ_TRIGRAM ?= 10
EXP_TRIGRAM_MIN_FREQ ?= 5
EXP_TIERED_CRIT_QUADGRAMS ?= 0
EXP_TIERED_QUADGRAM_MIN_CRIT ?= 3
EXP_TIERED_QUADGRAM_MAX ?= 5000
EXP_TIERED_QUADGRAM_MIN_FREQ ?= 5
# Batch 4 — trait & taxonomy extensions (default off / no overrides).
EXP_MBC_ID_VOCAB ?= 0
EXP_TRAIT_CONFIDENCE_MOMENTS ?= 0
EXP_TRAIT_ID_LEXICAL_DISTANCE ?= 0
EXP_DOCUMENT_OBFUSCATION_FEATURES ?= 0
EXP_TIERED_BIGRAM_BRANCH_MIN_CRIT ?=
EXP_DISABLE_FEATURE_GROUPS ?= clusters
# packaged_capability compute mode: zero | chars | tokens | paths | findings
EXP_PACKAGED_CAPABILITY_MODE ?= paths
# Experiment data cache directory. When set, corpus selections and extracted
# matrices are cached to disk so repeated experiments skip expensive DB scans.
EXP_CACHE_DIR ?= out/cache/experiment/$(MODEL)
ABLATE_CACHE_DIR ?= $(EXP_CACHE_DIR)
ABLATE_MAX_ID ?=
ALLOWED_FEATURES ?= src/collimator/allowed_features.json
# Validate DB is set for targets that need it
check-db:
ifndef DB
$(error DB is required. Usage: make azoth-full-train DB=postgres://hopper@localhost/hopper)
endif
# Fail if the newest sample is older than 24 hours (replication may be broken)
check-db-fresh: check-db
@age_hours=$$(psql "$(DB)" -tAc \
"SELECT EXTRACT(EPOCH FROM now() - MAX(updated_at)) / 3600 FROM samples;") ; \
age_hours=$${age_hours%.*} ; \
if [ "$$age_hours" -gt 2 ] 2>/dev/null; then \
echo "ERROR: newest sample is $${age_hours}h old (>2h). Check logical replication status:" >&2 ; \
echo " sudo -u postgres psql -d hopper -c \"SELECT * FROM pg_stat_subscription;\"" >&2 ; \
echo " tail -20 /var/log/postgresql/postgresql-17-main.log" >&2 ; \
exit 1 ; \
fi
venv: $(VENV_DIR)/.deps.stamp
$(VENV_DIR)/bin/python:
# A venv can be left with a dangling interpreter symlink after its base
# Python is removed (or the venv is moved). Clear it before recreating so
# `venv` does not trip over the stale bin/python3 entry.
rm -f $(VENV_DIR)/.deps.stamp
python3 -m venv --clear $(VENV_DIR)
$(VENV_DIR)/.deps.stamp: requirements.txt pyproject.toml | $(VENV_DIR)/bin/python
# Bootstrap pip if missing — handles the case where the venv was created
# by `uv venv` (which omits pip) instead of `python -m venv`. Using
# `python -m pip` rather than `pip` keeps us agnostic to the unversioned
# `pip` symlink, which `ensurepip` doesn't always install.
$(VENV_DIR)/bin/python -m ensurepip --upgrade
$(VENV_DIR)/bin/python -m pip install --upgrade pip
$(VENV_DIR)/bin/python -m pip install -r requirements.txt
$(VENV_DIR)/bin/python -m pip install -e .
touch $(VENV_DIR)/.deps.stamp
# azoth-{full,fast}-train: the two top-level "give me the latest best deployed
# model" entry points. Both run the same chain — replay autocollie's
# highest-F1 historical run for general → retrain every specialist with
# autocollie's per-route best → calibrate + deploy — but at different
# fidelities. Pick one explicitly; there is no `make azoth-train` shortcut
# because the speed/quality trade-off is real and worth being conscious of.
#
# make azoth-full-train — train on the full labeled corpus (~2M rows in
# natural distribution). ~7-8 hours end-to-end at K=3 seeds. Use this
# for deploy-bound retrains: the natural distribution preserves the
# benign tail that 50/50 sampling discards, which is what determines
# L25 (one calibration FP plus 24 extrapolated FP/100M) threshold quality.
#
# make azoth-fast-train — train on a 600k 50/50-balanced sample. ~5 hours
# end-to-end at K=3 seeds. Same fidelity autocollie's promote step uses
# internally, so this matches what an autocollie auto-promote would have
# produced. Use for fast iteration; the candidate is still deployable.
#
# Both:
# * Always retrain from scratch on current DB state (snapshot-pinned, no
# model cache).
# * K=3 multi-seed averaging.
# * Run azoth-specialists + azoth-deploy on completion.
# * Override individual knobs via DEPLOY_ESTIMATORS, DEPLOY_TRAIN_SAMPLES,
# DEPLOY_MAX_TEST_SAMPLES if you need a custom profile.
DEPLOY_ESTIMATORS ?= 400
DEPLOY_TRAIN_SAMPLES_FULL ?= 0
DEPLOY_MAX_TEST_SAMPLES_FULL ?= 0
DEPLOY_TRAIN_SAMPLES_FAST ?= 600000
DEPLOY_MAX_TEST_SAMPLES_FAST ?= 80000
# Default the fidelity for any standalone azoth-general / azoth-general-fold-*
# invocation to the FULL settings. azoth-full-train passes the _FULL values
# explicitly, and azoth-publish-train hands them to the pipeline script as
# PUBLISH_TRAIN_SAMPLES / PUBLISH_MAX_TEST_SAMPLES (which re-applies them on
# each general's command line), so this default just covers a sub-target called
# directly or scripts/azoth_oof_pipeline.sh run standalone.
# Setting them empty (the prior behavior) would propagate "--train-samples ''"
# through to the experiment CLI and abort it with an empty-arg error.
DEPLOY_TRAIN_SAMPLES ?= $(DEPLOY_TRAIN_SAMPLES_FULL)
DEPLOY_MAX_TEST_SAMPLES ?= $(DEPLOY_MAX_TEST_SAMPLES_FULL)
# Both build the ENTIRE bundle into an isolated run dir
# ($(AZOTH_RUNS_ROOT)/<id>/) and only flip the canonical $(OUT_ROOT)/azoth
# symlink on success via azoth-publish. An interrupted or failed train never
# touches the live bundle, so the previous model stays published and
# serveable (this is what would have saved the in-place wipe that stranded a
# crashed train). Successful runs are retained $(AZOTH_KEEP_RUNS) deep for
# rollback. See the azoth-run-new / azoth-publish / azoth-prune-runs block.
azoth-full-train: venv check-db
@echo "azoth-full-train: building into $(AZOTH_RUN_DIR) (canonical bundle untouched until success)"
@mkdir -p $(AZOTH_RUN_DIR)
$(MAKE) _azoth-train \
AZOTH_ROOT=$(AZOTH_RUN_DIR) \
AZOTH_PROMOTED_BASELINES="$(AZOTH_PROMOTED_BASELINES)" \
DEPLOY_TRAIN_SAMPLES=$(DEPLOY_TRAIN_SAMPLES_FULL) \
DEPLOY_MAX_TEST_SAMPLES=$(DEPLOY_MAX_TEST_SAMPLES_FULL)
$(MAKE) azoth-publish AZOTH_ROOT=$(AZOTH_RUN_DIR)
$(MAKE) azoth-prune-runs
$(MAKE) azoth-prune-feature-cache
@echo "azoth-full-train: published $(OUT_ROOT)/azoth -> $(AZOTH_RUN_DIR)"
azoth-fast-train: venv check-db
@echo "azoth-fast-train: building into $(AZOTH_RUN_DIR) (canonical bundle untouched until success)"
@mkdir -p $(AZOTH_RUN_DIR)
$(MAKE) _azoth-train \
AZOTH_ROOT=$(AZOTH_RUN_DIR) \
AZOTH_PROMOTED_BASELINES="$(AZOTH_PROMOTED_BASELINES)" \
DEPLOY_TRAIN_SAMPLES=$(DEPLOY_TRAIN_SAMPLES_FAST) \
DEPLOY_MAX_TEST_SAMPLES=$(DEPLOY_MAX_TEST_SAMPLES_FAST)
$(MAKE) azoth-publish AZOTH_ROOT=$(AZOTH_RUN_DIR)
$(MAKE) azoth-prune-runs
$(MAKE) azoth-prune-feature-cache
@echo "azoth-fast-train: published $(OUT_ROOT)/azoth -> $(AZOTH_RUN_DIR)"
.PHONY: azoth-profile azoth-profile-scoring
azoth-profile-scoring: venv check-db
@test -n "$(PROFILE_DIR)" || { echo "Set PROFILE_DIR to a new output directory"; exit 1; }
$(VENV_DIR)/bin/python scripts/benchmark_threshold_scoring.py \
--db "$(DB)" --model "$(AZOTH_GENERAL_DIR)" \
--percent "$(PROFILE_PERCENT)" --workers $(PROFILE_SCORING_WORKERS) \
--output "$(PROFILE_DIR)"
azoth-profile: venv check-db
$(VENV_DIR)/bin/python scripts/azoth_profile.py --db "$(DB)" \
--percent "$(PROFILE_PERCENT)" --workers "$(PROFILE_WORKERS)" \
--profiler "$(PROFILE_PROFILER)" --stop-stage "$(PROFILE_STOP_STAGE)" \
--target-minutes "$(PROFILE_TARGET_MINUTES)" \
$(if $(PROFILE_FIT_MEMORY_GB),--fit-memory-gb "$(PROFILE_FIT_MEMORY_GB)",) \
$(if $(PROFILE_EXTRACT_WORKERS_PER_FIT),--extract-workers-per-fit "$(PROFILE_EXTRACT_WORKERS_PER_FIT)",) \
$(if $(PROFILE_DIR),--output "$(PROFILE_DIR)",) \
$(if $(PROFILE_PIN),--pin "$(PROFILE_PIN)",) \
$(if $(filter 1,$(PROFILE_DRY_RUN)),--dry-run,)
# azoth-publish-train additionally computes k=2 OOF diagnostics.
# Fold-model scores cannot calibrate the separately trained production models:
# their score distributions differ. Publication always fits production ONNX
# ensemble scores on dev, then applies the saved policies to locked test.
# Use azoth-full-train when fold diagnostics are unnecessary.
#
# The recipe used to inline all nine steps in sequence, which idled 126 of 128
# cores between LightGBM phases for 7h29m of trainings. It now delegates to
# scripts/azoth_oof_pipeline.sh, which runs the same steps as two concurrent
# dependency chains (prod general->specialists || fold-A gen->fold-B gen->
# fold-A spec->fold-B spec) and pairs the two independent scoring stages. Same
# work, same models, ~11h -> ~7h. The scheduling and the RAM-splitting that
# makes it safe are documented in that script's header. PARALLEL_CHAINS=0
# restores the old strictly-sequential order.
#
# MAKEFLAGS is deliberately NOT cleared: it is how a command-line override
# (`make azoth-publish-train THRESHOLD_MAX_ID=... AZOTH_SPECIALIST_PARALLELISM=...`)
# reaches the script's sub-makes. The per-chain resource split still wins over
# it, because the script passes those on each sub-make's own command line and an
# explicit assignment outranks an inherited one.
#
# WORKERS is additionally handed over through the environment: the script halves
# it per chain, and $(WORKERS) here is the resolved value whether it came from
# the environment, the command line, or the nproc default — none of which the
# script could see on its own when the setting arrived via MAKEFLAGS.
azoth-publish-train: venv check-db
@echo "azoth-publish-train: starting full k=2 OOF run (general + specialists)."
@echo " 3x general + 3x specialists (folds skip benchmark) + OOF merge/route-scores + calibrate + deploy."
@# Read once: edits during hours of training must not shift Bash's file offset.
@pipeline=$$(cat scripts/azoth_oof_pipeline.sh) && \
bash -n <<< "$$pipeline" && \
WORKERS=$(WORKERS) \
PUBLISH_TRAIN_SAMPLES=$(DEPLOY_TRAIN_SAMPLES_FULL) \
PUBLISH_MAX_TEST_SAMPLES=$(DEPLOY_MAX_TEST_SAMPLES_FULL) \
bash -c "$$pipeline" scripts/azoth_oof_pipeline.sh
@echo "azoth-publish-train: complete (validation-only=$(or $(AZOTH_VALIDATE_ONLY),0))."
@echo "azoth-publish-train: archived fold bundles at out/models/azoth.oof-fold-{a,b}/"
# azoth-general: train the general model at deploy fidelity, promote it
# into the source bundle slot, and rebuild threshold_scores against the
# fresh model.
#
# This is the GENERAL-ONLY half of what _azoth-train used to do, split
# out so the OOF-publish flow can train fold-A/-B GENERAL bundles
# without burning compute on specialists that get thrown away. See the
# refactored azoth-publish-train below.
#
# Three steps:
# 1. Train general at deploy fidelity (writes to out/experiments/azoth/).
# 2. Promote the freshly-trained general into the source bundle slot
# (out/models/azoth/general/) so azoth-deploy actually picks it up —
# `make experiment` writes to a different location than azoth-deploy
# reads from, and without this step the deploy ships a stale general.
# 3. Rescore threshold_scores against the freshly-promoted model.
#
# Set AZOTH_GENERAL_SKIP_RESCORE=1 to skip step 3 — used by the fold
# variants below since azoth_oof_score.py does its own corpus-wide
# scoring after merging the two fold bundles, making this rescore a
# multi-hour wasted pass for fold builds.
azoth-general: venv check-db azoth-clean-bundle
$(PYTHON) scripts/azoth_train_best.py \
--runs-dir "$(AZOTH_AUTOCOLLIE_RUNS_DIR)" \
--promoted-baselines "$(AZOTH_PROMOTED_BASELINES)" \
--route general \
--set DB=$(DB) \
--set EXP_ALLOWED_FEATURES_FILE=$(abspath $(AZOTH_ALLOWED_FEATURES_FILE)) \
--set COLLIMATOR_MIN_SAMPLE_SCORE=$(EXP_MIN_SAMPLE_SCORE) \
--default-min-malware-score $(EXP_MIN_MALWARE_SCORE) \
$(if $(AZOTH_MALWARE_SCORE_OVERRIDE),--set EXP_MIN_MALWARE_SCORE=$(AZOTH_MALWARE_SCORE_OVERRIDE),) \
--set EXP_TRAIN_SAMPLES=$(DEPLOY_TRAIN_SAMPLES) \
--set EXP_MAX_TEST_SAMPLES=$(DEPLOY_MAX_TEST_SAMPLES) \
--set EXP_ESTIMATORS=$(DEPLOY_ESTIMATORS) \
$(if $(AZOTH_GENERAL_SEED_SEARCH_K),--set EXP_SEED_SEARCH_K=$(AZOTH_GENERAL_SEED_SEARCH_K),) \
$(if $(AZOTH_GENERAL_SEED_SEARCH_K),--set EXP_SAVE_ALL_SEEDS=0,) \
$(if $(WORKERS),--set EXP_WORKERS=$(WORKERS),)
@# Promote the freshly-trained general into the source bundle slot.
@# `make experiment` wrote to out/experiments/azoth/{models/,model.txt,
@# feature_spec.json}; copy whichever layout it used into the deployed
@# location, replacing whatever was there. Atomic-ish: the per-file
@# `cp` and `rm -rf` are sequenced so an interrupted run leaves the
@# slot in a clean (older) state rather than half-overwritten.
@# Promote from $(EXP_OUT_DIR) — NOT a hardcoded out/experiments/azoth.
@# Hardcoding here used to silently break fold parallelism: both
@# fold-A and fold-B wrote to the same path and clobbered each
@# other's freshly-trained models. Honoring $(EXP_OUT_DIR) lets
@# the fold targets point each invocation at its own workspace.
@mkdir -p $(AZOTH_GENERAL_DIR)
@if [ -d $(EXP_OUT_DIR)/models ]; then \
rm -rf $(AZOTH_GENERAL_DIR)/models $(AZOTH_GENERAL_DIR)/model.txt $(AZOTH_GENERAL_DIR)/model.json $(AZOTH_GENERAL_DIR)/model.onnx; \
cp -a $(EXP_OUT_DIR)/models $(AZOTH_GENERAL_DIR)/models; \
elif [ -f $(EXP_OUT_DIR)/model.txt ]; then \
rm -rf $(AZOTH_GENERAL_DIR)/models $(AZOTH_GENERAL_DIR)/model.txt $(AZOTH_GENERAL_DIR)/model.json $(AZOTH_GENERAL_DIR)/model.onnx; \
cp -a $(EXP_OUT_DIR)/model.txt $(AZOTH_GENERAL_DIR)/model.txt; \
else \
echo "error: azoth_train_best did not produce a general model in $(EXP_OUT_DIR)/"; \
exit 1; \
fi
cp -a $(EXP_OUT_DIR)/feature_spec.json $(AZOTH_GENERAL_DIR)/feature_spec.json
@if [ -f $(EXP_OUT_DIR)/model.onnx ]; then cp -a $(EXP_OUT_DIR)/model.onnx $(AZOTH_GENERAL_DIR)/model.onnx; fi
@rm -f $(AZOTH_GENERAL_DIR)/training_provenance.json
@if [ -f $(EXP_OUT_DIR)/training_provenance.json ]; then cp -a $(EXP_OUT_DIR)/training_provenance.json $(AZOTH_GENERAL_DIR)/training_provenance.json; fi
@# Rebuild the general's threshold_scores cache against the freshly-
@# promoted model. Without this, azoth-calibrate would consume a stale
@# score table whose probabilities map to a previous model — calibrators
@# and L0..L100 thresholds would be fit on the wrong score distribution.
@if [ "$(AZOTH_GENERAL_SKIP_RESCORE)" = "1" ]; then \
echo "AZOTH_GENERAL_SKIP_RESCORE=1: skipping threshold_scores rescore"; \
else \
set -e; \
echo "rescoring general against full labeled corpus -> $(AZOTH_GENERAL_SCORES)"; \
$(PYTHON) -u -m collimator tune-thresholds --db $(DB) \
--model "$(AZOTH_GENERAL_DIR)" --scores-only \
--spec $(AZOTH_GENERAL_DIR)/feature_spec.json \
$(WORKERS_ARG) \
--scores-cache $(AZOTH_GENERAL_SCORES) \
--refresh-cache \
--output $(AZOTH_GENERAL_DIR)/threshold_tuning.json; \
fi
# Fold-aware general training. Each variant trains general with one OOF
# fold held out, then archives the bundle to azoth.oof-fold-{a,b}/ for
# later consumption by azoth-oof-merge-general. Skips the threshold_scores
# rescore — those scores get replaced by the OOF merge anyway, so doing it
# here is a multi-hour wasted pass.
#
# Why a separate target rather than a shell variable: the fold-A and
# fold-B bundles need DISTINCT on-disk locations (different stash dirs).
# A single parameterized target couldn't run both back-to-back without
# the second one overwriting the first's bundle slot.
azoth-general-fold-a: venv check-db
@# Run training in a fold-specific workspace and deploy slot so that
@# concurrent chains (PARALLEL_CHAINS=1) don't race over EXP_OUT_DIR /
@# AZOTH_ROOT. The EXP_CACHE_DIR is intentionally NOT fold-suffixed —
@# corpus + matrix caches are content-hashed (oof_fold_exclude is in
@# the key), so both folds can share that directory and benefit from
@# cross-run cache hits.
EXP_OOF_FOLD_EXCLUDE=0 $(MAKE) azoth-general \
AZOTH_ROOT=$(OUT_ROOT)/azoth.oof-fold-a \
EXP_OUT_DIR=$(AZOTH_FOLD_A_EXP_OUT_DIR) \
AZOTH_GENERAL_SKIP_RESCORE=1 \
AZOTH_GENERAL_SEED_SEARCH_K=$(AZOTH_OOF_SEED_SEARCH_K)
@echo "fold-A bundle ready at $(OUT_ROOT)/azoth.oof-fold-a/"
azoth-general-fold-b: venv check-db
EXP_OOF_FOLD_EXCLUDE=1 $(MAKE) azoth-general \
AZOTH_ROOT=$(OUT_ROOT)/azoth.oof-fold-b \
EXP_OUT_DIR=$(AZOTH_FOLD_B_EXP_OUT_DIR) \
AZOTH_GENERAL_SKIP_RESCORE=1 \
AZOTH_GENERAL_SEED_SEARCH_K=$(AZOTH_OOF_SEED_SEARCH_K)
@echo "fold-B bundle ready at $(OUT_ROOT)/azoth.oof-fold-b/"
# Merge the two fold-trained general bundles into honest OOF probabilities.
# Reads from the stash dirs created by azoth-general-fold-{a,b}, writes
# diagnostic scores under oof_route_scores/general, separate from calibration. Pulled
# out of azoth-publish-train so it's individually rerunnable when only
# the merge needs refreshing.
azoth-oof-merge-general: venv check-db
$(PYTHON) scripts/azoth_oof_score.py \
--db $(DB) \
--fold-a-bundle $(OUT_ROOT)/azoth.oof-fold-a \
--fold-b-bundle $(OUT_ROOT)/azoth.oof-fold-b \
--prod-bundle $(AZOTH_ROOT) \
--output $(AZOTH_OOF_ROUTE_SCORES_DIR)/general/threshold_scores.npz \
$(WORKERS_ARG) \
$(THRESHOLD_MAX_ID_ARG)
# _azoth-train: shared body for the two named targets above. Not a public
# entry point — pick azoth-full-train or azoth-fast-train explicitly.
#
# Calls azoth-general (which does train + promote + rescore), then trains
# specialists and deploys. The split lets fold-aware OOF training reuse
# the general half without the specialist+deploy tail.
_azoth-train: azoth-general
$(MAKE) azoth-specialists AZOTH_SPECIALIST_SKIP_EXISTING=0
$(MAKE) azoth-deploy
$(MAKE) azoth-shap
# Per-route SHAP feature importance for the whole bundle. Pure inference on the
# trained LightGBM boosters (~seconds/route), so it runs on EVERY train — right
# after azoth-deploy, so it covers exactly the routes that survived the
# deploy-time weakness prune. Written into each route dir under $(AZOTH_ROOT)
# (i.e. out/) and INTENTIONALLY NOT staged into the deployed bundle:
# stage_azoth_runtime_bundle.py omits shap_importance.json, since SHAP is an
# internal debugging aid (ascan --extra), not a published artifact. Fatal on
# error so a systemic break (e.g. the XGBoost->LightGBM regression) surfaces
# loudly instead of silently leaving stale SHAP behind. Uses the first seed per
# route — global importance is stable across seeds.
AZOTH_SHAP_SCAN_LIMIT ?= 10000
azoth-shap: venv check-db
@for d in $(AZOTH_ROOT)/general $(AZOTH_ROOT)/filegroups/* $(AZOTH_ROOT)/filetypes/*; do \
[ -d "$$d" ] || continue; \
[ -f "$$d/feature_spec.json" ] || continue; \
m="$$d/models/seed_42.txt"; [ -f "$$m" ] || m="$$d/model.txt"; \
if [ ! -f "$$m" ]; then echo "azoth-shap: skip $$d (no .txt booster)"; continue; fi; \
echo "azoth-shap: $$d"; \
$(PYTHON) -m collimator explain --db $(DB) --model "$$m" \
--spec "$$d/feature_spec.json" --output "$$d" \
--scan-limit $(AZOTH_SHAP_SCAN_LIMIT) || exit 1; \
done
@echo "azoth-shap: wrote per-route shap_importance.json under $(AZOTH_ROOT) (kept in out/, not staged into deploy)"
evaluate: venv check-db
$(PYTHON) -m collimator evaluate --db $(DB) --model $(OUT_DIR)/model.onnx --spec $(OUT_DIR)/feature_spec.json
explain: venv check-db
$(PYTHON) -m collimator explain --db $(DB) --model $(OUT_DIR)/$(MODEL_FILE) --spec $(OUT_DIR)/feature_spec.json --output $(OUT_DIR)
inspect: venv check-db
ifndef SAMPLE
$(error SAMPLE is required. Usage: make inspect DB=... SAMPLE=<sha256>)
endif
$(PYTHON) -m collimator inspect --db $(DB) --sample $(SAMPLE) --model $(OUT_DIR)/$(MODEL_FILE) --spec $(OUT_DIR)/feature_spec.json
errors: venv check-db
$(PYTHON) -m collimator errors --db $(DB) --model $(OUT_DIR)/$(MODEL_FILE) --spec $(OUT_DIR)/feature_spec.json
traits: venv check-db
$(PYTHON) -m collimator traits --db $(DB)
thresholds: venv check-db
$(PYTHON) -u -m collimator tune-thresholds --db $(DB) \
--model $(OUT_DIR)/$(MODEL_FILE) \
--spec $(OUT_DIR)/feature_spec.json \
$(WORKERS_ARG) \
$(THRESHOLD_MAX_ID_ARG) \
--scores-cache $(THRESHOLD_SCORES) \
--top-errors $(THRESHOLD_TOP_ERRORS) \
--output $(OUT_DIR)/threshold_tuning.json
thresholds-refresh: venv check-db
$(PYTHON) -u -m collimator tune-thresholds --db $(DB) \
--model $(OUT_DIR)/$(MODEL_FILE) \
--spec $(OUT_DIR)/feature_spec.json \
$(WORKERS_ARG) \
$(THRESHOLD_MAX_ID_ARG) \
--scores-cache $(THRESHOLD_SCORES) \
--refresh-cache \
--top-errors $(THRESHOLD_TOP_ERRORS) \
--output $(OUT_DIR)/threshold_tuning.json
# repin: drop the pinned snapshot_max_id so the next experiment-style
# invocation re-queries the live max(id). Chain it before the target you
# actually want to run, e.g.:
#
# make repin azoth-full-train DB=...
#
# Make builds command-line goals left-to-right by default, so `repin` runs
# first, then the next target rebuilds the pin from the current DB.
# Prints the current pin (so you have a revert path) before clearing or
# re-setting. Pass PIN_TO=<id> to atomically replace the pin with a
# specific id (useful when reverting to the value an earlier `make repin`
# printed). Default behavior with no PIN_TO clears the pin so the next
# experiment re-queries max(id).
repin:
@if [ -f $(EXP_CACHE_DIR)/snapshot_max_id.txt ]; then \
echo "previous pin: $$(cat $(EXP_CACHE_DIR)/snapshot_max_id.txt) (revert with: make repin PIN_TO=$$(cat $(EXP_CACHE_DIR)/snapshot_max_id.txt))"; \
else \
echo "previous pin: (none)"; \
fi
@if [ -n "$(PIN_TO)" ]; then \
mkdir -p $(EXP_CACHE_DIR); \
echo "$(PIN_TO)" > $(EXP_CACHE_DIR)/snapshot_max_id.txt; \
echo "set snapshot pin: $(EXP_CACHE_DIR)/snapshot_max_id.txt -> $(PIN_TO)"; \
else \
rm -f $(EXP_CACHE_DIR)/snapshot_max_id.txt; \
echo "cleared snapshot pin: $(EXP_CACHE_DIR)/snapshot_max_id.txt (next invocation will re-query max(id))"; \
fi
# azoth-clean-bundle: wipe regen-able deployed artifacts from the source
# bundle slot at $(AZOTH_ROOT). Caches under */cache/ are preserved (rebuilding
# feature matrices is expensive and they're keyed by snapshot+model anyway).
# Run before any retrain to guarantee no leftover seed_*.txt, calibrator.json,
# benchmark.json, etc. from a prior generation pollutes the new bundle.
#
# Categories cleaned:
# - top-level deployed artifacts (config.json, score_table.npz, *.md, *.csv, *.json)
# - per-route deployed artifacts (model.{txt,json,onnx}, feature_spec.json, calibrator.json,
# benchmark.json, README.md, threshold_scores.npz, calibration_scores.npz,
# threshold_tuning.json)
# - per-route models/ subdirectories (catches multi-seed leftovers like
# seed_42.txt + a stale seed_45.txt from an earlier K=4 run)
#
# Categories preserved:
# - $(AZOTH_GENERAL_DIR)/cache/ (route feature matrices, snapshot-keyed)
# - configured auxiliary artifacts and .retained_components/ identity manifest
# - any sibling directories not matching the above patterns
azoth-clean-bundle: venv
$(PYTHON) scripts/clean_azoth_bundle.py --azoth-root "$(AZOTH_ROOT)"
# azoth-check-bundle-layout: fail fast on an ambiguous bundle layout — a stale
# top-level model.{onnx,txt,json} (a pre-multi-seed fossil) left sitting next to
# a freshly-trained models/seed_* dir. bundle.model_files() rejects this, but not