aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aetherscan/cli.py ADDED
@@ -0,0 +1,3032 @@
1
+ """
2
+ CLI argument parsing for Aetherscan Pipeline
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import shutil
13
+ from dataclasses import dataclass, field, is_dataclass
14
+ from dataclasses import fields as dataclass_fields
15
+ from datetime import datetime
16
+ from math import gcd, lcm
17
+ from typing import Any
18
+
19
+ from aetherscan.config import InferenceConfig, get_config
20
+ from aetherscan.run_state import (
21
+ _INFERENCE_FINGERPRINT_DATA_KEYS,
22
+ _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Validation primitives are expressed as global variables and shared by
29
+ # validate_args() and utility scripts (e.g. utils/find_optimal_configs.py)
30
+
31
+ # Tag scheme (see resolve_save_tag): a run's save-tag is `{command}_{YYYYMMDD_HHMMSS}`. The user
32
+ # supplies only the command prefix on --save-tag; the datetime is appended at runtime. --load-tag
33
+ # takes a full resolved tag (resume that run in place) or a per-round checkpoint (round_XX).
34
+ _SAVE_TAG_PREFIXES = ("test", "train", "inf", "bench")
35
+ # --save-tag accepts one of the prefixes above (bare); the datetime is auto-appended.
36
+ _SAVE_TAG_PATTERN = re.compile(r"^(?:test|train|inf|bench)$")
37
+ # A fully-resolved run tag `{prefix}_{YYYYMMDD_HHMMSS}` — used to detect a resume-in-place --load-tag.
38
+ _FULL_TAG_PATTERN = re.compile(r"^(?:test|train|inf|bench)_\d{8}_\d{6}$")
39
+ # --load-tag accepts a full resolved tag (resume that run in place) or a per-round checkpoint.
40
+ _LOAD_TAG_PATTERN = re.compile(r"^(?:(?:test|train|inf|bench)_\d{8}_\d{6}|round_\d+)$")
41
+
42
+ # The round_XX subset of _LOAD_TAG_PATTERN: per-round checkpoints are saved under the checkpoints/
43
+ # subdirectory, so loading one requires --load-dir checkpoints (issue #142)
44
+ _ROUND_TAG_PATTERN = re.compile(r"^round_\d+$")
45
+
46
+ # Default --save-tag prefix per subcommand when --save-tag is omitted.
47
+ _COMMAND_TAG_PREFIX = {"train": "train", "inference": "inf"}
48
+
49
+
50
+ def resolve_save_tag(command: str | None, save_tag: str | None, load_tag: str | None) -> str:
51
+ """Resolve the run's full `{prefix}_{YYYYMMDD_HHMMSS}` save-tag at runtime.
52
+
53
+ - A full `{prefix}_{datetime}` ``--load-tag`` resumes that run **in place**: its tag is
54
+ adopted so the resumed attempt writes under the same ``run_state`` / DB rows / artifacts.
55
+ (A ``round_XX`` ``--load-tag`` does NOT adopt — it seeds a fresh run from that checkpoint.)
56
+ - Otherwise the tag is ``{prefix}_{datetime}``, where the prefix is ``--save-tag``
57
+ (``test|train|inf|bench``) or, when omitted, the subcommand default (``train`` → ``train``,
58
+ ``inference`` → ``inf``). The datetime is stamped once, here, at run time.
59
+ """
60
+ if load_tag is not None and _FULL_TAG_PATTERN.match(load_tag):
61
+ return load_tag
62
+ if save_tag is not None:
63
+ prefix = save_tag
64
+ elif command in _COMMAND_TAG_PREFIX:
65
+ prefix = _COMMAND_TAG_PREFIX[command]
66
+ else:
67
+ # Fail loudly rather than emit an unloadable tag (a prefix outside {test,train,inf,bench}
68
+ # would not match _LOAD_TAG_PATTERN, so its artifacts could never be --load-tag'd).
69
+ raise ValueError(
70
+ f"Cannot derive a save-tag prefix for command {command!r}; pass --save-tag explicitly."
71
+ )
72
+ return f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
73
+
74
+
75
+ # sklearn's RandomForestClassifier accepts these string values for max_features
76
+ _RF_MAX_FEATURES_STR_VALUES = {"sqrt", "log2"}
77
+
78
+ # Allowed values for curriculum_schedule
79
+ _CURRICULUM_SCHEDULES = {"linear", "exponential", "step"}
80
+
81
+ # Allowed values for bandpass_method (energy-detection bandpass flattening)
82
+ _BANDPASS_METHODS = {"pfb", "spline"}
83
+
84
+ # HuggingFace Hub repo ids are "namespace/name" (user or org namespace)
85
+ _HF_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$")
86
+
87
+
88
+ @dataclass
89
+ class ValidationError:
90
+ """A single validation failure with enough structure for utility scripts to propose fixes."""
91
+
92
+ field: str # e.g. "training.effective_batch_size"
93
+ current: Any
94
+ message: str
95
+ fix_kind: str # one of: clamp_low, clamp_high, range, enum, divisibility, file_exists, cross_param, format
96
+ min_val: Any = None
97
+ max_val: Any = None
98
+ allowed: Any = None
99
+ divisor: Any = None
100
+ extra: dict = field(default_factory=dict)
101
+
102
+
103
+ def _resolve(args: argparse.Namespace, arg_name: str, default: Any) -> Any:
104
+ """Return `args.<arg_name>` if present and not None, otherwise `default` (typically the config value at startup time)."""
105
+ val = getattr(args, arg_name, None)
106
+ return val if val is not None else default
107
+
108
+
109
+ def _estimate_round_data_nbytes(
110
+ n_samples: int,
111
+ num_observations: int,
112
+ time_bins: int,
113
+ width_bin_downsampled: int,
114
+ bytes_per_element: int = 4,
115
+ ) -> int:
116
+ """
117
+ Estimate the on-disk size of one round's disk-backed dataset (see round_data.py): three
118
+ cadence arrays of shape (n_samples, num_observations, time_bins, width_bin_downsampled)
119
+ at `bytes_per_element` (4 for the float32 default, 2 under
120
+ training.round_array_dtype="float16") plus a tiny U20 labels array. Kept stdlib-only
121
+ (no numpy) so utils/print_cli_help.py can keep importing cli.py without the scientific
122
+ stack.
123
+ """
124
+ per_sample_bytes = num_observations * time_bins * width_bin_downsampled * bytes_per_element
125
+ labels_bytes = n_samples * 20 * 4 # numpy "U20" = 20 UCS-4 code points per label
126
+ return 3 * n_samples * per_sample_bytes + labels_bytes
127
+
128
+
129
+ def _nearest_existing_ancestor(path: str) -> str:
130
+ """Walk up from `path` to the closest directory that exists (for shutil.disk_usage on a
131
+ round-data dir that hasn't been created yet)."""
132
+ probe = os.path.abspath(path)
133
+ while probe and not os.path.exists(probe):
134
+ parent = os.path.dirname(probe)
135
+ if parent == probe:
136
+ break
137
+ probe = parent
138
+ return probe
139
+
140
+
141
+ def _resolve_num_replicas(args: argparse.Namespace) -> int | None:
142
+ """Resolve the replica count to validate cross-replica constraints against, and
143
+ fail fast if an explicitly-requested count is unusable on this host.
144
+
145
+ Returns the count `collect_validation_errors` should check batch/sample divisibility
146
+ against, or None to skip those checks.
147
+
148
+ Resolution:
149
+ - If the user passed --num-replicas, or `config.gpu.num_replicas` is set on the
150
+ singleton, e.g. loaded from a saved JSON config via `apply_saved_config` (flag has
151
+ priority), that value must be >= 1 and <= the number of GPUs TF reports; either
152
+ violation raises ValueError so a bad count hard-stops *before* the divisibility
153
+ checks consume it as a divisor. Otherwise it is returned, so those checks run
154
+ against the same count the strategy will actually use.
155
+ - With no explicit request, the returned count is the number of GPUs TF reports.
156
+ - If TF is unavailable (e.g. a dev box) or reports zero GPUs, None is returned
157
+ regardless of whether a count was requested: nothing can be confirmed against the
158
+ hardware, so the divisibility checks are skipped and handling is deferred to
159
+ `setup_gpu_strategy` (which determines whether to fail on GPU-required runs, or to
160
+ proceed with a CPU-only runtime). Note, the >= 1 check still runs first, so a
161
+ nonsensical explicit request is rejected even here.
162
+
163
+ TF is imported lazily so utility scripts (which call `collect_validation_errors`
164
+ directly and never reach this) don't pay the init cost.
165
+ """
166
+ config = get_config()
167
+ if config is None:
168
+ raise ValueError("get_config() returned None")
169
+
170
+ # Resolve the requested count and remember which source supplied it, so any failure
171
+ # below names the right one (the flag vs. the saved/programmatic config value).
172
+ if getattr(args, "num_replicas", None) is not None:
173
+ requested: int | None = int(args.num_replicas)
174
+ source = "--num-replicas"
175
+ elif config.gpu.num_replicas is not None:
176
+ requested = int(config.gpu.num_replicas)
177
+ source = "config.gpu.num_replicas"
178
+ else:
179
+ requested = None
180
+ source = None
181
+
182
+ # Positivity is checked first and without TF, so a nonsensical explicit request fails
183
+ # fast even on a no-GPU box.
184
+ if requested is not None and requested < 1:
185
+ raise ValueError(
186
+ f"{source} must be >= 1 (or unset to use all available GPUs), got {requested}"
187
+ )
188
+
189
+ # Everything below needs the host's GPU count. If TF is unavailable or reports zero
190
+ # GPUs we can neither detect nor verify a count, so skip the cross-replica checks and
191
+ # defer to setup_gpu_strategy rather than guessing.
192
+ try:
193
+ import tensorflow as tf # noqa: PLC0415
194
+
195
+ gpus = tf.config.list_physical_devices("GPU")
196
+ except Exception:
197
+ return None
198
+ if not gpus:
199
+ return None
200
+ total = len(gpus)
201
+
202
+ # No explicit request: use every GPU TF reports.
203
+ if requested is None:
204
+ return total
205
+
206
+ # Explicit request: never allow more replicas than the host has GPUs — propagating
207
+ # batch/sample sizes validated against the wrong replica count would silently corrupt
208
+ # the run.
209
+ if requested > total:
210
+ raise ValueError(
211
+ f"{source}={requested} exceeds the number of GPUs available on this node "
212
+ f"({total}). Set it to <= {total}, or unset it to use all available GPUs."
213
+ )
214
+ return requested
215
+
216
+
217
+ def setup_argument_parser() -> argparse.ArgumentParser:
218
+ """Build the top-level Aetherscan argparse parser with `train` and `inference` subcommands."""
219
+ parser = argparse.ArgumentParser(
220
+ description="Aetherscan Pipeline -- Breakthrough Listen's first end-to-end production-grade DL pipeline for SETI @ scale"
221
+ )
222
+
223
+ # Add commands. required=True: a subcommand is mandatory. Without it a bare
224
+ # `python -m aetherscan.main` leaves command=None, and main()'s resolve_save_tag(None, ...)
225
+ # would raise a ValueError traceback before the logger exists; argparse instead emits a clean
226
+ # usage error (caught by main()'s SystemExit handler, which prints full help).
227
+ subparsers = parser.add_subparsers(dest="command", required=True, help="Command to execute")
228
+
229
+ # Train command
230
+ _add_train_arguments(subparsers)
231
+ # Inference command
232
+ _add_inference_arguments(subparsers)
233
+
234
+ return parser
235
+
236
+
237
+ def _add_train_arguments(subparsers):
238
+ """Register the `train` subcommand and populate it with all training-mode flags."""
239
+ train_parser = subparsers.add_parser("train", help="Execute training pipeline")
240
+ _add_train_flags_to(train_parser)
241
+
242
+
243
+ # TODO: improve flag help descriptions
244
+ def _add_reproducibility_flags_to(parser):
245
+ """
246
+ Add the shared reproducibility flags (#279) — registered on BOTH subparsers via one
247
+ helper so the train and inference surfaces cannot drift.
248
+ """
249
+ parser.add_argument(
250
+ "--seed",
251
+ type=int,
252
+ default=None,
253
+ help="Root random seed for reproducible runs: every random stream derives from it — data generation, dataset split/shuffles, TF weight init, the VAE sampling layer (training AND inference), the random forest, UMAP/KMeans plot fits, and plot subsampling. Defaults to a concrete value (reproducible out of the box); must be >= 0. To run unseeded, pass --unseeded",
254
+ )
255
+ parser.add_argument(
256
+ "--unseeded",
257
+ action="store_true",
258
+ default=False,
259
+ help="Opt OUT of the seeded default: draw every random stream from OS entropy (non-reproducible). Mutually exclusive with --seed",
260
+ )
261
+ parser.add_argument(
262
+ "--tf-deterministic-ops",
263
+ action=argparse.BooleanOptionalAction,
264
+ default=None,
265
+ help="Force deterministic TensorFlow/cuDNN op implementations (tf.config.experimental.enable_op_determinism) for bit-exact GPU reproducibility at some speed cost. Default: enabled — without it, cuDNN autotune noise can flip near-threshold candidates between identical runs; opt out with --no-tf-deterministic-ops",
266
+ )
267
+
268
+
269
+ def _add_runtime_flags_to(parser):
270
+ """
271
+ Add the shared host-runtime flags — registered on BOTH subparsers via one helper so
272
+ the train and inference surfaces cannot drift (#303).
273
+ """
274
+ parser.add_argument(
275
+ "--n-processes",
276
+ type=int,
277
+ default=None,
278
+ help="Worker-process count for the multiprocessing pools (energy detection + stamp extraction at inference; data generation at training). Default: all cores. Host tuning: never layered from a saved --config-path, so a config recorded on a bigger host cannot oversubscribe this one (must be >= 1)",
279
+ )
280
+
281
+
282
+ def _add_train_flags_to(parser):
283
+ """
284
+ Add all training-mode CLI flags to `parser`. Defined separately from the subparser wrapper
285
+ so that utility scripts (e.g. utils/find_optimal_configs.py) can expose the same flag
286
+ surface without re-declaring every argument.
287
+ """
288
+ # Shared reproducibility + host-runtime flags — one helper each, both subparsers
289
+ _add_reproducibility_flags_to(parser)
290
+ _add_runtime_flags_to(parser)
291
+
292
+ # Path arguments (overrides environment variables)
293
+ parser.add_argument(
294
+ "--data-path",
295
+ type=str,
296
+ default=None,
297
+ help="Path to data directory (overrides AETHERSCAN_DATA_PATH environment variable)",
298
+ )
299
+ parser.add_argument(
300
+ "--model-path",
301
+ type=str,
302
+ default=None,
303
+ help="Path to model directory (overrides AETHERSCAN_MODEL_PATH environment variable)",
304
+ )
305
+ parser.add_argument(
306
+ "--output-path",
307
+ type=str,
308
+ default=None,
309
+ help="Path to output directory (overrides AETHERSCAN_OUTPUT_PATH environment variable)",
310
+ )
311
+ parser.add_argument(
312
+ "--dashboard",
313
+ action=argparse.BooleanOptionalAction,
314
+ default=None,
315
+ help="Auto-launch the live monitoring Streamlit dashboard for this run; SSH-forward the port to view it (default: on). Use --no-dashboard to disable",
316
+ )
317
+ parser.add_argument(
318
+ "--dashboard-port",
319
+ type=int,
320
+ default=None,
321
+ help="Port for the auto-launched live dashboard (default: 8501)",
322
+ )
323
+ parser.add_argument(
324
+ "--benchmark-report",
325
+ action=argparse.BooleanOptionalAction,
326
+ default=None,
327
+ help="Render the end-of-run benchmark report (stage timeline + bottleneck suggestions) and post it to Slack (default: on). Use --no-benchmark-report to disable",
328
+ )
329
+
330
+ # BetaVAE model configuration
331
+ parser.add_argument(
332
+ "--vae-latent-dim",
333
+ type=int,
334
+ default=None,
335
+ help="Dimensionality of the VAE latent space (bottleneck size)",
336
+ )
337
+ parser.add_argument(
338
+ "--vae-dense-layer-size",
339
+ type=int,
340
+ default=None,
341
+ help="Size of dense layer in VAE architecture (should match frequency bins after downsampling)",
342
+ )
343
+ parser.add_argument(
344
+ "--vae-kernel-size",
345
+ type=int,
346
+ nargs=2,
347
+ default=None,
348
+ help="Kernel size for Conv2D layers as two integers (e.g., --vae-kernel-size 3 3)",
349
+ )
350
+ parser.add_argument(
351
+ "--vae-beta",
352
+ type=float,
353
+ default=None,
354
+ help="Beta coefficient for KL divergence loss term in beta-VAE (controls disentanglement)",
355
+ )
356
+ parser.add_argument(
357
+ "--vae-alpha",
358
+ type=float,
359
+ default=None,
360
+ help="Alpha coefficient for clustering loss term in VAE (controls cluster separation)",
361
+ )
362
+
363
+ # Random Forest configuration
364
+ parser.add_argument(
365
+ "--rf-n-estimators",
366
+ type=int,
367
+ default=None,
368
+ help="Number of decision trees in the random forest ensemble",
369
+ )
370
+ parser.add_argument(
371
+ "--rf-bootstrap",
372
+ type=lambda x: x.lower() in ("true", "1", "yes"),
373
+ default=None,
374
+ help="Whether to use bootstrap sampling when building trees (enables bagging)",
375
+ )
376
+ parser.add_argument(
377
+ "--rf-max-features",
378
+ type=str,
379
+ default=None,
380
+ help="Number of features to consider for splits: 'sqrt', 'log2', or a float (fraction of features)",
381
+ )
382
+ parser.add_argument(
383
+ "--rf-n-jobs",
384
+ type=int,
385
+ default=None,
386
+ help="Number of parallel jobs for random forest training (-1 uses all CPU cores)",
387
+ )
388
+ parser.add_argument(
389
+ "--rf-seed",
390
+ type=int,
391
+ default=None,
392
+ help="DEPRECATED: explicit random forest seed override. The RF seed now derives from the root --seed (#279); this alias remains for existing scripts and logs a deprecation warning when used.",
393
+ )
394
+
395
+ # GPU configuration
396
+ parser.add_argument(
397
+ "--num-replicas",
398
+ type=int,
399
+ default=None,
400
+ help="Number of GPUs to use for the distributed strategy. If omitted, the strategy uses every GPU visible to TF; otherwise it is restricted to the first N physical GPUs and the rest are left untouched. Must be >= 1 and <= the number of physical GPUs on your machine.",
401
+ )
402
+ parser.add_argument(
403
+ "--gpu-memory-limit-mb",
404
+ type=int,
405
+ default=None,
406
+ help="Per-GPU memory cap in MiB. Omit to use memory-growth-only (recommended on Blackwell). Set for TF to allocate a fixed logical device of a given size per physical GPU (e.g. 14000)",
407
+ )
408
+ parser.add_argument(
409
+ "--nccl-num-packs",
410
+ type=int,
411
+ default=None,
412
+ help="num_packs for NCCL/HierarchicalCopy all-reduce. Lower values (e.g. 1) reduces tiny-tensor latency; higher values (e.g. >=4) can help bandwidth on >4-GPU topologies.",
413
+ )
414
+ parser.add_argument(
415
+ "--async-allocator",
416
+ action=argparse.BooleanOptionalAction,
417
+ default=None,
418
+ help="Toggle TF_GPU_ALLOCATOR=cuda_malloc_async (default: enabled). Pass --no-async-allocator as a workaround for NGC 25.02 multi-GPU OOM bugs.",
419
+ )
420
+
421
+ # Data configuration
422
+ parser.add_argument(
423
+ "--num-observations",
424
+ type=int,
425
+ default=None,
426
+ help="Number of observations per cadence snippet (e.g., 6 for 3 ON + 3 OFF)",
427
+ )
428
+ parser.add_argument(
429
+ "--width-bin",
430
+ type=int,
431
+ default=None,
432
+ help="Number of frequency bins per observation (spectral resolution)",
433
+ )
434
+ parser.add_argument(
435
+ "--downsample-factor",
436
+ type=int,
437
+ default=None,
438
+ help="Downsampling factor for frequency bins (reduces spectral dimension)",
439
+ )
440
+ parser.add_argument(
441
+ "--time-bins",
442
+ type=int,
443
+ default=None,
444
+ help="Number of time bins per observation (temporal resolution)",
445
+ )
446
+ parser.add_argument(
447
+ "--freq-resolution",
448
+ type=float,
449
+ default=None,
450
+ help="Frequency resolution in Hz (determined by instrument)",
451
+ )
452
+ parser.add_argument(
453
+ "--time-resolution",
454
+ type=float,
455
+ default=None,
456
+ help="Time resolution in seconds (determined by instrument)",
457
+ )
458
+ parser.add_argument(
459
+ "--num-target-backgrounds",
460
+ type=int,
461
+ default=None,
462
+ help="Number of background (noise-only) cadences to load for training data generation",
463
+ )
464
+ parser.add_argument(
465
+ "--background-load-chunk-size",
466
+ type=int,
467
+ default=None,
468
+ help="Maximum number of background cadences to process at once during loading (memory management)",
469
+ )
470
+ parser.add_argument(
471
+ "--max-chunks-per-file",
472
+ type=int,
473
+ default=None,
474
+ help="Maximum number of chunks to load from a single data file (limits per-file contribution)",
475
+ )
476
+ parser.add_argument(
477
+ "--train-files",
478
+ type=str,
479
+ nargs="+",
480
+ default=None,
481
+ help="Space-separated list of training data file names (e.g., real_filtered_LARGE_HIP110750.npy)",
482
+ )
483
+
484
+ # Training configuration
485
+ parser.add_argument(
486
+ "--num-training-rounds",
487
+ type=int,
488
+ default=None,
489
+ help="Total number of training rounds in curriculum learning schedule",
490
+ )
491
+ parser.add_argument(
492
+ "--epochs-per-round",
493
+ type=int,
494
+ default=None,
495
+ help="Number of epochs to train the VAE per curriculum learning round",
496
+ )
497
+ parser.add_argument(
498
+ "--num-samples-beta-vae",
499
+ type=int,
500
+ default=None,
501
+ # NOTE: divisible by 4 or num_replicas?
502
+ help="Number of training samples to generate for beta-VAE per round (must be divisible by 4)",
503
+ )
504
+ parser.add_argument(
505
+ "--num-samples-rf",
506
+ type=int,
507
+ default=None,
508
+ # NOTE: divisible by 4 or num_replicas?
509
+ help="Number of training samples to generate for random forest (must be divisible by 4)",
510
+ )
511
+ parser.add_argument(
512
+ "--train-val-split",
513
+ type=float,
514
+ default=None,
515
+ help="Fraction of data to use for training vs validation (e.g., 0.8 = 80%% train, 20%% val)",
516
+ )
517
+ parser.add_argument(
518
+ "--per-replica-batch-size",
519
+ type=int,
520
+ default=None,
521
+ help="Batch size per GPU/device replica during training",
522
+ )
523
+ parser.add_argument(
524
+ "--effective-batch-size",
525
+ type=int,
526
+ default=None,
527
+ help="Effective batch size for gradient accumulation across all replicas",
528
+ )
529
+ parser.add_argument(
530
+ "--per-replica-val-batch-size",
531
+ type=int,
532
+ default=None,
533
+ help="Batch size per GPU/device replica during validation",
534
+ )
535
+ parser.add_argument(
536
+ "--signal-injection-chunk-size",
537
+ type=int,
538
+ default=None,
539
+ # NOTE: divisible by 4 or num_replicas?
540
+ help="Maximum cadences to process at once during synthetic signal injection (must be divisible by 4)",
541
+ )
542
+ parser.add_argument(
543
+ "--data-gen-task-size",
544
+ type=int,
545
+ default=None,
546
+ help="Cadences per batched signal-injection worker task (workers write results straight into the round's on-disk memmap; must be >= 1)",
547
+ )
548
+ parser.add_argument(
549
+ "--round-data-dir",
550
+ type=str,
551
+ default=None,
552
+ help="Directory for disk-backed per-round training datasets (defaults to <data-path>/training/round_data; needs ~2.2x one round's size free when data-generation overlap is enabled, ~1.1x otherwise)",
553
+ )
554
+ parser.add_argument(
555
+ "--overlap-data-generation",
556
+ action=argparse.BooleanOptionalAction,
557
+ default=None,
558
+ help="Generate round k+1's training data in a background producer process while round k trains (default: enabled). Pass --no-overlap-data-generation to fall back to sequential in-process generation for debugging",
559
+ )
560
+ parser.add_argument(
561
+ "--keep-round-data",
562
+ action=argparse.BooleanOptionalAction,
563
+ default=None,
564
+ help="Retain each round's on-disk training data after that round finishes (default: disabled — round k's data directory is deleted as soon as round k's training completes). Enable for debugging",
565
+ )
566
+ parser.add_argument(
567
+ "--plot-injection-subsampling-count",
568
+ type=int,
569
+ default=None,
570
+ help="Max points per stat name, per signal type, for A→B intensity bias scatter plots. Outliers are prioritized, with the difference made up from randomly sampling without replacement the remaining points",
571
+ )
572
+ parser.add_argument(
573
+ "--plot-injection-outlier-percentile",
574
+ type=float,
575
+ default=None,
576
+ help="Threshold for points to always be included in A→B intensity bias scatter plots",
577
+ )
578
+ parser.add_argument(
579
+ "--latent-viz-num-cadences-per-type",
580
+ type=int,
581
+ default=None,
582
+ help="Number of cadences per signal type for latent space visualization batch (total points = 4× this value × 6 observations per cadence)",
583
+ )
584
+ parser.add_argument(
585
+ "--latent-viz-step-interval",
586
+ type=int,
587
+ default=None,
588
+ help="Capture a latent space snapshot every N training steps (lower = more snapshots, more DB writes, and larger storage costs)",
589
+ )
590
+ parser.add_argument(
591
+ "--latent-viz-umap-fit-max-samples",
592
+ type=int,
593
+ default=None,
594
+ help="Maximum number of pooled latent vectors used to fit the UMAP model (remaining vectors are projected via transform; lower = faster, higher = more faithful embedding)",
595
+ )
596
+ parser.add_argument(
597
+ "--latent-viz-umap-n-neighbors",
598
+ type=int,
599
+ nargs="+",
600
+ default=None,
601
+ help="UMAP n_neighbors values to sweep for latent space visualization (e.g., --latent-viz-umap-n-neighbors 5 15 30 50)",
602
+ )
603
+ parser.add_argument(
604
+ "--latent-viz-umap-min-dist",
605
+ type=float,
606
+ nargs="+",
607
+ default=None,
608
+ help="UMAP min_dist values to sweep for latent space visualization (e.g., --latent-viz-umap-min-dist 0.0 0.1 0.5)",
609
+ )
610
+ parser.add_argument(
611
+ "--latent-viz-gif-max-frames",
612
+ type=int,
613
+ default=None,
614
+ help="Maximum number of frames in latent space GIF output (snapshots beyond this limit are log-subsampled, prioritizing earlier training steps)",
615
+ )
616
+ parser.add_argument(
617
+ "--latent-viz-gif-duration-ms",
618
+ type=int,
619
+ default=None,
620
+ help="Milliseconds per frame in latent space GIF output",
621
+ )
622
+ parser.add_argument(
623
+ "--latent-traversal-every-round",
624
+ action=argparse.BooleanOptionalAction,
625
+ default=None,
626
+ help="Render latent-dimension traversal figures at the end of every training round, in addition to the end-of-training set (default: disabled)",
627
+ )
628
+ parser.add_argument(
629
+ "--latent-traversal-num-steps",
630
+ type=int,
631
+ default=None,
632
+ help="Number of traversal steps per latent dimension (must be odd and >= 3 so the center column is the unperturbed class-mean decode)",
633
+ )
634
+ parser.add_argument(
635
+ "--latent-traversal-max-sigma",
636
+ type=float,
637
+ default=None,
638
+ help="Latent traversal range in per-dimension standard deviations: steps span [-max_sigma, +max_sigma] (must be > 0)",
639
+ )
640
+
641
+ parser.add_argument(
642
+ "--snr-base",
643
+ type=int,
644
+ default=None,
645
+ help="Base signal-to-noise ratio for curriculum learning (minimum SNR difficulty level)",
646
+ )
647
+ parser.add_argument(
648
+ "--initial-snr-range",
649
+ type=int,
650
+ default=None,
651
+ help="SNR range for initial (easiest) training rounds (signals sampled from snr_base to snr_base + initial_snr_range)",
652
+ )
653
+ parser.add_argument(
654
+ "--final-snr-range",
655
+ type=int,
656
+ default=None,
657
+ help="SNR range for final (hardest) training rounds (signals sampled from snr_base to snr_base + final_snr_range). Ignored if only training for 1 round",
658
+ )
659
+ parser.add_argument(
660
+ "--curriculum-schedule",
661
+ type=str,
662
+ default=None,
663
+ help="Curriculum difficulty progression schedule: 'linear', 'exponential', or 'step'",
664
+ )
665
+ parser.add_argument(
666
+ "--exponential-decay-rate",
667
+ type=float,
668
+ default=None,
669
+ help="Decay rate for exponential curriculum schedule (must be negative; more negative = faster difficulty increase)",
670
+ )
671
+ parser.add_argument(
672
+ "--step-easy-rounds",
673
+ type=int,
674
+ default=None,
675
+ help="Number of rounds with easy signals when using step curriculum schedule",
676
+ )
677
+ parser.add_argument(
678
+ "--step-hard-rounds",
679
+ type=int,
680
+ default=None,
681
+ help="Number of rounds with hard signals when using step curriculum schedule",
682
+ )
683
+ parser.add_argument(
684
+ "--base-learning-rate",
685
+ type=float,
686
+ default=None,
687
+ help="Initial learning rate for Adam optimizer",
688
+ )
689
+ parser.add_argument(
690
+ "--min-learning-rate",
691
+ type=float,
692
+ default=None,
693
+ help="Learning rate floor for adaptive learning rate reduction",
694
+ )
695
+ parser.add_argument(
696
+ "--min-pct-improvement",
697
+ type=float,
698
+ default=None,
699
+ help="Minimum fractional validation loss improvement to avoid LR reduction (e.g., 0.001 = 0.1%%)",
700
+ )
701
+ parser.add_argument(
702
+ "--patience-threshold",
703
+ type=int,
704
+ default=None,
705
+ help="Number of consecutive epochs without minimum improvement before reducing learning rate",
706
+ )
707
+ parser.add_argument(
708
+ "--lr-reduction-factor",
709
+ type=float,
710
+ default=None,
711
+ help="Multiplicative factor for learning rate reduction (e.g., 0.2 reduces LR by 20%%)",
712
+ )
713
+ parser.add_argument(
714
+ "--max-retries",
715
+ type=int,
716
+ default=None,
717
+ help="Maximum number of retry attempts when training fails due to errors",
718
+ )
719
+ parser.add_argument(
720
+ "--retry-delay",
721
+ type=int,
722
+ default=None,
723
+ help="Delay in seconds between retry attempts after training failure",
724
+ )
725
+
726
+ # HuggingFace Hub configuration
727
+ parser.add_argument(
728
+ "--hf-upload",
729
+ action=argparse.BooleanOptionalAction,
730
+ default=None,
731
+ help="Upload the final model artifacts (encoder, decoder, random forest, config) plus a generated model card to the HuggingFace Hub after training completes, tagging the commit with --save-tag (default: disabled = local-only). Requires HF_TOKEN in the environment (via .env)",
732
+ )
733
+ parser.add_argument(
734
+ "--hf-repo-id",
735
+ type=str,
736
+ default=None,
737
+ help="HuggingFace model repo id (namespace/name) for weight upload/download (default: zachtheyek/aetherscan)",
738
+ )
739
+
740
+ # Checkpoint configuration
741
+ parser.add_argument(
742
+ "--load-dir",
743
+ type=str,
744
+ default=None,
745
+ help="Subdirectory for checkpoint loading (relative to --model-path)",
746
+ )
747
+ parser.add_argument(
748
+ "--load-tag",
749
+ type=str,
750
+ default=None,
751
+ help="Checkpoint to load. A full run tag ({command}_YYYYMMDD_HHMMSS) resumes that run in place (its tag is adopted, so the resumed attempt writes under the same run). round_XX (requires --load-dir checkpoints) seeds a fresh run from that per-round checkpoint, resuming from round XX+1 unless --start-round is given.",
752
+ )
753
+ parser.add_argument(
754
+ "--start-round",
755
+ type=int,
756
+ default=None,
757
+ help="Round to begin/resume training from",
758
+ )
759
+ parser.add_argument(
760
+ "--save-tag",
761
+ type=str,
762
+ default=None,
763
+ help="Run label prefix: one of test, train, inf, bench. The datetime is appended automatically at runtime (e.g. train_20260101_120000). Defaults to the subcommand (train->train, inference->inf) if omitted.",
764
+ )
765
+ parser.add_argument(
766
+ "--force-tag",
767
+ action=argparse.BooleanOptionalAction,
768
+ default=None,
769
+ help="Override the fail-early save-tag collision guard: proceed even when an explicitly-provided --save-tag matches existing artifacts, DB rows, or (with --hf-upload) an existing HuggingFace tag (default: disabled)",
770
+ )
771
+
772
+
773
+ def _add_inference_arguments(subparsers):
774
+ """Register the `inference` subcommand and populate it with all inference-mode flags."""
775
+ inf_parser = subparsers.add_parser("inference", help="Execute inference pipeline")
776
+ _add_inference_flags_to(inf_parser)
777
+
778
+
779
+ # TODO: improve flag help descriptions
780
+ def _add_inference_flags_to(parser):
781
+ """
782
+ Add all inference-mode CLI flags to `parser`. Defined separately from the subparser wrapper
783
+ so that utility scripts (e.g. utils/find_optimal_configs.py) can expose the same flag
784
+ surface without re-declaring every argument.
785
+ """
786
+ # Shared reproducibility + host-runtime flags — one helper each, both subparsers
787
+ _add_reproducibility_flags_to(parser)
788
+ _add_runtime_flags_to(parser)
789
+
790
+ # Path arguments
791
+ parser.add_argument(
792
+ "--data-path",
793
+ type=str,
794
+ default=None,
795
+ help="Path to data directory (overrides AETHERSCAN_DATA_PATH environment variable)",
796
+ )
797
+ parser.add_argument(
798
+ "--model-path",
799
+ type=str,
800
+ default=None,
801
+ help="Path to model directory (overrides AETHERSCAN_MODEL_PATH environment variable)",
802
+ )
803
+ parser.add_argument(
804
+ "--output-path",
805
+ type=str,
806
+ default=None,
807
+ help="Path to output directory (overrides AETHERSCAN_OUTPUT_PATH environment variable)",
808
+ )
809
+ parser.add_argument(
810
+ "--dashboard",
811
+ action=argparse.BooleanOptionalAction,
812
+ default=None,
813
+ help="Auto-launch the live monitoring Streamlit dashboard for this run; SSH-forward the port to view it (default: on). Use --no-dashboard to disable",
814
+ )
815
+ parser.add_argument(
816
+ "--dashboard-port",
817
+ type=int,
818
+ default=None,
819
+ help="Port for the auto-launched live dashboard (default: 8501)",
820
+ )
821
+ parser.add_argument(
822
+ "--benchmark-report",
823
+ action=argparse.BooleanOptionalAction,
824
+ default=None,
825
+ help="Render the end-of-run benchmark report (stage timeline + bottleneck suggestions) and post it to Slack (default: on). Use --no-benchmark-report to disable",
826
+ )
827
+
828
+ # GPU configuration
829
+ parser.add_argument(
830
+ "--num-replicas",
831
+ type=int,
832
+ default=None,
833
+ help="Number of GPUs to use for the distributed strategy. If omitted, the strategy uses every GPU visible to TF; otherwise it is restricted to the first N physical GPUs and the rest are left untouched. Must be >= 1 and <= the number of physical GPUs on your machine.",
834
+ )
835
+ parser.add_argument(
836
+ "--gpu-memory-limit-mb",
837
+ type=int,
838
+ default=None,
839
+ help="Per-GPU memory cap in MiB. Omit to use memory-growth-only (recommended on Blackwell). Set for TF to allocate a fixed logical device of a given size per physical GPU (e.g. 14000)",
840
+ )
841
+ parser.add_argument(
842
+ "--async-allocator",
843
+ action=argparse.BooleanOptionalAction,
844
+ default=None,
845
+ help="Toggle TF_GPU_ALLOCATOR=cuda_malloc_async (default: enabled). Pass --no-async-allocator as a workaround for NGC 25.02 multi-GPU OOM bugs.",
846
+ )
847
+
848
+ # Data configuration
849
+ parser.add_argument(
850
+ "--test-files",
851
+ type=str,
852
+ nargs="+",
853
+ default=None,
854
+ help="Space-separated list of testing data file names (e.g., real_filtered_LARGE_test_HIP15638.npy)",
855
+ )
856
+ parser.add_argument(
857
+ "--inference-files",
858
+ type=str,
859
+ nargs="+",
860
+ default=None,
861
+ help="Space-separated list of inference catalog file names (e.g. complete_cadences_catalog.csv). Expects .h5 filepaths to individual observations, and sufficient metadata for recovering cadence groupings. If provided, triggers the energy detection preprocessing pipeline and takes precedence over --test-files",
862
+ )
863
+
864
+ # Inference configuration
865
+ parser.add_argument(
866
+ "--encoder-path",
867
+ type=str,
868
+ default=None,
869
+ help="Path to trained VAE encoder model file (.keras). Optional: when none of --encoder-path/--rf-path/--config-path are given, the artifacts are downloaded from the HuggingFace Hub (see --hf-repo-id/--hf-revision); provide either all three local paths or none",
870
+ )
871
+ parser.add_argument(
872
+ "--rf-path",
873
+ type=str,
874
+ default=None,
875
+ help="Path to trained Random Forest model file (.joblib). Optional: see --encoder-path for the all-three-or-none rule",
876
+ )
877
+ parser.add_argument(
878
+ "--config-path",
879
+ type=str,
880
+ default=None,
881
+ help="Path to config file from corresponding training run (.json). Optional: see --encoder-path for the all-three-or-none rule",
882
+ )
883
+ parser.add_argument(
884
+ "--per-replica-batch-size",
885
+ type=int,
886
+ default=None,
887
+ help="Batch size per GPU/device replica during inference",
888
+ )
889
+ parser.add_argument(
890
+ "--classification-threshold",
891
+ type=float,
892
+ default=None,
893
+ help="Science threshold for candidate detection, applied to the pass-2 MC mean probability (the two-pass cascade's final score)",
894
+ )
895
+ parser.add_argument(
896
+ "--screening-threshold",
897
+ type=float,
898
+ default=None,
899
+ help="Permissive pass-1 screening threshold of the two-pass cascade (tuned for recall; must not exceed --classification-threshold). Snippets below it are rejected without MC scoring",
900
+ )
901
+ parser.add_argument(
902
+ "--mc-draws",
903
+ type=int,
904
+ default=None,
905
+ help="Seeded Monte-Carlo latent draws per pass-2 survivor (mean carries the science threshold; std is the reported uncertainty spread)",
906
+ )
907
+ parser.add_argument(
908
+ "--reference-cloud-size",
909
+ type=int,
910
+ default=None,
911
+ help="Size of the seeded uniform reservoir of pass-1 rejects MC-scored as the candidate uncertainty plot's survey background (0 disables)",
912
+ )
913
+ parser.add_argument(
914
+ "--prefetch-depth",
915
+ type=int,
916
+ default=None,
917
+ help="Cadences preprocessed+loaded ahead of the GPU stage in the streaming loop (>= 1). Each unit of depth overlaps energy-detection reads with stamp extraction and the serial per-cadence sections, costing one in-flight cadence of RAM (up to ~65 GB for RFI-dense C-band cadences); outputs are identical at any depth (default: 3 per the on-cluster A/B)",
918
+ )
919
+
920
+ # Energy detection preprocessing
921
+ parser.add_argument(
922
+ "--cadence-group-by-cols",
923
+ type=str,
924
+ nargs="+",
925
+ default=None,
926
+ help="Space-separated list of CSV column names whose joint value defines cadence membership (e.g., Target Session Band 'Cadence ID' Frequency)",
927
+ )
928
+ parser.add_argument(
929
+ "--cadence-h5-path-col",
930
+ type=str,
931
+ default=None,
932
+ help="CSV column containing the .h5 file path for each observation (default: '.h5 path')",
933
+ )
934
+ parser.add_argument(
935
+ "--cadence-expected-obs",
936
+ type=int,
937
+ default=None,
938
+ help="Required number of observations per cadence (default: 6 for ABACAD)",
939
+ )
940
+ parser.add_argument(
941
+ "--coarse-channel-width",
942
+ type=int,
943
+ default=None,
944
+ help="Number of fine channels per coarse channel (default: 1048576)",
945
+ )
946
+ parser.add_argument(
947
+ "--coarse-channel-log-interval",
948
+ type=int,
949
+ default=None,
950
+ help="Progress-logging cadence for energy detection, in coarse channels per log line. Default: ~25%% milestone lines per ON file (the per-channel lines were 62%% of a run's Slack-bound log volume); pass an explicit N to restore every-N-channels lines. Parallelism itself comes from the persistent worker pool, not this knob.",
951
+ )
952
+ parser.add_argument(
953
+ "--bandpass-method",
954
+ type=str,
955
+ default=None,
956
+ help="Bandpass flattening method for energy detection: 'pfb' (default) divides each coarse channel by the instrument's static polyphase-filterbank response; 'spline' fits and subtracts a per-channel spline",
957
+ )
958
+ parser.add_argument(
959
+ "--pfb-taps-per-channel",
960
+ type=int,
961
+ default=None,
962
+ help="PFB prototype-filter taps per coarse channel for --bandpass-method pfb (default: 12, the GBT/Breakthrough Listen backend value). INSTRUMENT-DEPENDENT: must match the backend that produced the .h5 files",
963
+ )
964
+ parser.add_argument(
965
+ "--bandpass-debug-plot",
966
+ action=argparse.BooleanOptionalAction,
967
+ default=None,
968
+ help="Save a per-cadence bandpass-flattening overlay debug plot (raw vs flattened integrated spectrum for a few sampled coarse channels) under plots/inference/{save-tag}/ (default: off)",
969
+ )
970
+ parser.add_argument(
971
+ "--spline-order",
972
+ type=int,
973
+ default=None,
974
+ help="Spline order for bandpass fitting with --bandpass-method spline (default: 16)",
975
+ )
976
+ parser.add_argument(
977
+ "--detection-window-size",
978
+ type=int,
979
+ default=None,
980
+ help="Sliding window size in fine channels for normality test (default: 256)",
981
+ )
982
+ parser.add_argument(
983
+ "--detection-step-size",
984
+ type=int,
985
+ default=None,
986
+ help="Step size in fine channels for sliding window (default: 128)",
987
+ )
988
+ parser.add_argument(
989
+ "--stat-threshold",
990
+ type=float,
991
+ default=None,
992
+ help="D'Agostino-Pearson statistic threshold for hit detection (default: 2048.0)",
993
+ )
994
+ parser.add_argument(
995
+ "--stamp-width",
996
+ type=int,
997
+ default=None,
998
+ help="Width in fine channels of the extracted stamp around each hit (default: 4096; must equal --width-bin)",
999
+ )
1000
+ parser.add_argument(
1001
+ "--store-downsampled-stamps",
1002
+ action=argparse.BooleanOptionalAction,
1003
+ default=None,
1004
+ help="Downsample stamps along frequency (by --downsample-factor) at extraction time, storing stamp_width // downsample_factor bins per stamp (~8x smaller at defaults; default: enabled). Pass --no-store-downsampled-stamps to archive raw-resolution stamps; loading handles both layouts.",
1005
+ )
1006
+ parser.add_argument(
1007
+ "--overlap-search",
1008
+ action=argparse.BooleanOptionalAction,
1009
+ default=None,
1010
+ help="Additionally extract stamps offset by ±overlap_fraction*stamp_width around each hit. Pass --no-overlap-search to disable when the config default is True.",
1011
+ )
1012
+ parser.add_argument(
1013
+ "--overlap-fraction",
1014
+ type=float,
1015
+ default=None,
1016
+ help="Fractional offset (relative to stamp_width) for overlap-search stamps (default: 0.5)",
1017
+ )
1018
+ parser.add_argument(
1019
+ "--preprocess-output-dir",
1020
+ type=str,
1021
+ default=None,
1022
+ help="Directory for per-cadence .npy outputs from preprocessing. Default: a per-CSV directory {data_path}/inference/preprocessed/<csv_stem>_ed<hash>/ keyed on the energy-detection config fingerprint — runs sharing an ED config reuse each other's stamps automatically, and any ED-config change resolves to a fresh directory. Pass a directory explicitly to pin/share one location (reuse is still guarded by the sidecar's recorded h5 paths and ED fingerprint)",
1023
+ )
1024
+
1025
+ parser.add_argument(
1026
+ "--prune-stamps",
1027
+ action=argparse.BooleanOptionalAction,
1028
+ default=None,
1029
+ help="Delete each cadence's stamp .npy right after its 'inferred' manifest row lands, keeping the metadata .json plus a ~196 KB snippet sidecar per candidate — resume rides the DB row, and only stamps this run freshly extracted are ever pruned. Without pruning a full catalog writes ~30-90 TB of stamps. Default: AUTO — enabled for the fingerprint-scoped default cache directory, disabled when --preprocess-output-dir is set explicitly. Pass --no-prune-stamps to keep every stamp (slice-scale runs wanting the cross-run rerun cache).",
1030
+ )
1031
+
1032
+ # Visualization suite
1033
+ parser.add_argument(
1034
+ "--inference-viz",
1035
+ action=argparse.BooleanOptionalAction,
1036
+ default=None,
1037
+ help="Render the inference visualization suite (energy detection distributions, hit spectrum, bandpass overlay, stamp/candidate galleries, confidence distribution, latent projection, summary card) at the end of a CSV inference run, saved under plots/inference/{save_tag}/ and uploaded to Slack (default: enabled). Pass --no-inference-viz to disable.",
1038
+ )
1039
+ parser.add_argument(
1040
+ "--inference-viz-scope",
1041
+ type=str,
1042
+ choices=["full", "new"],
1043
+ default=None,
1044
+ help="Which cadences the metadata-driven viz figures cover: 'full' (default) renders the whole accumulated tag every successful pass; 'new' renders only cadences inferred this pass — recommended for resumed multi-pass catalog campaigns, where 'full' re-pays the entire catalog's viz tail on every pass. DB-sourced candidate figures always cover the full tag either way.",
1045
+ )
1046
+ parser.add_argument(
1047
+ "--stamp-gallery-top-k",
1048
+ type=int,
1049
+ default=None,
1050
+ help="Number of top-statistic stamps shown in the stamp gallery figure, each as a 6-observation waterfall grid (default: 12)",
1051
+ )
1052
+ parser.add_argument(
1053
+ "--max-candidate-plots",
1054
+ type=int,
1055
+ default=None,
1056
+ help="Maximum number of per-candidate figures rendered per run, highest confidence first (default: 50; the candidate gallery is unaffected)",
1057
+ )
1058
+ parser.add_argument(
1059
+ "--max-retries",
1060
+ type=int,
1061
+ default=None,
1062
+ help="Maximum number of retry attempts for inference (including preprocessing) on failure",
1063
+ )
1064
+ parser.add_argument(
1065
+ "--retry-delay",
1066
+ type=int,
1067
+ default=None,
1068
+ help="Delay in seconds between inference retry attempts",
1069
+ )
1070
+
1071
+ # HuggingFace Hub configuration
1072
+ parser.add_argument(
1073
+ "--hf-repo-id",
1074
+ type=str,
1075
+ default=None,
1076
+ help="HuggingFace model repo id (namespace/name) for weight upload/download (default: zachtheyek/aetherscan)",
1077
+ )
1078
+ parser.add_argument(
1079
+ "--hf-revision",
1080
+ type=str,
1081
+ default=None,
1082
+ help="HuggingFace revision (tag, branch, or commit hash) to pin the model download to when no local artifact paths are given (default: v{package version} when running as an installed release, else the repo's latest release tag — highest semver vX.Y.Z tag; a release tag is required for a no-artifact download)",
1083
+ )
1084
+
1085
+ # Checkpoint configuration
1086
+ parser.add_argument(
1087
+ "--save-tag",
1088
+ type=str,
1089
+ default=None,
1090
+ help="Run label prefix: one of test, train, inf, bench. The datetime is appended automatically at runtime (e.g. inf_20260101_120000). Defaults to the subcommand (train->train, inference->inf) if omitted.",
1091
+ )
1092
+ parser.add_argument(
1093
+ "--force-tag",
1094
+ action=argparse.BooleanOptionalAction,
1095
+ default=None,
1096
+ help="Override the fail-early save-tag collision guard: proceed even when an explicitly-provided --save-tag matches a previous run's saved config or DB rows (default: disabled)",
1097
+ )
1098
+
1099
+
1100
+ # Saved-config ALLOWLIST (#303): apply_saved_config layers ONLY these fields from a saved
1101
+ # training config; everything else always comes from env + defaults + CLI. The old design
1102
+ # was the inverse — apply everything minus a skip-list — which left every host-scoped
1103
+ # field unsafe by default and needed a new amendment each time one bit (checkpoint, then
1104
+ # inference batching/prefetch, then reproducibility). The live footgun: a config saved on
1105
+ # 96-core bla0 carries manager.n_processes=96 and silently 3x-oversubscribed 32-core
1106
+ # blpc3's ED pool, with no CLI rescue before --n-processes existed.
1107
+ #
1108
+ # The allowlist is DERIVED from run_state.py's result-fingerprint key sets so the two can
1109
+ # never drift (test-pinned): every field the fingerprints treat as result-affecting keeps
1110
+ # layering — de-layering one would silently change science relative to the artifact's
1111
+ # validation, and would also stale the resume fingerprints — while host/runtime fields
1112
+ # (gpu, manager, db, logger, monitor, paths, hf, training, checkpoint, reproducibility)
1113
+ # never layer. An allowlist can only under-layer (visible in the startup diff log below,
1114
+ # fixable via CLI); the old shape could silently import a foreign host's tuning.
1115
+ # - beta_vae: whole section — model contract (latent_dim drives the scoring feature
1116
+ # layout; dense_layer_size/kernel_size feed shape validation; loss/precision fields are
1117
+ # inert at inference because the encoder is loaded whole from the .keras file).
1118
+ # - rf: the deployed-representation contract only — NOT n_jobs (host tuning, re-pinned
1119
+ # from the runtime config at load) and NOT seeds (run-scoped, #279).
1120
+ # - inference: exactly the fields inference_config_fingerprint hashes (thresholds,
1121
+ # mc_draws, ED params — training certifies the threshold cascade against this specific
1122
+ # model, so the artifact carrying its own science params is correct provenance) minus
1123
+ # the artifact paths (host-local; CLI/HF-resolved).
1124
+ # - data: the stamp/encoder geometry keys the fingerprints use.
1125
+ _SAVED_CONFIG_SECTION_ALLOWLIST = frozenset({"beta_vae"})
1126
+ _SAVED_CONFIG_FIELD_ALLOWLIST: dict[str, frozenset[str]] = {
1127
+ "data": frozenset(_INFERENCE_FINGERPRINT_DATA_KEYS),
1128
+ "inference": frozenset(f.name for f in dataclass_fields(InferenceConfig))
1129
+ - _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS
1130
+ - frozenset({"encoder_path", "rf_path", "config_path"}),
1131
+ "rf": frozenset({"latent_variant", "active_dims", "calibration_active", "calibration_method"}),
1132
+ }
1133
+
1134
+
1135
+ def apply_saved_config(config_path: str) -> None:
1136
+ """Layer a saved JSON config (e.g. from a prior training run) onto the singleton.
1137
+
1138
+ Used in inference mode to make `--config-path my_run.json` actually take effect:
1139
+ the saved file's values become the new "defaults" that `validate_args` sees via
1140
+ `_resolve`, and any CLI flags the user also passes will override them in the
1141
+ subsequent `apply_args_to_config` call.
1142
+
1143
+ Priority order for ALLOWLISTED fields ends up as:
1144
+
1145
+ defaults < saved config < CLI args
1146
+
1147
+ Only the model-contract / result-affecting fields in _SAVED_CONFIG_FIELD_ALLOWLIST /
1148
+ _SAVED_CONFIG_SECTION_ALLOWLIST layer (#303); every other saved field — paths, gpu,
1149
+ manager, db/logger/monitor, hf, training, checkpoint (most damagingly save_tag, which
1150
+ would make this run masquerade under the training run's tag), reproducibility (the
1151
+ run's own contract: opting out of determinism is an explicit CLI act) — is ignored,
1152
+ with every ignored field whose saved value differs from the resolved one logged as a
1153
+ startup diff line so nothing disappears silently. Unknown keys/fields are skipped to
1154
+ stay forward-compat with newer/older saved configs.
1155
+
1156
+ Raises `ValueError` if the file is missing or malformed — caught by main.py's
1157
+ wrapper alongside `validate_args` failures.
1158
+ """
1159
+ if not os.path.exists(config_path):
1160
+ raise ValueError(f"--config-path: file does not exist on disk: {config_path}")
1161
+ try:
1162
+ with open(config_path) as f:
1163
+ saved = json.load(f)
1164
+ except json.JSONDecodeError as exc:
1165
+ raise ValueError(f"--config-path: {config_path} is not valid JSON: {exc}") from exc
1166
+
1167
+ config = get_config()
1168
+ if config is None:
1169
+ raise ValueError("get_config() returned None")
1170
+
1171
+ ignored_diffs: list[str] = []
1172
+ for key, value in saved.items():
1173
+ target = getattr(config, key, None)
1174
+ if not (is_dataclass(target) and isinstance(value, dict)):
1175
+ # Top-level scalars (a legacy flat config's data_path/model_path/...) and
1176
+ # unknown sections never layer: paths and host layout always come from
1177
+ # env + defaults + CLI (#303; current-format configs nest paths under a
1178
+ # "paths" key that was never applied anyway — this formalizes that).
1179
+ if not isinstance(value, dict) and value != target:
1180
+ ignored_diffs.append(f"{key}: saved={value!r} ignored")
1181
+ continue
1182
+ if key in _SAVED_CONFIG_SECTION_ALLOWLIST:
1183
+ allowed = {f.name for f in dataclass_fields(target)}
1184
+ else:
1185
+ allowed = _SAVED_CONFIG_FIELD_ALLOWLIST.get(key, frozenset())
1186
+ for sub_key, sub_val in value.items():
1187
+ if not hasattr(target, sub_key):
1188
+ continue
1189
+ if sub_key in allowed:
1190
+ setattr(target, sub_key, sub_val)
1191
+ else:
1192
+ current = getattr(target, sub_key)
1193
+ if current != sub_val:
1194
+ ignored_diffs.append(
1195
+ f"{key}.{sub_key}: saved={sub_val!r} ignored, using {current!r}"
1196
+ )
1197
+ if ignored_diffs:
1198
+ logger.info(
1199
+ f"Saved-config fields NOT layered ({len(ignored_diffs)} host/run-scoped "
1200
+ f"difference(s); the #303 allowlist keeps paths and host tuning with "
1201
+ f"env+defaults+CLI): {'; '.join(ignored_diffs)}"
1202
+ )
1203
+
1204
+
1205
+ def apply_args_to_config(args: argparse.Namespace) -> None:
1206
+ """Mutate the singleton config in place with any non-None overrides from the parsed CLI
1207
+ namespace. Only attributes actually present on `args` are considered; missing ones fall back
1208
+ to the config defaults."""
1209
+ config = get_config()
1210
+ if config is None:
1211
+ raise ValueError("get_config() returned None")
1212
+
1213
+ # Host-runtime overrides (#303): the only way to size the worker pools besides the
1214
+ # cpu_count() default — never layered from a saved config
1215
+ if hasattr(args, "n_processes") and args.n_processes is not None:
1216
+ config.manager.n_processes = args.n_processes
1217
+
1218
+ # Path overrides (must be done first as they affect file loading)
1219
+ if hasattr(args, "data_path") and args.data_path is not None:
1220
+ config.data_path = args.data_path
1221
+ if hasattr(args, "model_path") and args.model_path is not None:
1222
+ config.model_path = args.model_path
1223
+ if hasattr(args, "output_path") and args.output_path is not None:
1224
+ config.output_path = args.output_path
1225
+ if hasattr(args, "dashboard") and args.dashboard is not None:
1226
+ config.monitor.dashboard_enabled = args.dashboard
1227
+ if hasattr(args, "dashboard_port") and args.dashboard_port is not None:
1228
+ config.monitor.dashboard_port = args.dashboard_port
1229
+ if hasattr(args, "benchmark_report") and args.benchmark_report is not None:
1230
+ config.monitor.benchmark_report_enabled = args.benchmark_report
1231
+
1232
+ # BetaVAE configuration
1233
+ if hasattr(args, "vae_latent_dim") and args.vae_latent_dim is not None:
1234
+ config.beta_vae.latent_dim = args.vae_latent_dim
1235
+ if hasattr(args, "vae_dense_layer_size") and args.vae_dense_layer_size is not None:
1236
+ config.beta_vae.dense_layer_size = args.vae_dense_layer_size
1237
+ if hasattr(args, "vae_kernel_size") and args.vae_kernel_size is not None:
1238
+ config.beta_vae.kernel_size = tuple(args.vae_kernel_size)
1239
+ if hasattr(args, "vae_beta") and args.vae_beta is not None:
1240
+ config.beta_vae.beta = args.vae_beta
1241
+ if hasattr(args, "vae_alpha") and args.vae_alpha is not None:
1242
+ config.beta_vae.alpha = args.vae_alpha
1243
+
1244
+ # Random Forest configuration
1245
+ if hasattr(args, "rf_n_estimators") and args.rf_n_estimators is not None:
1246
+ config.rf.n_estimators = args.rf_n_estimators
1247
+ if hasattr(args, "rf_bootstrap") and args.rf_bootstrap is not None:
1248
+ config.rf.bootstrap = args.rf_bootstrap
1249
+ if hasattr(args, "rf_max_features") and args.rf_max_features is not None:
1250
+ config.rf.max_features = args.rf_max_features
1251
+ if hasattr(args, "rf_n_jobs") and args.rf_n_jobs is not None:
1252
+ config.rf.n_jobs = args.rf_n_jobs
1253
+ if hasattr(args, "rf_seed") and args.rf_seed is not None:
1254
+ # Deprecated alias (#279): the RF seed normally derives from the root --seed via
1255
+ # STREAM_RF; an explicit override still works but is discouraged
1256
+ logger.warning(
1257
+ "--rf-seed is DEPRECATED: the random forest seed now derives from the root "
1258
+ "--seed. The explicit override will be honored this run."
1259
+ )
1260
+ config.rf.seed = args.rf_seed
1261
+
1262
+ # GPU configuration
1263
+ if hasattr(args, "num_replicas") and args.num_replicas is not None:
1264
+ config.gpu.num_replicas = args.num_replicas
1265
+ if hasattr(args, "gpu_memory_limit_mb") and args.gpu_memory_limit_mb is not None:
1266
+ config.gpu.per_gpu_memory_limit_mb = args.gpu_memory_limit_mb
1267
+ if hasattr(args, "nccl_num_packs") and args.nccl_num_packs is not None:
1268
+ config.gpu.nccl_num_packs = args.nccl_num_packs
1269
+ # async_allocator uses argparse.BooleanOptionalAction with default=None so that
1270
+ # the CLI can express "leave the config default" (omit), "force on"
1271
+ # (--async-allocator), and "force off" (--no-async-allocator). The `is not None`
1272
+ # guard preserves the config default when the user passes neither
1273
+ if hasattr(args, "async_allocator") and args.async_allocator is not None:
1274
+ config.gpu.use_async_allocator = args.async_allocator
1275
+
1276
+ # Data configuration
1277
+ if hasattr(args, "num_observations") and args.num_observations is not None:
1278
+ config.data.num_observations = args.num_observations
1279
+ if hasattr(args, "width_bin") and args.width_bin is not None:
1280
+ config.data.width_bin = args.width_bin
1281
+ if hasattr(args, "downsample_factor") and args.downsample_factor is not None:
1282
+ config.data.downsample_factor = args.downsample_factor
1283
+ if hasattr(args, "time_bins") and args.time_bins is not None:
1284
+ config.data.time_bins = args.time_bins
1285
+ if hasattr(args, "freq_resolution") and args.freq_resolution is not None:
1286
+ config.data.freq_resolution = args.freq_resolution
1287
+ if hasattr(args, "time_resolution") and args.time_resolution is not None:
1288
+ config.data.time_resolution = args.time_resolution
1289
+ if hasattr(args, "num_target_backgrounds") and args.num_target_backgrounds is not None:
1290
+ config.data.num_target_backgrounds = args.num_target_backgrounds
1291
+ if hasattr(args, "background_load_chunk_size") and args.background_load_chunk_size is not None:
1292
+ config.data.background_load_chunk_size = args.background_load_chunk_size
1293
+ if hasattr(args, "max_chunks_per_file") and args.max_chunks_per_file is not None:
1294
+ config.data.max_chunks_per_file = args.max_chunks_per_file
1295
+ if hasattr(args, "train_files") and args.train_files is not None:
1296
+ config.data.train_files = args.train_files
1297
+ if hasattr(args, "test_files") and args.test_files is not None:
1298
+ config.data.test_files = args.test_files
1299
+ if hasattr(args, "inference_files") and args.inference_files is not None:
1300
+ config.data.inference_files = args.inference_files
1301
+
1302
+ # Reproducibility (#279): shared by both subparsers. --unseeded is the explicit opt-out
1303
+ # of the seeded default (#298) — validate_args rejects passing it together with --seed.
1304
+ if hasattr(args, "unseeded") and args.unseeded:
1305
+ config.reproducibility.seed = None
1306
+ elif hasattr(args, "seed") and args.seed is not None:
1307
+ config.reproducibility.seed = args.seed
1308
+ # tf_deterministic_ops uses argparse.BooleanOptionalAction with default=None so the CLI
1309
+ # can express "leave the config default" (omit), "force on", and "force off" — same
1310
+ # pattern as async_allocator above
1311
+ if hasattr(args, "tf_deterministic_ops") and args.tf_deterministic_ops is not None:
1312
+ config.reproducibility.tf_deterministic_ops = args.tf_deterministic_ops
1313
+
1314
+ # Training configuration
1315
+ if hasattr(args, "num_training_rounds") and args.num_training_rounds is not None:
1316
+ config.training.num_training_rounds = args.num_training_rounds
1317
+ if hasattr(args, "epochs_per_round") and args.epochs_per_round is not None:
1318
+ config.training.epochs_per_round = args.epochs_per_round
1319
+ if hasattr(args, "num_samples_beta_vae") and args.num_samples_beta_vae is not None:
1320
+ config.training.num_samples_beta_vae = args.num_samples_beta_vae
1321
+ if hasattr(args, "num_samples_rf") and args.num_samples_rf is not None:
1322
+ config.training.num_samples_rf = args.num_samples_rf
1323
+ if hasattr(args, "train_val_split") and args.train_val_split is not None:
1324
+ config.training.train_val_split = args.train_val_split
1325
+ if (
1326
+ hasattr(args, "per_replica_batch_size")
1327
+ and args.per_replica_batch_size is not None
1328
+ and getattr(args, "command", None) == "train"
1329
+ ):
1330
+ config.training.per_replica_batch_size = args.per_replica_batch_size
1331
+ if hasattr(args, "effective_batch_size") and args.effective_batch_size is not None:
1332
+ config.training.effective_batch_size = args.effective_batch_size
1333
+ if hasattr(args, "per_replica_val_batch_size") and args.per_replica_val_batch_size is not None:
1334
+ config.training.per_replica_val_batch_size = args.per_replica_val_batch_size
1335
+ if (
1336
+ hasattr(args, "signal_injection_chunk_size")
1337
+ and args.signal_injection_chunk_size is not None
1338
+ ):
1339
+ config.training.signal_injection_chunk_size = args.signal_injection_chunk_size
1340
+ if hasattr(args, "data_gen_task_size") and args.data_gen_task_size is not None:
1341
+ config.training.data_gen_task_size = args.data_gen_task_size
1342
+ if hasattr(args, "round_data_dir") and args.round_data_dir is not None:
1343
+ config.training.round_data_dir = args.round_data_dir
1344
+ # overlap_data_generation / keep_round_data use argparse.BooleanOptionalAction with
1345
+ # default=None so that the CLI can express "leave the config default" (omit), "force on"
1346
+ # (--overlap-data-generation), and "force off" (--no-overlap-data-generation). The
1347
+ # `is not None` guard preserves the config default when the user passes neither
1348
+ if hasattr(args, "overlap_data_generation") and args.overlap_data_generation is not None:
1349
+ config.training.overlap_data_generation = args.overlap_data_generation
1350
+ if hasattr(args, "keep_round_data") and args.keep_round_data is not None:
1351
+ config.training.keep_round_data = args.keep_round_data
1352
+ if (
1353
+ hasattr(args, "plot_injection_subsampling_count")
1354
+ and args.plot_injection_subsampling_count is not None
1355
+ ):
1356
+ config.training.plot_injection_subsampling_count = args.plot_injection_subsampling_count
1357
+ if (
1358
+ hasattr(args, "plot_injection_outlier_percentile")
1359
+ and args.plot_injection_outlier_percentile is not None
1360
+ ):
1361
+ config.training.plot_injection_outlier_percentile = args.plot_injection_outlier_percentile
1362
+ if (
1363
+ hasattr(args, "latent_viz_num_cadences_per_type")
1364
+ and args.latent_viz_num_cadences_per_type is not None
1365
+ ):
1366
+ config.training.latent_viz_num_cadences_per_type = args.latent_viz_num_cadences_per_type
1367
+ if hasattr(args, "latent_viz_step_interval") and args.latent_viz_step_interval is not None:
1368
+ config.training.latent_viz_step_interval = args.latent_viz_step_interval
1369
+ if (
1370
+ hasattr(args, "latent_viz_umap_fit_max_samples")
1371
+ and args.latent_viz_umap_fit_max_samples is not None
1372
+ ):
1373
+ config.training.latent_viz_umap_fit_max_samples = args.latent_viz_umap_fit_max_samples
1374
+ if (
1375
+ hasattr(args, "latent_viz_umap_n_neighbors")
1376
+ and args.latent_viz_umap_n_neighbors is not None
1377
+ ):
1378
+ config.training.latent_viz_umap_n_neighbors = args.latent_viz_umap_n_neighbors
1379
+ if hasattr(args, "latent_viz_umap_min_dist") and args.latent_viz_umap_min_dist is not None:
1380
+ config.training.latent_viz_umap_min_dist = args.latent_viz_umap_min_dist
1381
+ if hasattr(args, "latent_viz_gif_max_frames") and args.latent_viz_gif_max_frames is not None:
1382
+ config.training.latent_viz_gif_max_frames = args.latent_viz_gif_max_frames
1383
+ if hasattr(args, "latent_viz_gif_duration_ms") and args.latent_viz_gif_duration_ms is not None:
1384
+ config.training.latent_viz_gif_duration_ms = args.latent_viz_gif_duration_ms
1385
+ # latent_traversal_every_round uses argparse.BooleanOptionalAction with default=None
1386
+ # (same tri-state as overlap_data_generation / keep_round_data above)
1387
+ if (
1388
+ hasattr(args, "latent_traversal_every_round")
1389
+ and args.latent_traversal_every_round is not None
1390
+ ):
1391
+ config.training.latent_traversal_every_round = args.latent_traversal_every_round
1392
+ if hasattr(args, "latent_traversal_num_steps") and args.latent_traversal_num_steps is not None:
1393
+ config.training.latent_traversal_num_steps = args.latent_traversal_num_steps
1394
+ if hasattr(args, "latent_traversal_max_sigma") and args.latent_traversal_max_sigma is not None:
1395
+ config.training.latent_traversal_max_sigma = args.latent_traversal_max_sigma
1396
+ if hasattr(args, "snr_base") and args.snr_base is not None:
1397
+ config.training.snr_base = args.snr_base
1398
+ if hasattr(args, "initial_snr_range") and args.initial_snr_range is not None:
1399
+ config.training.initial_snr_range = args.initial_snr_range
1400
+ if hasattr(args, "final_snr_range") and args.final_snr_range is not None:
1401
+ config.training.final_snr_range = args.final_snr_range
1402
+ if hasattr(args, "curriculum_schedule") and args.curriculum_schedule is not None:
1403
+ config.training.curriculum_schedule = args.curriculum_schedule
1404
+ if hasattr(args, "exponential_decay_rate") and args.exponential_decay_rate is not None:
1405
+ config.training.exponential_decay_rate = args.exponential_decay_rate
1406
+ if hasattr(args, "step_easy_rounds") and args.step_easy_rounds is not None:
1407
+ config.training.step_easy_rounds = args.step_easy_rounds
1408
+ if hasattr(args, "step_hard_rounds") and args.step_hard_rounds is not None:
1409
+ config.training.step_hard_rounds = args.step_hard_rounds
1410
+ if hasattr(args, "base_learning_rate") and args.base_learning_rate is not None:
1411
+ config.training.base_learning_rate = args.base_learning_rate
1412
+ if hasattr(args, "min_learning_rate") and args.min_learning_rate is not None:
1413
+ config.training.min_learning_rate = args.min_learning_rate
1414
+ if hasattr(args, "min_pct_improvement") and args.min_pct_improvement is not None:
1415
+ config.training.min_pct_improvement = args.min_pct_improvement
1416
+ if hasattr(args, "patience_threshold") and args.patience_threshold is not None:
1417
+ config.training.patience_threshold = args.patience_threshold
1418
+ if hasattr(args, "lr_reduction_factor") and args.lr_reduction_factor is not None:
1419
+ config.training.reduction_factor = args.lr_reduction_factor
1420
+ if (
1421
+ hasattr(args, "max_retries")
1422
+ and args.max_retries is not None
1423
+ and getattr(args, "command", None) == "train"
1424
+ ):
1425
+ config.training.max_retries = args.max_retries
1426
+ if (
1427
+ hasattr(args, "retry_delay")
1428
+ and args.retry_delay is not None
1429
+ and getattr(args, "command", None) == "train"
1430
+ ):
1431
+ config.training.retry_delay = args.retry_delay
1432
+
1433
+ # HuggingFace Hub configuration
1434
+ # hf_upload uses argparse.BooleanOptionalAction with default=None so that the CLI can
1435
+ # express "leave the config default" (omit), "force on" (--hf-upload), and "force off"
1436
+ # (--no-hf-upload). The `is not None` guard preserves the config default when the user
1437
+ # passes neither
1438
+ if hasattr(args, "hf_upload") and args.hf_upload is not None:
1439
+ config.hf.upload_after_training = args.hf_upload
1440
+ if hasattr(args, "hf_repo_id") and args.hf_repo_id is not None:
1441
+ config.hf.repo_id = args.hf_repo_id
1442
+ if hasattr(args, "hf_revision") and args.hf_revision is not None:
1443
+ config.hf.revision = args.hf_revision
1444
+
1445
+ # Checkpoint configuration
1446
+ if hasattr(args, "load_dir") and args.load_dir is not None:
1447
+ config.checkpoint.load_dir = args.load_dir
1448
+ if hasattr(args, "load_tag") and args.load_tag is not None:
1449
+ config.checkpoint.load_tag = args.load_tag
1450
+ config.checkpoint.infer_start_round() # Try inferring start_round from load_tag first
1451
+ if hasattr(args, "start_round") and args.start_round is not None:
1452
+ config.checkpoint.start_round = args.start_round # Override start_round if provided
1453
+ # NOTE: config.checkpoint.save_tag is resolved once in main() (before init_logger, so the log
1454
+ # file / config / artifacts share one datetime) via resolve_save_tag — not applied from args here.
1455
+ # force_tag uses argparse.BooleanOptionalAction with default=None (same tri-state as
1456
+ # hf_upload above)
1457
+ if hasattr(args, "force_tag") and args.force_tag is not None:
1458
+ config.checkpoint.force_tag = args.force_tag
1459
+
1460
+ # Inference configuration
1461
+ if hasattr(args, "encoder_path") and args.encoder_path is not None:
1462
+ config.inference.encoder_path = args.encoder_path
1463
+ if hasattr(args, "rf_path") and args.rf_path is not None:
1464
+ config.inference.rf_path = args.rf_path
1465
+ if hasattr(args, "config_path") and args.config_path is not None:
1466
+ config.inference.config_path = args.config_path
1467
+ if hasattr(args, "classification_threshold") and args.classification_threshold is not None:
1468
+ config.inference.classification_threshold = args.classification_threshold
1469
+ if hasattr(args, "screening_threshold") and args.screening_threshold is not None:
1470
+ config.inference.screening_threshold = args.screening_threshold
1471
+ if hasattr(args, "mc_draws") and args.mc_draws is not None:
1472
+ config.inference.mc_draws = args.mc_draws
1473
+ if hasattr(args, "reference_cloud_size") and args.reference_cloud_size is not None:
1474
+ config.inference.reference_cloud_size = args.reference_cloud_size
1475
+ if hasattr(args, "prefetch_depth") and args.prefetch_depth is not None:
1476
+ config.inference.prefetch_depth = args.prefetch_depth
1477
+ if (
1478
+ hasattr(args, "per_replica_batch_size")
1479
+ and args.per_replica_batch_size is not None
1480
+ and getattr(args, "command", None) == "inference"
1481
+ ):
1482
+ config.inference.per_replica_batch_size = args.per_replica_batch_size
1483
+
1484
+ # Energy detection preprocessing
1485
+ if hasattr(args, "cadence_group_by_cols") and args.cadence_group_by_cols is not None:
1486
+ config.inference.cadence_group_by_cols = args.cadence_group_by_cols
1487
+ if hasattr(args, "cadence_h5_path_col") and args.cadence_h5_path_col is not None:
1488
+ config.inference.cadence_h5_path_col = args.cadence_h5_path_col
1489
+ if hasattr(args, "cadence_expected_obs") and args.cadence_expected_obs is not None:
1490
+ config.inference.cadence_expected_obs = args.cadence_expected_obs
1491
+ if hasattr(args, "coarse_channel_width") and args.coarse_channel_width is not None:
1492
+ config.inference.coarse_channel_width = args.coarse_channel_width
1493
+ if (
1494
+ hasattr(args, "coarse_channel_log_interval")
1495
+ and args.coarse_channel_log_interval is not None
1496
+ ):
1497
+ config.inference.coarse_channel_log_interval = args.coarse_channel_log_interval
1498
+ if hasattr(args, "bandpass_method") and args.bandpass_method is not None:
1499
+ config.inference.bandpass_method = args.bandpass_method
1500
+ if hasattr(args, "pfb_taps_per_channel") and args.pfb_taps_per_channel is not None:
1501
+ config.inference.pfb_taps_per_channel = args.pfb_taps_per_channel
1502
+ # bandpass_debug_plot uses argparse.BooleanOptionalAction with default=None so the CLI
1503
+ # can express "leave the config default" (omit), "force on" (--bandpass-debug-plot),
1504
+ # and "force off" (--no-bandpass-debug-plot).
1505
+ if hasattr(args, "bandpass_debug_plot") and args.bandpass_debug_plot is not None:
1506
+ config.inference.bandpass_debug_plot = args.bandpass_debug_plot
1507
+ if hasattr(args, "spline_order") and args.spline_order is not None:
1508
+ config.inference.spline_order = args.spline_order
1509
+ if hasattr(args, "detection_window_size") and args.detection_window_size is not None:
1510
+ config.inference.detection_window_size = args.detection_window_size
1511
+ if hasattr(args, "detection_step_size") and args.detection_step_size is not None:
1512
+ config.inference.detection_step_size = args.detection_step_size
1513
+ if hasattr(args, "stat_threshold") and args.stat_threshold is not None:
1514
+ config.inference.stat_threshold = args.stat_threshold
1515
+ if hasattr(args, "stamp_width") and args.stamp_width is not None:
1516
+ config.inference.stamp_width = args.stamp_width
1517
+ # store_downsampled_stamps uses argparse.BooleanOptionalAction with default=None so that
1518
+ # the CLI can express "leave the config default" (omit), "force on"
1519
+ # (--store-downsampled-stamps), and "force off" (--no-store-downsampled-stamps)
1520
+ if hasattr(args, "store_downsampled_stamps") and args.store_downsampled_stamps is not None:
1521
+ config.inference.store_downsampled_stamps = args.store_downsampled_stamps
1522
+ # overlap_search uses argparse.BooleanOptionalAction with default=None so that
1523
+ # the CLI can express "leave the config default" (omit), "force on"
1524
+ # (--overlap-search), and "force off" (--no-overlap-search). The `is not None`
1525
+ # guard preserves the config default when the user passes neither.
1526
+ if hasattr(args, "overlap_search") and args.overlap_search is not None:
1527
+ config.inference.overlap_search = args.overlap_search
1528
+ if hasattr(args, "overlap_fraction") and args.overlap_fraction is not None:
1529
+ config.inference.overlap_fraction = args.overlap_fraction
1530
+ if hasattr(args, "preprocess_output_dir") and args.preprocess_output_dir is not None:
1531
+ config.inference.preprocess_output_dir = args.preprocess_output_dir
1532
+
1533
+ # Visualization suite
1534
+ # inference_viz uses argparse.BooleanOptionalAction with default=None so that the CLI
1535
+ # can express "leave the config default" (omit), "force on" (--inference-viz), and
1536
+ # "force off" (--no-inference-viz)
1537
+ # prune_stamps uses BooleanOptionalAction with default=None, where None means AUTO
1538
+ # (on for the fingerprint-default cache dir, off for an explicit one) — resolved at
1539
+ # run time by main._resolve_prune_stamps, so only explicit flags land here
1540
+ if hasattr(args, "prune_stamps") and args.prune_stamps is not None:
1541
+ config.inference.prune_stamps = args.prune_stamps
1542
+ if hasattr(args, "inference_viz") and args.inference_viz is not None:
1543
+ config.inference.inference_viz_enabled = args.inference_viz
1544
+ if hasattr(args, "inference_viz_scope") and args.inference_viz_scope is not None:
1545
+ config.inference.inference_viz_scope = args.inference_viz_scope
1546
+ if hasattr(args, "stamp_gallery_top_k") and args.stamp_gallery_top_k is not None:
1547
+ config.inference.stamp_gallery_top_k = args.stamp_gallery_top_k
1548
+ if hasattr(args, "max_candidate_plots") and args.max_candidate_plots is not None:
1549
+ config.inference.max_candidate_plots = args.max_candidate_plots
1550
+ if (
1551
+ hasattr(args, "max_retries")
1552
+ and args.max_retries is not None
1553
+ and getattr(args, "command", None) == "inference"
1554
+ ):
1555
+ config.inference.max_retries = args.max_retries
1556
+ if (
1557
+ hasattr(args, "retry_delay")
1558
+ and args.retry_delay is not None
1559
+ and getattr(args, "command", None) == "inference"
1560
+ ):
1561
+ config.inference.retry_delay = args.retry_delay
1562
+
1563
+
1564
+ # NOTE: come back to this later (make sure these checks are both comprehensive and implemented correctly)
1565
+ def collect_validation_errors(
1566
+ args: argparse.Namespace, num_replicas: int | None
1567
+ ) -> list[ValidationError]:
1568
+ """
1569
+ Collect semantic and cross-param validation failures for the parsed CLI namespace, merging
1570
+ args with config defaults via _resolve(). Returns a list rather than raising so utility
1571
+ scripts (e.g. utils/find_optimal_configs.py) can post-process violations and propose fixes;
1572
+ validate_args() is the thin wrapper that turns this list into a ValueError.
1573
+ """
1574
+
1575
+ config = get_config()
1576
+ if config is None:
1577
+ raise ValueError("get_config() returned None")
1578
+
1579
+ cmd = getattr(args, "command", None)
1580
+ errors: list[ValidationError] = []
1581
+
1582
+ # Shared host-runtime flags (#303) — both subcommands
1583
+ nproc = _resolve(args, "n_processes", config.manager.n_processes)
1584
+ if nproc is not None and nproc < 1:
1585
+ errors.append(
1586
+ ValidationError(
1587
+ field="manager.n_processes",
1588
+ current=nproc,
1589
+ message=f"--n-processes must be >= 1, got {nproc}",
1590
+ fix_kind="clamp_low",
1591
+ min_val=1,
1592
+ )
1593
+ )
1594
+
1595
+ # --num-replicas validation (>= 1 and <= available GPUs) is performed upstream
1596
+ # by _resolve_num_replicas(), which validate_args() calls before
1597
+ # collect_validation_errors(). By the time we get here, num_replicas is known to
1598
+ # be a sane positive int (or None on a no-GPU dev box, in which case cross-
1599
+ # replica divisibility checks are skipped further down).
1600
+
1601
+ # Checks below are ordered to follow the parser sections in _add_train_flags_to /
1602
+ # _add_inference_flags_to, which themselves mirror the sub-dataclass order in
1603
+ # config.py. Within each section, fields appear in the same order they are
1604
+ # registered on the parser. Cross-parameter checks live in the section of the
1605
+ # primary constrained field.
1606
+
1607
+ # ============================================================================
1608
+ # COMMON CHECKS — flags shared by both subcommands
1609
+ # ============================================================================
1610
+ # --hf-repo-id must look like an HF "namespace/name" repo id (shared Pattern B flag).
1611
+ hf_repo_id = _resolve(args, "hf_repo_id", config.hf.repo_id)
1612
+ if hf_repo_id is not None and not _HF_REPO_ID_PATTERN.match(hf_repo_id):
1613
+ errors.append(
1614
+ ValidationError(
1615
+ field="hf.repo_id",
1616
+ current=hf_repo_id,
1617
+ message=f"--hf-repo-id must be a HuggingFace repo id of the form namespace/name, got {hf_repo_id!r}",
1618
+ fix_kind="format",
1619
+ )
1620
+ )
1621
+
1622
+ # Reproducibility (#279, shared flags): the root seed must be non-negative
1623
+ # (np.random.SeedSequence and tf.random.set_seed both reject negatives at runtime)
1624
+ seed = _resolve(args, "seed", config.reproducibility.seed)
1625
+ if seed is not None and seed < 0:
1626
+ errors.append(
1627
+ ValidationError(
1628
+ field="reproducibility.seed",
1629
+ current=seed,
1630
+ message=f"--seed must be a non-negative integer, got {seed}",
1631
+ fix_kind="clamp_low",
1632
+ min_val=0,
1633
+ )
1634
+ )
1635
+
1636
+ # --unseeded is the explicit opt-out of the seeded default (#298); combining it with an
1637
+ # explicit --seed is contradictory — refuse rather than silently picking one
1638
+ if getattr(args, "unseeded", False) and getattr(args, "seed", None) is not None:
1639
+ errors.append(
1640
+ ValidationError(
1641
+ field="reproducibility.seed",
1642
+ current=args.seed,
1643
+ message="--unseeded and --seed are mutually exclusive: pass one or the other",
1644
+ fix_kind="cross_param",
1645
+ )
1646
+ )
1647
+
1648
+ # Same bound for the deprecated explicit RF override (train-only flag; _resolve falls
1649
+ # back to the config default on the inference namespace)
1650
+ rf_seed = _resolve(args, "rf_seed", config.rf.seed)
1651
+ if rf_seed is not None and rf_seed < 0:
1652
+ errors.append(
1653
+ ValidationError(
1654
+ field="rf.seed",
1655
+ current=rf_seed,
1656
+ message=f"--rf-seed must be a non-negative integer, got {rf_seed}",
1657
+ fix_kind="clamp_low",
1658
+ min_val=0,
1659
+ )
1660
+ )
1661
+
1662
+ # ============================================================================
1663
+ # TRAINING-MODE CHECKS — match _add_train_flags_to layout
1664
+ # ============================================================================
1665
+ if cmd == "train":
1666
+ # ---------------------------------------------------------------- BetaVAE
1667
+ # --vae-dense-layer-size must match width_bin // downsample_factor (a Data
1668
+ # constraint that lands on a BetaVAE field — gate on the BetaVAE field).
1669
+ wb = _resolve(args, "width_bin", config.data.width_bin)
1670
+ df = _resolve(args, "downsample_factor", config.data.downsample_factor)
1671
+ vds = _resolve(args, "vae_dense_layer_size", config.beta_vae.dense_layer_size)
1672
+ if wb and df and vds is not None:
1673
+ expected = wb // df
1674
+ if vds != expected:
1675
+ errors.append(
1676
+ ValidationError(
1677
+ field="beta_vae.dense_layer_size",
1678
+ current=vds,
1679
+ message=f"--vae-dense-layer-size ({vds}) must equal width_bin // downsample_factor ({wb} // {df} = {expected})",
1680
+ fix_kind="range",
1681
+ min_val=expected,
1682
+ max_val=expected,
1683
+ )
1684
+ )
1685
+
1686
+ # ---------------------------------------------------------- Random Forest
1687
+ rfmf = _resolve(args, "rf_max_features", config.rf.max_features)
1688
+ if rfmf is not None and not (
1689
+ rfmf in _RF_MAX_FEATURES_STR_VALUES or isinstance(rfmf, (int, float))
1690
+ ):
1691
+ errors.append(
1692
+ ValidationError(
1693
+ field="rf.max_features",
1694
+ current=rfmf,
1695
+ message=f"--rf-max-features must be one of {sorted(_RF_MAX_FEATURES_STR_VALUES)} or a number, got {rfmf!r}",
1696
+ fix_kind="enum",
1697
+ allowed=sorted(_RF_MAX_FEATURES_STR_VALUES),
1698
+ )
1699
+ )
1700
+
1701
+ rfj = _resolve(args, "rf_n_jobs", config.rf.n_jobs)
1702
+ n_cores = os.cpu_count() or 1
1703
+ if rfj is not None and not (-1 <= rfj <= n_cores):
1704
+ errors.append(
1705
+ ValidationError(
1706
+ field="rf.n_jobs",
1707
+ current=rfj,
1708
+ message=f"--rf-n-jobs must satisfy -1 <= n_jobs <= cpu_count ({n_cores}), got {rfj}",
1709
+ fix_kind="range",
1710
+ min_val=-1,
1711
+ max_val=n_cores,
1712
+ )
1713
+ )
1714
+
1715
+ # --------------------------------------------------------------------- GPU
1716
+ # (--num-replicas <= 0 lives in the COMMON checks block above; nothing else
1717
+ # in GPUConfig requires train-side validation.)
1718
+
1719
+ # -------------------------------------------------------------------- Data
1720
+ # TODO: Deferred -- time_bin & width_bin must match data shape — requires
1721
+ # loading actual data files; validated at runtime by the data loader.
1722
+ data_path = _resolve(args, "data_path", config.data_path)
1723
+ train_files = _resolve(args, "train_files", config.data.train_files) or []
1724
+ for f in train_files:
1725
+ full = config.get_training_file_path(f, data_path)
1726
+ if not os.path.exists(full):
1727
+ errors.append(
1728
+ ValidationError(
1729
+ field="data.train_files",
1730
+ current=f,
1731
+ message=f"--train-files: file does not exist on disk: {full}",
1732
+ fix_kind="file_exists",
1733
+ )
1734
+ )
1735
+
1736
+ # ---------------------------------------------------------------- Training
1737
+ # Resolve the fields needed by multiple checks up front so the cross-field
1738
+ # validations below can refer to them without re-resolving.
1739
+ ntr = _resolve(args, "num_training_rounds", config.training.num_training_rounds)
1740
+ nsb = _resolve(args, "num_samples_beta_vae", config.training.num_samples_beta_vae)
1741
+ nsr = _resolve(args, "num_samples_rf", config.training.num_samples_rf)
1742
+ tvs = _resolve(args, "train_val_split", config.training.train_val_split)
1743
+ prb = _resolve(args, "per_replica_batch_size", config.training.per_replica_batch_size)
1744
+ eb = _resolve(args, "effective_batch_size", config.training.effective_batch_size)
1745
+ prvb = _resolve(
1746
+ args, "per_replica_val_batch_size", config.training.per_replica_val_batch_size
1747
+ )
1748
+ sic = _resolve(
1749
+ args, "signal_injection_chunk_size", config.training.signal_injection_chunk_size
1750
+ )
1751
+ lvc = _resolve(
1752
+ args,
1753
+ "latent_viz_num_cadences_per_type",
1754
+ config.training.latent_viz_num_cadences_per_type,
1755
+ )
1756
+ cs = _resolve(args, "curriculum_schedule", config.training.curriculum_schedule)
1757
+
1758
+ # NOTE: come back to this later (should we parametrize num_signal_types = 4 in config.py?)
1759
+ # num_samples_beta_vae divisible by 4 (balanced class generation)
1760
+ if nsb is not None and nsb % 4 != 0:
1761
+ errors.append(
1762
+ ValidationError(
1763
+ field="training.num_samples_beta_vae",
1764
+ current=nsb,
1765
+ message=f"--num-samples-beta-vae must be divisible by 4 for balanced class generation, got {nsb}",
1766
+ fix_kind="divisibility",
1767
+ divisor=4,
1768
+ )
1769
+ )
1770
+ # num_samples_rf must be divisible by 4 (class balance) AND by 2 (triplet batches).
1771
+ if nsr is not None and nsr % 4 != 0:
1772
+ errors.append(
1773
+ ValidationError(
1774
+ field="training.num_samples_rf",
1775
+ current=nsr,
1776
+ message=f"--num-samples-rf must be divisible by 4 for balanced class generation, got {nsr}",
1777
+ fix_kind="divisibility",
1778
+ divisor=4,
1779
+ )
1780
+ )
1781
+ if nsr is not None and nsr % 2 != 0:
1782
+ errors.append(
1783
+ ValidationError(
1784
+ field="training.num_samples_rf",
1785
+ current=nsr,
1786
+ message=f"--num-samples-rf must be divisible by 2 for the balanced true/false halves in data generation, got {nsr}",
1787
+ fix_kind="divisibility",
1788
+ divisor=2,
1789
+ )
1790
+ )
1791
+
1792
+ # train_val_split bounds
1793
+ if tvs is not None and not (0 <= tvs <= 1):
1794
+ errors.append(
1795
+ ValidationError(
1796
+ field="training.train_val_split",
1797
+ current=tvs,
1798
+ message=f"--train-val-split must satisfy 0 <= split <= 1, got {tvs}",
1799
+ fix_kind="range",
1800
+ min_val=0.0,
1801
+ max_val=1.0,
1802
+ )
1803
+ )
1804
+
1805
+ # signal_injection_chunk_size divisible by 4 (class balance)
1806
+ if sic is not None and sic % 4 != 0:
1807
+ errors.append(
1808
+ ValidationError(
1809
+ field="training.signal_injection_chunk_size",
1810
+ current=sic,
1811
+ message=f"--signal-injection-chunk-size must be divisible by 4 for balanced class generation, got {sic}",
1812
+ fix_kind="divisibility",
1813
+ divisor=4,
1814
+ )
1815
+ )
1816
+
1817
+ # data_gen_task_size >= 1 (batched memmap worker tasks)
1818
+ dgts = _resolve(args, "data_gen_task_size", config.training.data_gen_task_size)
1819
+ if dgts is not None and dgts < 1:
1820
+ errors.append(
1821
+ ValidationError(
1822
+ field="training.data_gen_task_size",
1823
+ current=dgts,
1824
+ message=f"--data-gen-task-size must be >= 1, got {dgts}",
1825
+ fix_kind="clamp_low",
1826
+ min_val=1,
1827
+ )
1828
+ )
1829
+
1830
+ # Latent traversal: odd step count >= 3 (so the center column is the unperturbed
1831
+ # class-mean decode) and a strictly positive sigma range. No upper step bound:
1832
+ # decode cost scales linearly and stays trivial at any plausible value (99 steps
1833
+ # ~= 800 decoder calls), and an oversized figure is immediately self-evident
1834
+ lts = _resolve(
1835
+ args, "latent_traversal_num_steps", config.training.latent_traversal_num_steps
1836
+ )
1837
+ if lts is not None and (lts < 3 or lts % 2 == 0):
1838
+ errors.append(
1839
+ ValidationError(
1840
+ field="training.latent_traversal_num_steps",
1841
+ current=lts,
1842
+ message=f"--latent-traversal-num-steps must be odd and >= 3 so the center column is the unperturbed decode, got {lts}",
1843
+ # No fix_kind captures "odd and >= 3"; clamp_low is closest and the
1844
+ # proposal seed is the field's default (7 — odd and valid), matching the
1845
+ # max_sigma block below. propose_simple_fix only ever suggests this value.
1846
+ fix_kind="clamp_low",
1847
+ min_val=7,
1848
+ )
1849
+ )
1850
+ ltms = _resolve(
1851
+ args, "latent_traversal_max_sigma", config.training.latent_traversal_max_sigma
1852
+ )
1853
+ if ltms is not None and ltms <= 0:
1854
+ errors.append(
1855
+ ValidationError(
1856
+ field="training.latent_traversal_max_sigma",
1857
+ current=ltms,
1858
+ message=f"--latent-traversal-max-sigma must be > 0, got {ltms}",
1859
+ fix_kind="clamp_low",
1860
+ min_val=3.0, # Proposal seed: the field's default (matches num_steps above)
1861
+ )
1862
+ )
1863
+
1864
+ # Disk budget for the disk-backed round datasets (round_data.py): with overlap enabled
1865
+ # two rounds coexist on disk (round k trains while round k+1 generates), so require
1866
+ # 2.2x one round's estimated size free; 1.1x when overlap is disabled. Estimated from
1867
+ # sample counts only — actual usage tracks the estimate closely since the arrays are
1868
+ # fixed-shape (float32; halved under training.round_array_dtype="float16").
1869
+ nob = _resolve(args, "num_observations", config.data.num_observations)
1870
+ tb = _resolve(args, "time_bins", config.data.time_bins)
1871
+ overlap = _resolve(args, "overlap_data_generation", config.training.overlap_data_generation)
1872
+ round_data_dir = _resolve(args, "round_data_dir", config.training.round_data_dir)
1873
+ if round_data_dir is None:
1874
+ # data_path was resolved in the Data block above; round data are generated
1875
+ # training data, so they live under the training-data root
1876
+ round_data_dir = config.get_training_file_path("round_data", data_path)
1877
+ if all(v is not None for v in (nsb, nob, tb, wb)) and df:
1878
+ element_bytes = 2 if config.training.round_array_dtype == "float16" else 4
1879
+ round_nbytes = _estimate_round_data_nbytes(
1880
+ nsb, nob, tb, wb // df, bytes_per_element=element_bytes
1881
+ )
1882
+ required_factor = 2.2 if overlap else 1.1
1883
+ required_bytes = required_factor * round_nbytes
1884
+ try:
1885
+ free_bytes = shutil.disk_usage(_nearest_existing_ancestor(round_data_dir)).free
1886
+ except OSError as e:
1887
+ logger.warning(
1888
+ f"Could not check free disk space for --round-data-dir "
1889
+ f"({round_data_dir}): {e} — skipping the disk-budget check"
1890
+ )
1891
+ else:
1892
+ if free_bytes < required_bytes:
1893
+ errors.append(
1894
+ ValidationError(
1895
+ field="training.round_data_dir",
1896
+ current=round_data_dir,
1897
+ message=(
1898
+ f"--round-data-dir ({round_data_dir}) needs >= "
1899
+ f"{required_bytes / 1e9:.1f} GB free ({required_factor}x one "
1900
+ f"round's ~{round_nbytes / 1e9:.1f} GB"
1901
+ f"{' with data-generation overlap enabled' if overlap else ''}), "
1902
+ f"but only {free_bytes / 1e9:.1f} GB is available. Free up disk "
1903
+ f"space, point --round-data-dir at a larger volume, reduce "
1904
+ f"--num-samples-beta-vae, or pass --no-overlap-data-generation "
1905
+ f"to halve the requirement"
1906
+ ),
1907
+ fix_kind="file_exists",
1908
+ )
1909
+ )
1910
+
1911
+ # SNR sanity (positivity + curriculum ordering)
1912
+ snr_base = _resolve(args, "snr_base", config.training.snr_base)
1913
+ snr_init = _resolve(args, "initial_snr_range", config.training.initial_snr_range)
1914
+ snr_fin = _resolve(args, "final_snr_range", config.training.final_snr_range)
1915
+ if snr_base is not None and snr_base <= 0:
1916
+ errors.append(
1917
+ ValidationError(
1918
+ field="training.snr_base",
1919
+ current=snr_base,
1920
+ message=f"--snr-base must be > 0, got {snr_base}",
1921
+ fix_kind="clamp_low",
1922
+ min_val=1,
1923
+ )
1924
+ )
1925
+ if snr_init is not None and snr_init <= 0:
1926
+ errors.append(
1927
+ ValidationError(
1928
+ field="training.initial_snr_range",
1929
+ current=snr_init,
1930
+ message=f"--initial-snr-range must be > 0, got {snr_init}",
1931
+ fix_kind="clamp_low",
1932
+ min_val=1,
1933
+ )
1934
+ )
1935
+ if snr_fin is not None and snr_fin <= 0:
1936
+ errors.append(
1937
+ ValidationError(
1938
+ field="training.final_snr_range",
1939
+ current=snr_fin,
1940
+ message=f"--final-snr-range must be > 0, got {snr_fin}",
1941
+ fix_kind="clamp_low",
1942
+ min_val=1,
1943
+ )
1944
+ )
1945
+ if snr_init is not None and snr_fin is not None and snr_init < snr_fin:
1946
+ errors.append(
1947
+ ValidationError(
1948
+ field="training.initial_snr_range",
1949
+ current=snr_init,
1950
+ message=f"--initial-snr-range ({snr_init}) must be >= --final-snr-range ({snr_fin}) — curriculum schedules from easy to hard",
1951
+ fix_kind="clamp_low",
1952
+ min_val=snr_fin,
1953
+ )
1954
+ )
1955
+
1956
+ # Curriculum schedule enum + exponential / step parameters
1957
+ if cs is not None and cs not in _CURRICULUM_SCHEDULES:
1958
+ errors.append(
1959
+ ValidationError(
1960
+ field="training.curriculum_schedule",
1961
+ current=cs,
1962
+ message=f"--curriculum-schedule must be one of {sorted(_CURRICULUM_SCHEDULES)}, got {cs!r}",
1963
+ fix_kind="enum",
1964
+ allowed=sorted(_CURRICULUM_SCHEDULES),
1965
+ )
1966
+ )
1967
+
1968
+ edr = _resolve(args, "exponential_decay_rate", config.training.exponential_decay_rate)
1969
+ # exponential_decay_rate only governs the exponential schedule; skip it otherwise.
1970
+ if cs == "exponential" and edr is not None and edr >= 0:
1971
+ errors.append(
1972
+ ValidationError(
1973
+ field="training.exponential_decay_rate",
1974
+ current=edr,
1975
+ message=f"--exponential-decay-rate must be < 0 (more negative = faster difficulty ramp), got {edr}",
1976
+ fix_kind="clamp_high",
1977
+ max_val=-0.01,
1978
+ )
1979
+ )
1980
+
1981
+ ser = _resolve(args, "step_easy_rounds", config.training.step_easy_rounds)
1982
+ shr = _resolve(args, "step_hard_rounds", config.training.step_hard_rounds)
1983
+ # step_easy_rounds / step_hard_rounds only apply to the step schedule; skip their
1984
+ # range checks otherwise.
1985
+ if cs == "step" and ntr is not None and ser is not None and not (0 <= ser <= ntr):
1986
+ errors.append(
1987
+ ValidationError(
1988
+ field="training.step_easy_rounds",
1989
+ current=ser,
1990
+ message=f"--step-easy-rounds must satisfy 0 <= rounds <= num_training_rounds ({ntr}), got {ser}",
1991
+ fix_kind="range",
1992
+ min_val=0,
1993
+ max_val=ntr,
1994
+ )
1995
+ )
1996
+ if cs == "step" and ntr is not None and shr is not None and not (0 <= shr <= ntr):
1997
+ errors.append(
1998
+ ValidationError(
1999
+ field="training.step_hard_rounds",
2000
+ current=shr,
2001
+ message=f"--step-hard-rounds must satisfy 0 <= rounds <= num_training_rounds ({ntr}), got {shr}",
2002
+ fix_kind="range",
2003
+ min_val=0,
2004
+ max_val=ntr,
2005
+ )
2006
+ )
2007
+ # step_easy + step_hard only need to sum to num_training_rounds when step
2008
+ # schedule is selected.
2009
+ if (
2010
+ cs == "step"
2011
+ and ntr is not None
2012
+ and ser is not None
2013
+ and shr is not None
2014
+ and ser + shr != ntr
2015
+ ):
2016
+ errors.append(
2017
+ ValidationError(
2018
+ field="training.step_easy_rounds",
2019
+ current=ser,
2020
+ message=f"--step-easy-rounds + --step-hard-rounds ({ser} + {shr} = {ser + shr}) must equal --num-training-rounds ({ntr}) when curriculum_schedule=step",
2021
+ fix_kind="cross_param",
2022
+ extra={"step_hard_rounds": shr, "num_training_rounds": ntr},
2023
+ )
2024
+ )
2025
+
2026
+ # Learning rate / patience
2027
+ blr = _resolve(args, "base_learning_rate", config.training.base_learning_rate)
2028
+ mlr = _resolve(args, "min_learning_rate", config.training.min_learning_rate)
2029
+ if blr is not None and mlr is not None and blr < mlr:
2030
+ errors.append(
2031
+ ValidationError(
2032
+ field="training.base_learning_rate",
2033
+ current=blr,
2034
+ message=f"--base-learning-rate ({blr}) must be >= --min-learning-rate ({mlr})",
2035
+ fix_kind="clamp_low",
2036
+ min_val=mlr,
2037
+ )
2038
+ )
2039
+ mpi = _resolve(args, "min_pct_improvement", config.training.min_pct_improvement)
2040
+ if mpi is not None and mpi < 0:
2041
+ errors.append(
2042
+ ValidationError(
2043
+ field="training.min_pct_improvement",
2044
+ current=mpi,
2045
+ message=f"--min-pct-improvement must be >= 0, got {mpi}",
2046
+ fix_kind="clamp_low",
2047
+ min_val=0.0,
2048
+ )
2049
+ )
2050
+ pt = _resolve(args, "patience_threshold", config.training.patience_threshold)
2051
+ if pt is not None and pt < 1:
2052
+ errors.append(
2053
+ ValidationError(
2054
+ field="training.patience_threshold",
2055
+ current=pt,
2056
+ message=f"--patience-threshold must be >= 1 (must wait at least one epoch before reducing LR), got {pt}",
2057
+ fix_kind="clamp_low",
2058
+ min_val=1,
2059
+ )
2060
+ )
2061
+ lrf = _resolve(args, "lr_reduction_factor", config.training.reduction_factor)
2062
+ if lrf is not None and not (0 < lrf < 1):
2063
+ errors.append(
2064
+ ValidationError(
2065
+ field="training.reduction_factor",
2066
+ current=lrf,
2067
+ message=f"--lr-reduction-factor must satisfy 0 < factor < 1 (it's a reduction multiplier), got {lrf}",
2068
+ fix_kind="range",
2069
+ min_val=0.01,
2070
+ max_val=0.99,
2071
+ )
2072
+ )
2073
+
2074
+ # Retries (training-scoped — args.max_retries / args.retry_delay also exist
2075
+ # in the inference subparser, so the Pattern C gate on cmd kept this clean).
2076
+ mr = _resolve(args, "max_retries", config.training.max_retries)
2077
+ if mr is not None and mr < 1:
2078
+ errors.append(
2079
+ ValidationError(
2080
+ field="training.max_retries",
2081
+ current=mr,
2082
+ message=f"--max-retries must be >= 1 (the run needs at least one attempt), got {mr}",
2083
+ fix_kind="clamp_low",
2084
+ min_val=1,
2085
+ )
2086
+ )
2087
+ rd = _resolve(args, "retry_delay", config.training.retry_delay)
2088
+ if rd is not None and rd < 0:
2089
+ errors.append(
2090
+ ValidationError(
2091
+ field="training.retry_delay",
2092
+ current=rd,
2093
+ message=f"--retry-delay must be >= 0, got {rd}",
2094
+ fix_kind="clamp_low",
2095
+ min_val=0,
2096
+ )
2097
+ )
2098
+
2099
+ # Cross-replica batch / sample constraints. Tied to multiple Training fields
2100
+ # (effective_batch_size, per_replica_*_batch_size, num_samples_*, latent_viz_*)
2101
+ # plus num_replicas, so they live at the end of the Training section.
2102
+ if all(v is not None for v in (prb, eb, prvb, lvc, nsb, nsr, tvs)):
2103
+ if num_replicas is None:
2104
+ logger.warning(
2105
+ "GPU count unknown (TF reports 0 GPUs or is unavailable) — skipping "
2106
+ "cross-replica divisibility checks; they need a TF-visible GPU count "
2107
+ "even when --num-replicas is passed. The effective-batch-size check "
2108
+ "re-runs at training time once the real replica count is known."
2109
+ )
2110
+ elif num_replicas < 1:
2111
+ # _resolve_num_replicas would have raised before we got here for a real
2112
+ # pipeline invocation. Reachable only via direct callers (e.g.
2113
+ # utils/find_optimal_configs.py) that bypass validate_args; skip the
2114
+ # cross-replica section to avoid div-by-zero.
2115
+ pass
2116
+ else:
2117
+ train_samples = nsb * tvs
2118
+ val_samples = nsb * (1 - tvs)
2119
+ global_train_batch = prb * num_replicas
2120
+ global_val_batch = prvb * num_replicas
2121
+ latent_total = lvc * 4
2122
+
2123
+ if not (global_train_batch <= eb <= train_samples):
2124
+ errors.append(
2125
+ ValidationError(
2126
+ field="training.effective_batch_size",
2127
+ current=eb,
2128
+ message=f"--effective-batch-size ({eb}) must satisfy per_replica_batch_size * num_replicas ({prb} * {num_replicas} = {global_train_batch}) <= effective_batch_size <= num_samples_beta_vae * train_val_split ({nsb} * {tvs} = {train_samples})",
2129
+ fix_kind="cross_param",
2130
+ extra={
2131
+ "per_replica_batch_size": prb,
2132
+ "num_replicas": num_replicas,
2133
+ "num_samples_beta_vae": nsb,
2134
+ "train_val_split": tvs,
2135
+ },
2136
+ )
2137
+ )
2138
+ if global_val_batch > val_samples:
2139
+ errors.append(
2140
+ ValidationError(
2141
+ field="training.per_replica_val_batch_size",
2142
+ current=prvb,
2143
+ message=f"--per-replica-val-batch-size * num_replicas ({prvb} * {num_replicas} = {global_val_batch}) must be <= num_samples_beta_vae * (1 - train_val_split) ({nsb} * {1 - tvs:.4f} = {val_samples})",
2144
+ fix_kind="cross_param",
2145
+ )
2146
+ )
2147
+ # The RF splits num_samples_rf by train_val_split (like the beta-VAE), so the
2148
+ # val set the runtime actually builds is nsr * (1 - train_val_split), then
2149
+ # trims it to a multiple of the global val batch. Validate that val portion,
2150
+ # not raw nsr — otherwise train_random_forest fails at runtime with
2151
+ # "val_steps < 1: n_val_trimmed (0) ..." when the val split is too small.
2152
+ rf_val_samples = round(nsr * (1 - tvs))
2153
+ if global_val_batch > rf_val_samples:
2154
+ errors.append(
2155
+ ValidationError(
2156
+ field="training.num_samples_rf",
2157
+ current=nsr,
2158
+ message=f"num_samples_rf * (1 - train_val_split) ({nsr} * {1 - tvs:.4f} = {rf_val_samples}) must be >= per_replica_val_batch_size * num_replicas ({prvb} * {num_replicas} = {global_val_batch})",
2159
+ fix_kind="cross_param",
2160
+ )
2161
+ )
2162
+ # The RF's TRAIN split is trimmed to a multiple of effective_batch_size at
2163
+ # runtime (create_train_val_split); below one full batch it trims to ZERO
2164
+ # and the run dies at rf_train with "train_steps < 1" — after the beta-VAE
2165
+ # rounds already trained (#297). Divisibility is deliberately NOT required:
2166
+ # the defaults themselves rely on the trim (79,872 % 7,680 != 0); only the
2167
+ # >= one-effective-batch floor is a hard constraint. int(), not round():
2168
+ # the runtime split truncates PER LABEL (sum of int(count_l * tvs)), so
2169
+ # round()'s upward half could pass a config the runtime kills. Truncating
2170
+ # the total still over-estimates the per-label sum by at most
2171
+ # (n_labels - 1) samples — negligible against a multi-thousand-row batch
2172
+ # and covered by the runtime backstop for exact-floor pathologies.
2173
+ rf_train_samples = int(nsr * tvs)
2174
+ if eb > rf_train_samples:
2175
+ errors.append(
2176
+ ValidationError(
2177
+ field="training.num_samples_rf",
2178
+ current=nsr,
2179
+ message=f"num_samples_rf * train_val_split ({nsr} * {tvs:.4f} = {rf_train_samples}) must be >= --effective-batch-size ({eb}) — the RF train split trims to a multiple of the effective batch, so below one batch it trims to zero and rf_train fails at runtime",
2180
+ fix_kind="cross_param",
2181
+ )
2182
+ )
2183
+ if latent_total > val_samples:
2184
+ errors.append(
2185
+ ValidationError(
2186
+ field="training.latent_viz_num_cadences_per_type",
2187
+ current=lvc,
2188
+ message=f"--latent-viz-num-cadences-per-type * 4 ({lvc} * 4 = {latent_total}) must be <= num_samples_beta_vae * (1 - train_val_split) ({val_samples})",
2189
+ fix_kind="cross_param",
2190
+ )
2191
+ )
2192
+ if eb % global_train_batch != 0:
2193
+ errors.append(
2194
+ ValidationError(
2195
+ field="training.effective_batch_size",
2196
+ current=eb,
2197
+ message=f"--effective-batch-size ({eb}) must be divisible by per_replica_batch_size * num_replicas ({global_train_batch})",
2198
+ fix_kind="cross_param",
2199
+ divisor=global_train_batch,
2200
+ )
2201
+ )
2202
+ # Round to int — train_samples/val_samples come from `nsb * tvs` and
2203
+ # `nsb * (1 - tvs)` which suffer IEEE-754 noise (e.g.
2204
+ # 499200 * 0.2 = 99839.999...). Use round() so the intended sample
2205
+ # counts survive the cast.
2206
+ train_samples_i = round(train_samples)
2207
+ val_samples_i = round(val_samples)
2208
+ if train_samples_i % eb != 0:
2209
+ errors.append(
2210
+ ValidationError(
2211
+ field="training.num_samples_beta_vae",
2212
+ current=nsb,
2213
+ message=f"num_samples_beta_vae * train_val_split ({train_samples_i}) must be divisible by --effective-batch-size ({eb})",
2214
+ fix_kind="cross_param",
2215
+ divisor=eb,
2216
+ )
2217
+ )
2218
+ if val_samples_i % global_val_batch != 0:
2219
+ errors.append(
2220
+ ValidationError(
2221
+ field="training.num_samples_beta_vae",
2222
+ current=nsb,
2223
+ message=f"num_samples_beta_vae * (1 - train_val_split) ({val_samples_i}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
2224
+ fix_kind="cross_param",
2225
+ divisor=global_val_batch,
2226
+ )
2227
+ )
2228
+ if nsr % global_val_batch != 0:
2229
+ errors.append(
2230
+ ValidationError(
2231
+ field="training.num_samples_rf",
2232
+ current=nsr,
2233
+ message=f"--num-samples-rf ({nsr}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
2234
+ fix_kind="cross_param",
2235
+ divisor=global_val_batch,
2236
+ )
2237
+ )
2238
+ if latent_total % global_val_batch != 0:
2239
+ errors.append(
2240
+ ValidationError(
2241
+ field="training.latent_viz_num_cadences_per_type",
2242
+ current=lvc,
2243
+ message=f"--latent-viz-num-cadences-per-type * 4 ({latent_total}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
2244
+ fix_kind="cross_param",
2245
+ divisor=global_val_batch,
2246
+ )
2247
+ )
2248
+
2249
+ # -------------------------------------------------------------- Checkpoint
2250
+ # TODO: Deferred -- save_tag uniqueness check requires the DB (init_db runs
2251
+ # after validate_args).
2252
+ load_tag = _resolve(args, "load_tag", config.checkpoint.load_tag)
2253
+ if load_tag is not None and not _LOAD_TAG_PATTERN.match(load_tag):
2254
+ errors.append(
2255
+ ValidationError(
2256
+ field="checkpoint.load_tag",
2257
+ current=load_tag,
2258
+ message=f"--load-tag must be a full run tag ({{test|train|inf|bench}}_YYYYMMDD_HHMMSS) or round_XX; got {load_tag!r}",
2259
+ fix_kind="format",
2260
+ )
2261
+ )
2262
+
2263
+ # Resume footgun (issue #142): per-round checkpoints are saved under the checkpoints/
2264
+ # subdirectory, so `--load-tag round_XX` without `--load-dir checkpoints` searches the
2265
+ # models root instead and used to silently resume from a stale, unrelated model.
2266
+ load_dir = _resolve(args, "load_dir", config.checkpoint.load_dir)
2267
+ if (
2268
+ load_tag is not None
2269
+ and _ROUND_TAG_PATTERN.match(load_tag)
2270
+ and load_dir != "checkpoints"
2271
+ ):
2272
+ errors.append(
2273
+ ValidationError(
2274
+ field="checkpoint.load_dir",
2275
+ current=load_dir,
2276
+ message=(
2277
+ f"--load-tag {load_tag!r} names a per-round checkpoint, and those are "
2278
+ f"saved under the 'checkpoints/' subdirectory — pass --load-dir "
2279
+ f"checkpoints to resume from it. (If you genuinely keep a final model "
2280
+ f"tagged {load_tag!r} in the models root, re-save it under a full run "
2281
+ f"tag ({{test|train|inf|bench}}_YYYYMMDD_HHMMSS) and load that instead.)"
2282
+ ),
2283
+ fix_kind="cross_param",
2284
+ )
2285
+ )
2286
+
2287
+ sr = _resolve(args, "start_round", config.checkpoint.start_round)
2288
+ if sr is not None and ntr is not None and not (1 <= sr <= ntr):
2289
+ errors.append(
2290
+ ValidationError(
2291
+ field="checkpoint.start_round",
2292
+ current=sr,
2293
+ message=f"--start-round must satisfy 1 <= round <= num_training_rounds ({ntr}), got {sr}",
2294
+ fix_kind="range",
2295
+ min_val=1,
2296
+ max_val=ntr,
2297
+ )
2298
+ )
2299
+
2300
+ save_tag = getattr(args, "save_tag", None)
2301
+ if save_tag is not None and not _SAVE_TAG_PATTERN.match(save_tag):
2302
+ errors.append(
2303
+ ValidationError(
2304
+ field="checkpoint.save_tag",
2305
+ current=save_tag,
2306
+ message=f"--save-tag must be one of: test, train, inf, bench (the datetime is appended automatically); got {save_tag!r}",
2307
+ fix_kind="format",
2308
+ )
2309
+ )
2310
+
2311
+ # ============================================================================
2312
+ # INFERENCE-MODE CHECKS — match _add_inference_flags_to layout
2313
+ # ============================================================================
2314
+ if cmd == "inference":
2315
+ # ----------------------------------------------------- Inference model artifacts
2316
+ # The encoder/RF/config artifact trio is all-or-none: with all three paths given
2317
+ # they must each exist on disk; with none given, main.py's
2318
+ # resolve_inference_artifacts() has already filled `args` with HuggingFace-
2319
+ # downloaded cache paths before validation runs (utility scripts that skip that
2320
+ # step simply defer to the Hub default). A partial trio is an error — mixing local
2321
+ # and Hub-sourced artifacts would silently pair mismatched models.
2322
+ artifact_specs = (
2323
+ ("encoder_path", "encoder_path", "--encoder-path"),
2324
+ ("rf_path", "rf_path", "--rf-path"),
2325
+ ("config_path", "config_path", "--config-path"),
2326
+ )
2327
+ artifact_paths = {
2328
+ arg_name: _resolve(args, arg_name, getattr(config.inference, cfg_attr))
2329
+ for arg_name, cfg_attr, _flag in artifact_specs
2330
+ }
2331
+ n_provided = sum(path is not None for path in artifact_paths.values())
2332
+ for arg_name, cfg_attr, flag in artifact_specs:
2333
+ path = artifact_paths[arg_name]
2334
+ if path is None and 0 < n_provided < len(artifact_specs):
2335
+ errors.append(
2336
+ ValidationError(
2337
+ field=f"inference.{cfg_attr}",
2338
+ current=None,
2339
+ message=f"{flag} is missing: provide all three of --encoder-path/--rf-path/--config-path, or omit all three to download the artifacts from the HuggingFace Hub",
2340
+ fix_kind="file_exists",
2341
+ )
2342
+ )
2343
+ elif path is not None and not os.path.exists(path):
2344
+ errors.append(
2345
+ ValidationError(
2346
+ field=f"inference.{cfg_attr}",
2347
+ current=path,
2348
+ message=f"{flag}: file does not exist on disk: {path}",
2349
+ fix_kind="file_exists",
2350
+ )
2351
+ )
2352
+
2353
+ # -------------------------------------------------------------------- Data
2354
+ data_path = _resolve(args, "data_path", config.data_path)
2355
+ test_files = _resolve(args, "test_files", config.data.test_files) or []
2356
+ for f in test_files:
2357
+ full = config.get_test_file_path(f, data_path)
2358
+ if not os.path.exists(full):
2359
+ errors.append(
2360
+ ValidationError(
2361
+ field="data.test_files",
2362
+ current=f,
2363
+ message=f"--test-files: file does not exist on disk: {full}",
2364
+ fix_kind="file_exists",
2365
+ )
2366
+ )
2367
+ inf_files = _resolve(args, "inference_files", config.data.inference_files)
2368
+ for f in inf_files or []:
2369
+ full = config.get_inference_file_path(f, data_path)
2370
+ if not os.path.exists(full):
2371
+ errors.append(
2372
+ ValidationError(
2373
+ field="data.inference_files",
2374
+ current=f,
2375
+ message=f"--inference-files: file does not exist on disk: {full}",
2376
+ fix_kind="file_exists",
2377
+ )
2378
+ )
2379
+
2380
+ # --------------------------------------------------------------- Inference
2381
+ ct = _resolve(args, "classification_threshold", config.inference.classification_threshold)
2382
+ if ct is not None and not (0 <= ct <= 1):
2383
+ errors.append(
2384
+ ValidationError(
2385
+ field="inference.classification_threshold",
2386
+ current=ct,
2387
+ message=f"--classification-threshold must satisfy 0 <= threshold <= 1, got {ct}",
2388
+ fix_kind="range",
2389
+ min_val=0.0,
2390
+ max_val=1.0,
2391
+ )
2392
+ )
2393
+
2394
+ # Two-pass cascade (#282): the screen must be MORE permissive than the science
2395
+ # threshold — pass 1 only exists to reject cheap certain-negatives
2396
+ st = _resolve(args, "screening_threshold", config.inference.screening_threshold)
2397
+ if st is not None and not (0 <= st <= 1):
2398
+ errors.append(
2399
+ ValidationError(
2400
+ field="inference.screening_threshold",
2401
+ current=st,
2402
+ message=f"--screening-threshold must satisfy 0 <= threshold <= 1, got {st}",
2403
+ fix_kind="range",
2404
+ min_val=0.0,
2405
+ max_val=1.0,
2406
+ )
2407
+ )
2408
+ elif st is not None and ct is not None and st > ct:
2409
+ errors.append(
2410
+ ValidationError(
2411
+ field="inference.screening_threshold",
2412
+ current=st,
2413
+ message=(
2414
+ f"--screening-threshold ({st}) must not exceed "
2415
+ f"--classification-threshold ({ct}): the pass-1 screen must be the "
2416
+ "more permissive of the two (it can only ever REMOVE candidates)"
2417
+ ),
2418
+ fix_kind="cross_param",
2419
+ )
2420
+ )
2421
+
2422
+ mc_draws = _resolve(args, "mc_draws", config.inference.mc_draws)
2423
+ if mc_draws is not None and mc_draws < 1:
2424
+ errors.append(
2425
+ ValidationError(
2426
+ field="inference.mc_draws",
2427
+ current=mc_draws,
2428
+ message=f"--mc-draws must be a positive integer, got {mc_draws}",
2429
+ fix_kind="clamp_low",
2430
+ min_val=1,
2431
+ )
2432
+ )
2433
+
2434
+ cloud_size = _resolve(args, "reference_cloud_size", config.inference.reference_cloud_size)
2435
+ if cloud_size is not None and cloud_size < 0:
2436
+ errors.append(
2437
+ ValidationError(
2438
+ field="inference.reference_cloud_size",
2439
+ current=cloud_size,
2440
+ message=f"--reference-cloud-size must be >= 0, got {cloud_size}",
2441
+ fix_kind="clamp_low",
2442
+ min_val=0,
2443
+ )
2444
+ )
2445
+
2446
+ prefetch_depth = _resolve(args, "prefetch_depth", config.inference.prefetch_depth)
2447
+ if prefetch_depth is not None and prefetch_depth < 1:
2448
+ errors.append(
2449
+ ValidationError(
2450
+ field="inference.prefetch_depth",
2451
+ current=prefetch_depth,
2452
+ message=f"--prefetch-depth must be a positive integer, got {prefetch_depth}",
2453
+ fix_kind="clamp_low",
2454
+ min_val=1,
2455
+ )
2456
+ )
2457
+
2458
+ # The CLI enforces choices=["full", "new"], but the config field can be set
2459
+ # programmatically (or by a hand-edited config file passed through a future
2460
+ # surface) — a typo would otherwise silently render full-scope (#301 review note)
2461
+ viz_scope = _resolve(args, "inference_viz_scope", config.inference.inference_viz_scope)
2462
+ if viz_scope not in (None, "full", "new"):
2463
+ errors.append(
2464
+ ValidationError(
2465
+ field="inference.inference_viz_scope",
2466
+ current=viz_scope,
2467
+ message=f"--inference-viz-scope must be 'full' or 'new', got {viz_scope!r}",
2468
+ fix_kind="format",
2469
+ )
2470
+ )
2471
+
2472
+ # -------------------------------------------- Energy detection preprocessing
2473
+ # When inference_files is set the energy-detection pipeline runs before
2474
+ # inference; stamp_width must equal data.width_bin so the extracted
2475
+ # (n_hits, 6, 16, stamp_width) tensor matches the downstream downsample +
2476
+ # log-norm path.
2477
+ if inf_files is not None:
2478
+ sw_inf = _resolve(args, "stamp_width", config.inference.stamp_width)
2479
+ wb_inf = _resolve(args, "width_bin", config.data.width_bin)
2480
+ if sw_inf is not None and wb_inf is not None and sw_inf != wb_inf:
2481
+ errors.append(
2482
+ ValidationError(
2483
+ field="inference.stamp_width",
2484
+ current=sw_inf,
2485
+ message=(
2486
+ f"--stamp-width ({sw_inf}) must equal --width-bin ({wb_inf}) "
2487
+ "when --inference-files is set"
2488
+ ),
2489
+ fix_kind="range",
2490
+ min_val=wb_inf,
2491
+ max_val=wb_inf,
2492
+ )
2493
+ )
2494
+ group_cols = _resolve(
2495
+ args, "cadence_group_by_cols", config.inference.cadence_group_by_cols
2496
+ )
2497
+ if group_cols is not None and len(group_cols) == 0:
2498
+ errors.append(
2499
+ ValidationError(
2500
+ field="inference.cadence_group_by_cols",
2501
+ current=group_cols,
2502
+ message="--cadence-group-by-cols must be non-empty when --inference-files is provided",
2503
+ fix_kind="clamp_low",
2504
+ )
2505
+ )
2506
+ # Downsample-at-extraction stores stamp_width // downsample_factor bins per
2507
+ # stamp, so the division must be exact for the stored width to be well-defined
2508
+ store_ds = _resolve(
2509
+ args, "store_downsampled_stamps", config.inference.store_downsampled_stamps
2510
+ )
2511
+ df_inf = _resolve(args, "downsample_factor", config.data.downsample_factor)
2512
+ if (
2513
+ store_ds
2514
+ and sw_inf is not None
2515
+ and df_inf is not None
2516
+ and df_inf > 0
2517
+ and sw_inf % df_inf != 0
2518
+ ):
2519
+ errors.append(
2520
+ ValidationError(
2521
+ field="inference.stamp_width",
2522
+ current=sw_inf,
2523
+ message=(
2524
+ f"--stamp-width ({sw_inf}) must be divisible by "
2525
+ f"--downsample-factor ({df_inf}) when stamps are downsampled at "
2526
+ "extraction (--store-downsampled-stamps)"
2527
+ ),
2528
+ fix_kind="divisibility",
2529
+ divisor=df_inf,
2530
+ )
2531
+ )
2532
+
2533
+ coarse_channel_width = _resolve(
2534
+ args, "coarse_channel_width", config.inference.coarse_channel_width
2535
+ )
2536
+ coarse_channel_log_interval = _resolve(
2537
+ args, "coarse_channel_log_interval", config.inference.coarse_channel_log_interval
2538
+ )
2539
+ spline_order = _resolve(args, "spline_order", config.inference.spline_order)
2540
+ detection_window_size = _resolve(
2541
+ args, "detection_window_size", config.inference.detection_window_size
2542
+ )
2543
+ detection_step_size = _resolve(
2544
+ args, "detection_step_size", config.inference.detection_step_size
2545
+ )
2546
+ stat_threshold = _resolve(args, "stat_threshold", config.inference.stat_threshold)
2547
+ stamp_width = _resolve(args, "stamp_width", config.inference.stamp_width)
2548
+ overlap_fraction = _resolve(args, "overlap_fraction", config.inference.overlap_fraction)
2549
+
2550
+ # Positivity checks for the bare-int / bare-float fields, in parser order.
2551
+ for name, val in (
2552
+ ("coarse_channel_width", coarse_channel_width),
2553
+ ("coarse_channel_log_interval", coarse_channel_log_interval),
2554
+ ("stat_threshold", stat_threshold),
2555
+ ("stamp_width", stamp_width),
2556
+ ):
2557
+ if val is not None and val <= 0:
2558
+ errors.append(
2559
+ ValidationError(
2560
+ field=f"inference.{name}",
2561
+ current=val,
2562
+ message=f"--{name.replace('_', '-')} must be > 0, got {val}",
2563
+ fix_kind="clamp_low",
2564
+ min_val=1,
2565
+ )
2566
+ )
2567
+ if spline_order is not None and spline_order < 1:
2568
+ errors.append(
2569
+ ValidationError(
2570
+ field="inference.spline_order",
2571
+ current=spline_order,
2572
+ message=f"--spline-order must be >= 1, got {spline_order}",
2573
+ fix_kind="clamp_low",
2574
+ min_val=1,
2575
+ )
2576
+ )
2577
+ bandpass_method = _resolve(args, "bandpass_method", config.inference.bandpass_method)
2578
+ if bandpass_method is not None and bandpass_method not in _BANDPASS_METHODS:
2579
+ errors.append(
2580
+ ValidationError(
2581
+ field="inference.bandpass_method",
2582
+ current=bandpass_method,
2583
+ message=f"--bandpass-method must be one of {sorted(_BANDPASS_METHODS)}, got {bandpass_method!r}",
2584
+ fix_kind="enum",
2585
+ allowed=sorted(_BANDPASS_METHODS),
2586
+ )
2587
+ )
2588
+ pfb_taps = _resolve(args, "pfb_taps_per_channel", config.inference.pfb_taps_per_channel)
2589
+ if pfb_taps is not None and pfb_taps < 1:
2590
+ errors.append(
2591
+ ValidationError(
2592
+ field="inference.pfb_taps_per_channel",
2593
+ current=pfb_taps,
2594
+ message=f"--pfb-taps-per-channel must be >= 1, got {pfb_taps}",
2595
+ fix_kind="clamp_low",
2596
+ min_val=1,
2597
+ )
2598
+ )
2599
+ # detection_window_size <= stamp_width
2600
+ if (
2601
+ detection_window_size is not None
2602
+ and stamp_width is not None
2603
+ and detection_window_size > stamp_width
2604
+ ):
2605
+ errors.append(
2606
+ ValidationError(
2607
+ field="inference.detection_window_size",
2608
+ current=detection_window_size,
2609
+ message=(
2610
+ f"--detection-window-size ({detection_window_size}) must be"
2611
+ f" <= --stamp-width ({stamp_width})"
2612
+ ),
2613
+ fix_kind="clamp_high",
2614
+ max_val=stamp_width,
2615
+ )
2616
+ )
2617
+ # detection_step_size > 0 and <= detection_window_size
2618
+ if detection_step_size is not None and detection_step_size <= 0:
2619
+ errors.append(
2620
+ ValidationError(
2621
+ field="inference.detection_step_size",
2622
+ current=detection_step_size,
2623
+ message=f"--detection-step-size must be > 0, got {detection_step_size}",
2624
+ fix_kind="clamp_low",
2625
+ min_val=1,
2626
+ )
2627
+ )
2628
+ if (
2629
+ detection_step_size is not None
2630
+ and detection_window_size is not None
2631
+ and detection_step_size > detection_window_size
2632
+ ):
2633
+ errors.append(
2634
+ ValidationError(
2635
+ field="inference.detection_step_size",
2636
+ current=detection_step_size,
2637
+ message=(
2638
+ f"--detection-step-size ({detection_step_size}) must be"
2639
+ f" <= --detection-window-size ({detection_window_size})"
2640
+ ),
2641
+ fix_kind="clamp_high",
2642
+ max_val=detection_window_size,
2643
+ )
2644
+ )
2645
+ if overlap_fraction is not None and not (0 <= overlap_fraction <= 1):
2646
+ errors.append(
2647
+ ValidationError(
2648
+ field="inference.overlap_fraction",
2649
+ current=overlap_fraction,
2650
+ message=f"--overlap-fraction must satisfy 0 <= fraction <= 1, got {overlap_fraction}",
2651
+ fix_kind="range",
2652
+ min_val=0.0,
2653
+ max_val=1.0,
2654
+ )
2655
+ )
2656
+
2657
+ # Visualization suite
2658
+ stamp_gallery_top_k = _resolve(
2659
+ args, "stamp_gallery_top_k", config.inference.stamp_gallery_top_k
2660
+ )
2661
+ if stamp_gallery_top_k is not None and stamp_gallery_top_k < 1:
2662
+ errors.append(
2663
+ ValidationError(
2664
+ field="inference.stamp_gallery_top_k",
2665
+ current=stamp_gallery_top_k,
2666
+ message=f"--stamp-gallery-top-k must be >= 1, got {stamp_gallery_top_k}",
2667
+ fix_kind="clamp_low",
2668
+ min_val=1,
2669
+ )
2670
+ )
2671
+ max_candidate_plots = _resolve(
2672
+ args, "max_candidate_plots", config.inference.max_candidate_plots
2673
+ )
2674
+ if max_candidate_plots is not None and max_candidate_plots < 0:
2675
+ errors.append(
2676
+ ValidationError(
2677
+ field="inference.max_candidate_plots",
2678
+ current=max_candidate_plots,
2679
+ message=f"--max-candidate-plots must be >= 0, got {max_candidate_plots}",
2680
+ fix_kind="clamp_low",
2681
+ min_val=0,
2682
+ )
2683
+ )
2684
+
2685
+ # Retries (inference-scoped)
2686
+ mr = _resolve(args, "max_retries", config.inference.max_retries)
2687
+ if mr is not None and mr < 0:
2688
+ errors.append(
2689
+ ValidationError(
2690
+ field="inference.max_retries",
2691
+ current=mr,
2692
+ message=f"--max-retries must be >= 0, got {mr}",
2693
+ fix_kind="clamp_low",
2694
+ min_val=0,
2695
+ )
2696
+ )
2697
+ rd = _resolve(args, "retry_delay", config.inference.retry_delay)
2698
+ if rd is not None and rd < 0:
2699
+ errors.append(
2700
+ ValidationError(
2701
+ field="inference.retry_delay",
2702
+ current=rd,
2703
+ message=f"--retry-delay must be >= 0, got {rd}",
2704
+ fix_kind="clamp_low",
2705
+ min_val=0,
2706
+ )
2707
+ )
2708
+
2709
+ # -------------------------------------------------------------- Checkpoint
2710
+ save_tag = getattr(args, "save_tag", None)
2711
+ if save_tag is not None and not _SAVE_TAG_PATTERN.match(save_tag):
2712
+ errors.append(
2713
+ ValidationError(
2714
+ field="checkpoint.save_tag",
2715
+ current=save_tag,
2716
+ message=f"--save-tag must be one of: test, train, inf, bench (the datetime is appended automatically); got {save_tag!r}",
2717
+ fix_kind="format",
2718
+ )
2719
+ )
2720
+
2721
+ return errors
2722
+
2723
+
2724
+ # NOTE: come back to this later (everything in this section)
2725
+ # ---------------------------------------------------------------------------
2726
+ # Fix proposal surface — used both by validate_args (to include suggestions in
2727
+ # the ValueError message on failure) and by utils/find_optimal_configs.py (to
2728
+ # print suggestions to stdout when run directly). Kept here in cli.py rather
2729
+ # than utils/ so cli.py can call them without a circular import or sys.path
2730
+ # gymnastics, and so the validation + proposal logic stays colocated.
2731
+ # ---------------------------------------------------------------------------
2732
+
2733
+ # Neighborhoods for the fields the solver prefers to keep near their base value (data sizes +
2734
+ # the throughput-optimal per-replica batch). effective_batch_size and per_replica_val_batch_size
2735
+ # are deliberately NOT here: they are generated exactly from the divisibility structure (divisors
2736
+ # of the train split / of the gcd of the val-side counts), so the search is small and complete
2737
+ # rather than a coarse grid that either explodes or steps over the only valid values.
2738
+ _SEARCH_RANGES: dict[str, tuple[int, int, int]] = {
2739
+ # (min, max, step)
2740
+ "num_samples_beta_vae": (400_000, 600_000, 10_240),
2741
+ "num_samples_rf": (80_000, 120_000, 2_048),
2742
+ "per_replica_batch_size": (64, 256, 64),
2743
+ }
2744
+
2745
+ _CROSS_PARAM_FIELDS = (
2746
+ "num_samples_beta_vae",
2747
+ "num_samples_rf",
2748
+ "per_replica_batch_size",
2749
+ "effective_batch_size",
2750
+ "per_replica_val_batch_size",
2751
+ )
2752
+
2753
+
2754
+ def _divisors(n: int) -> list[int]:
2755
+ """Sorted positive divisors of ``n`` (empty for n < 1)."""
2756
+ if n < 1:
2757
+ return []
2758
+ small: list[int] = []
2759
+ large: list[int] = []
2760
+ i = 1
2761
+ while i * i <= n:
2762
+ if n % i == 0:
2763
+ small.append(i)
2764
+ if i != n // i:
2765
+ large.append(n // i)
2766
+ i += 1
2767
+ return small + large[::-1]
2768
+
2769
+
2770
+ def _check_cross_constraints(
2771
+ num_samples_beta_vae: int,
2772
+ num_samples_rf: int,
2773
+ train_val_split: float,
2774
+ per_replica_batch_size: int,
2775
+ effective_batch_size: int,
2776
+ per_replica_val_batch_size: int,
2777
+ num_replicas_list: list[int],
2778
+ latent_total: int | None = None,
2779
+ ) -> bool:
2780
+ """Return True iff the six-tuple satisfies all cross-replica constraints for every
2781
+ num_replicas in the list. Mirrors the cross-replica checks in
2782
+ :func:`collect_validation_errors`. When ``latent_total`` (= latent_viz_num_cadences_per_type
2783
+ * 4) is supplied, the latent-viz divisibility check is included too, so a returned solution is
2784
+ valid against every cross-replica constraint the pipeline enforces — pass None to skip it (e.g.
2785
+ when the caller doesn't vary anything the latent batch depends on)."""
2786
+ if not (0 <= train_val_split <= 1):
2787
+ return False
2788
+ if num_samples_rf % 2 != 0:
2789
+ return False
2790
+ for nr in num_replicas_list:
2791
+ gtrain = per_replica_batch_size * nr
2792
+ gval = per_replica_val_batch_size * nr
2793
+ # round() rather than int() — `num_samples_beta_vae * (1 - train_val_split)`
2794
+ # suffers IEEE-754 noise (e.g. 499200 * 0.2 = 99839.999...) and int() would
2795
+ # truncate to 99839.
2796
+ train_samples = round(num_samples_beta_vae * train_val_split)
2797
+ val_samples = round(num_samples_beta_vae * (1 - train_val_split))
2798
+ if not (gtrain <= effective_batch_size <= num_samples_beta_vae * train_val_split):
2799
+ return False
2800
+ if gval > num_samples_beta_vae * (1 - train_val_split):
2801
+ return False
2802
+ if gval > num_samples_rf:
2803
+ return False
2804
+ # RF train-split floor (#297): mirrors collect_validation_errors — the RF stage
2805
+ # trims its train split to a multiple of the effective batch, so it needs at
2806
+ # least one full effective batch after the split. int() matches the validator
2807
+ # (the runtime truncates per label; see the comment there).
2808
+ if effective_batch_size > int(num_samples_rf * train_val_split):
2809
+ return False
2810
+ if effective_batch_size % gtrain != 0:
2811
+ return False
2812
+ if train_samples % effective_batch_size != 0:
2813
+ return False
2814
+ if val_samples % gval != 0:
2815
+ return False
2816
+ if num_samples_rf % gval != 0:
2817
+ return False
2818
+ if latent_total is not None and latent_total % gval != 0:
2819
+ return False
2820
+ return True
2821
+
2822
+
2823
+ def _solve_cross_param_constraints(
2824
+ base: dict[str, int | float],
2825
+ num_replicas_list: list[int],
2826
+ max_candidates: int = 5_000_000,
2827
+ ) -> dict[str, int | float] | None:
2828
+ """Find the batch/sample configuration nearest (L1) to ``base`` that satisfies every
2829
+ cross-replica constraint for all replica counts in ``num_replicas_list``, or None if none
2830
+ exists. ``base`` may carry an optional ``latent_total`` key (= latent_viz_num_cadences_per_type
2831
+ * 4) so the latent-viz constraint is honored.
2832
+
2833
+ Rather than a blind grid (which both explodes past the candidate budget and steps over the only
2834
+ valid values), the effective batch and per-replica val batch — the two purely divisibility-bound
2835
+ fields — are enumerated *exactly* from the structure: the effective batch must be a divisor of
2836
+ the train split that is a multiple of ``per_replica_batch_size * num_replicas`` for every replica
2837
+ count; the global val batch must divide the gcd of the val split, num_samples_rf, and (if given)
2838
+ latent_total. The data sizes and per-replica batch are only searched over small neighborhoods
2839
+ (seeded with the base value, so they're preferred when a solution keeps them) — but because the
2840
+ result is chosen purely by L1 distance, a multi-count solve can still move per_replica_batch when
2841
+ doing so is closer than raising the effective batch to the larger multiple all counts share (e.g.
2842
+ a 4/5/6-GPU solve drops it to 64 rather than lifting the effective batch to lcm(128*{4,5,6})).
2843
+ Every candidate is confirmed with the shared :func:`_check_cross_constraints`, which stays the
2844
+ single source of truth."""
2845
+ latent_total = base.get("latent_total")
2846
+ tvs = base["train_val_split"]
2847
+ b_nsb = base["num_samples_beta_vae"]
2848
+ b_nsr = base["num_samples_rf"]
2849
+ b_p = base["per_replica_batch_size"]
2850
+ b_e = base["effective_batch_size"]
2851
+ b_v = base["per_replica_val_batch_size"]
2852
+
2853
+ def _ok(nsb, nsr, p, e, v):
2854
+ return _check_cross_constraints(
2855
+ nsb, nsr, tvs, p, e, v, num_replicas_list, latent_total=latent_total
2856
+ )
2857
+
2858
+ if _ok(b_nsb, b_nsr, b_p, b_e, b_v):
2859
+ return {
2860
+ "num_samples_beta_vae": b_nsb,
2861
+ "num_samples_rf": b_nsr,
2862
+ "train_val_split": tvs,
2863
+ "per_replica_batch_size": b_p,
2864
+ "effective_batch_size": b_e,
2865
+ "per_replica_val_batch_size": b_v,
2866
+ }
2867
+
2868
+ def _neighborhood(field: str) -> list[int]:
2869
+ lo, hi, step = _SEARCH_RANGES[field]
2870
+ return sorted({base[field]} | set(range(lo, hi + 1, step)))
2871
+
2872
+ nsb_cands = _neighborhood("num_samples_beta_vae")
2873
+ nsr_cands = _neighborhood("num_samples_rf")
2874
+ p_cands = _neighborhood("per_replica_batch_size")
2875
+
2876
+ best: dict[str, int | float] | None = None
2877
+ best_dist = float("inf")
2878
+ checked = 0
2879
+ for nsb in nsb_cands:
2880
+ train_samples = round(nsb * tvs)
2881
+ val_samples = round(nsb * (1 - tvs))
2882
+ train_divisors = _divisors(train_samples)
2883
+ for p in p_cands:
2884
+ gtrains = [p * nr for nr in num_replicas_list]
2885
+ lcm_gtrain = 1
2886
+ for gt in gtrains:
2887
+ lcm_gtrain = lcm(lcm_gtrain, gt)
2888
+ min_e = max(gtrains)
2889
+ e_cands = [d for d in train_divisors if d >= min_e and d % lcm_gtrain == 0]
2890
+ if not e_cands:
2891
+ continue
2892
+ for nsr in nsr_cands:
2893
+ if nsr % 2 != 0:
2894
+ continue
2895
+ g = gcd(val_samples, nsr)
2896
+ if latent_total is not None:
2897
+ g = gcd(g, latent_total)
2898
+ v_cands = [
2899
+ d for d in _divisors(g) if all(g % (d * nr) == 0 for nr in num_replicas_list)
2900
+ ]
2901
+ for e in e_cands:
2902
+ for v in v_cands:
2903
+ if checked >= max_candidates:
2904
+ return best
2905
+ checked += 1
2906
+ if not _ok(nsb, nsr, p, e, v):
2907
+ continue
2908
+ dist = (
2909
+ abs(nsb - b_nsb)
2910
+ + abs(nsr - b_nsr)
2911
+ + abs(p - b_p)
2912
+ + abs(e - b_e)
2913
+ + abs(v - b_v)
2914
+ )
2915
+ if dist < best_dist:
2916
+ best_dist = dist
2917
+ best = {
2918
+ "num_samples_beta_vae": nsb,
2919
+ "num_samples_rf": nsr,
2920
+ "train_val_split": tvs,
2921
+ "per_replica_batch_size": p,
2922
+ "effective_batch_size": e,
2923
+ "per_replica_val_batch_size": v,
2924
+ }
2925
+ return best
2926
+
2927
+
2928
+ def _round_to_multiple(val: int, divisor: int) -> int:
2929
+ """Round ``val`` to the nearest multiple of ``divisor`` (ties round up)."""
2930
+ q, r = divmod(val, divisor)
2931
+ return divisor * (q if r * 2 < divisor else q + 1)
2932
+
2933
+
2934
+ def propose_simple_fix(err: ValidationError) -> object | None:
2935
+ """Suggest a single replacement value for a non-cross-param violation. Returns None when
2936
+ a violation cannot be auto-fixed (e.g. a missing file)."""
2937
+ if err.fix_kind == "clamp_low" and err.min_val is not None:
2938
+ return err.min_val
2939
+ if err.fix_kind == "clamp_high" and err.max_val is not None:
2940
+ return err.max_val
2941
+ if err.fix_kind == "range" and err.min_val is not None and err.max_val is not None:
2942
+ if isinstance(err.current, (int, float)):
2943
+ return max(err.min_val, min(err.current, err.max_val))
2944
+ return err.min_val
2945
+ if err.fix_kind == "enum" and err.allowed:
2946
+ return err.allowed[0]
2947
+ if err.fix_kind == "divisibility" and err.divisor and isinstance(err.current, int):
2948
+ return _round_to_multiple(err.current, err.divisor)
2949
+ return None # format / file_exists / cross_param handled separately
2950
+
2951
+
2952
+ def _format_proposal_value(val: object) -> str:
2953
+ """Format a proposed value for the CLI flag hint shown to the user."""
2954
+ if isinstance(val, float):
2955
+ return f"{val:g}"
2956
+ if isinstance(val, list):
2957
+ return "[" + ", ".join(str(x) for x in val) + "]"
2958
+ return str(val)
2959
+
2960
+
2961
+ def _build_suggestion_block(errors: list[ValidationError], num_replicas: int | None) -> str:
2962
+ """Compose the 'Suggested fixes:' block appended to the ValueError message.
2963
+
2964
+ Mirrors the proposer logic in utils/find_optimal_configs.py: simple violations get
2965
+ a per-field replacement value via :func:`propose_simple_fix`; cross-replica
2966
+ violations trigger a single grid search via :func:`_solve_cross_param_constraints`.
2967
+ Returns an empty string if nothing actionable was found.
2968
+ """
2969
+ config = get_config()
2970
+ simple = [e for e in errors if e.fix_kind != "cross_param"]
2971
+ cross = [e for e in errors if e.fix_kind == "cross_param"]
2972
+ # Keyed on (field, fix_kind): distinct violations of the SAME field (e.g. the
2973
+ # stamp_width equality and divisibility checks both firing) must not silently
2974
+ # overwrite each other's proposal — one flag line per violation is honest, even
2975
+ # when that repeats a flag with different candidate values.
2976
+ proposals: dict[tuple[str, str], object] = {}
2977
+
2978
+ for err in simple:
2979
+ fix = propose_simple_fix(err)
2980
+ if fix is not None:
2981
+ proposals[(err.field, err.fix_kind)] = fix
2982
+
2983
+ if cross and num_replicas is not None and config is not None:
2984
+ base = {
2985
+ "num_samples_beta_vae": config.training.num_samples_beta_vae,
2986
+ "num_samples_rf": config.training.num_samples_rf,
2987
+ "train_val_split": config.training.train_val_split,
2988
+ "per_replica_batch_size": config.training.per_replica_batch_size,
2989
+ "effective_batch_size": config.training.effective_batch_size,
2990
+ "per_replica_val_batch_size": config.training.per_replica_val_batch_size,
2991
+ "latent_total": config.training.latent_viz_num_cadences_per_type * 4,
2992
+ }
2993
+ solution = _solve_cross_param_constraints(base, [num_replicas])
2994
+ if solution is not None:
2995
+ for f_name in _CROSS_PARAM_FIELDS + ("train_val_split",):
2996
+ if solution[f_name] != base[f_name]:
2997
+ proposals[(f"training.{f_name}", "cross_param")] = solution[f_name]
2998
+
2999
+ if not proposals:
3000
+ return ""
3001
+
3002
+ flag_lines: list[str] = []
3003
+ for (f_name, _fix_kind), value in proposals.items():
3004
+ # Drop the config-section prefix and switch underscores to hyphens.
3005
+ short = f_name.split(".", 1)[-1]
3006
+ flag = "--" + short.replace("_", "-")
3007
+ flag_lines.append(f" {flag} {_format_proposal_value(value)}")
3008
+
3009
+ body = " \\\n".join(flag_lines)
3010
+ return (
3011
+ "\n\nSuggested fixes (run `python utils/find_optimal_configs.py` for the full "
3012
+ "report):\n" + body
3013
+ )
3014
+
3015
+
3016
+ def validate_args(args: argparse.Namespace) -> None:
3017
+ """
3018
+ Pre-flight semantic and cross-parameter validation for the parsed CLI namespace, run before
3019
+ apply_args_to_config(). Delegates to _resolve_num_replicas() (which also fails fast on a
3020
+ replica count that's unusable on this host) and collect_validation_errors(), and raises
3021
+ ValueError if any failures came back. On failure the ValueError message includes a list of
3022
+ suggested CLI flags from the same proposer surface that powers utils/find_optimal_configs.py.
3023
+ Syntax and type checks are handled earlier by argparse itself in ArgumentParser.parse_args
3024
+ (called from main.py:main).
3025
+ """
3026
+ num_replicas = _resolve_num_replicas(args)
3027
+ # NOTE: come back to this later (should we log a help message to use utils/find_optimal_configs.py if the inline `Suggested fixes: ` block isn't enough? alternatively, why don't we make it such that validate_args and the utility script share the exact same proposer surface -- e.g. bounded grid search on both, not just utility script. then, the purpose of the utility script would simply be to test hypothetical configurations? update docs/CONFIG_AND_CLI.md if any changes.)
3028
+ errors = collect_validation_errors(args, num_replicas)
3029
+ if errors:
3030
+ violation_block = "\n".join(f" - {e.message}" for e in errors)
3031
+ suggestions = _build_suggestion_block(errors, num_replicas)
3032
+ raise ValueError(f"Invalid arguments detected:\n{violation_block}{suggestions}")