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/config.py ADDED
@@ -0,0 +1,1002 @@
1
+ # Note, we avoid logging anything in config.py to prevent coupling with the logger module
2
+ """
3
+ Configuration module for Aetherscan Pipeline
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import threading
10
+ from dataclasses import dataclass, field
11
+ from multiprocessing import cpu_count
12
+
13
+
14
+ @dataclass
15
+ class DBConfig:
16
+ """SQLite database configuration"""
17
+
18
+ get_connection_timeout: float = 60.0 # seconds
19
+ stop_writer_timeout: float = 10.0 # seconds
20
+ write_interval: float = 5.0 # seconds
21
+ # Rows per foreground transaction. 100 meant a commit (and fsync) every 100 rows — one of
22
+ # the drivers of the ~590 rows/s writer that let a multi-hour backlog build up (#277)
23
+ write_buffer_max_size: int = 5000 # records
24
+ write_retry_delay: float = 1.0 # seconds
25
+ flush_timeout: float = 10.0 # seconds
26
+ # Bulk lane (#277): high-volume injection stats ride a separate bounded queue so a plot
27
+ # flush only has to drain the (small) foreground lane, and queue growth is capped —
28
+ # backpressure blocks the background enqueuer (the round-data drainer thread), never the
29
+ # training path. bulk_chunk_rows is both the enqueue granularity and the bulk transaction
30
+ # size; the cap is bulk_queue_max_items * bulk_chunk_rows rows in memory (~1.6M at defaults).
31
+ bulk_chunk_rows: int = 50_000 # rows per bulk queue item / transaction
32
+ bulk_queue_max_items: int = 32 # bounded bulk-lane depth (items)
33
+ # Shutdown drain (#277): stop() drains BOTH lanes to disk before the writer exits (the old
34
+ # behavior silently dropped everything still queued — up to ~26M rows on the release run).
35
+ # If the drain exceeds this cap the remaining rows are dropped with an exact ERROR count.
36
+ stop_drain_timeout: float = 600.0 # seconds
37
+
38
+
39
+ @dataclass
40
+ class ManagerConfig:
41
+ """Resource manager configuration"""
42
+
43
+ n_processes: int = cpu_count() # use all available cores
44
+ # TODO: experiment with larger chunk sizes (how to track chunk processing efficiency)
45
+ # NOTE: should we move chunks_per_worker to DataConfig() or TrainingConfig() and make it specific to preproc/data_gen?
46
+ chunks_per_worker: int = 4 # for balancing overhead vs parallelism
47
+ pool_terminate_timeout: float = (
48
+ 10.0 # seconds (actual timeout may be 2x this value -- from terminate + join threads)
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class MonitorConfig:
54
+ """Resource monitor configuration"""
55
+
56
+ get_gpu_timeout: float = 5.0 # seconds
57
+ stop_monitor_timeout: float = 10.0 # seconds
58
+ monitor_interval: float = 1.0 # seconds
59
+ monitor_retry_delay: float = 1.0 # seconds
60
+ # Overlay top-level pipeline_stages spans (depth <= 2 dot-names, e.g. "train.round_03")
61
+ # as labeled translucent bands on the resource plot's CPU panel, so utilization
62
+ # plateaus are attributable to pipeline stages at a glance
63
+ annotate_stages: bool = True
64
+ # Live monitoring dashboard (aetherscan/dashboard.py) auto-launched by main.py at run start;
65
+ # --no-dashboard opts out. Served headless on dashboard_port (SSH-forward to reach it).
66
+ dashboard_enabled: bool = True
67
+ dashboard_port: int = 8501
68
+ # End-of-run benchmark report (utils/benchmark_report.py) rendered at the tail of
69
+ # train/inference and posted to Slack; --no-benchmark-report opts out.
70
+ benchmark_report_enabled: bool = True
71
+
72
+
73
+ @dataclass
74
+ class LoggerConfig:
75
+ """Logger configuration"""
76
+
77
+ # Set log levels
78
+ console_level: str = "INFO"
79
+ file_level: str = "INFO"
80
+ slack_level: str = "INFO"
81
+
82
+ # Slack configuration
83
+ slack_enabled: bool = True
84
+ slack_channel: str | None = None # Override with SLACK_CHANNEL env var
85
+ slack_username: str = "Aetherscan"
86
+ slack_timeout: float = 15.0
87
+ slack_retry_attempts: int = 3
88
+ slack_buffer_size: int = 100 # Max messages to buffer before flushing
89
+ slack_flush_interval: float = 60.0 # Seconds between automatic flushes
90
+ slack_broadcast_level: str = "ERROR" # Messages at this level+ are broadcast to main channel
91
+
92
+
93
+ @dataclass
94
+ class BetaVAEConfig:
95
+ """Beta-VAE model configuration"""
96
+
97
+ latent_dim: int = 8 # Bottleneck size
98
+ dense_layer_size: int = 512 # Should match num frequency bins after downsampling
99
+ kernel_size: tuple[int, int] = (3, 3) # For Conv2D & Conv2DTranspose layers
100
+ beta: float = 1.5 # KL divergence weight
101
+ alpha: float = 10.0 # Clustering loss weight
102
+ # Opt-in keras mixed_bfloat16 policy for the Beta-VAE (train.py sets the global policy
103
+ # before model build). A/B-gated: changes training numerics — bf16 needs no loss scaling,
104
+ # variables/optimizer state stay fp32 under keras mixed precision, and the numerically
105
+ # sensitive layers (z_mean/z_log_var/Sampling, decoder sigmoid output → loss math) are
106
+ # pinned fp32 in models/vae.py. Default False = fp32 end-to-end, byte-identical to before
107
+ # this flag existed (no policy call is made at all). STAYS OFF after the 7-seed A/B:
108
+ # 6 seeds clean, but seed 13 reproducibly fails (recall .8432 / val AUC .9807, far below
109
+ # the 6-seed control envelope, and the only runs to trip the ECE->calibrator gate were
110
+ # bf16-seed-13, twice, across configs) — a bf16-specific trajectory pathology the gate
111
+ # exists to catch. Revisit only with that pathology understood (+1.15x step throughput
112
+ # and ~halved activation VRAM are the upside on the table).
113
+ mixed_precision: bool = False
114
+ # Whether the conv layers' declared L1/L2 penalties are ADDED to the training objective.
115
+ # v1 default False: the declarations were dead code since inception (a custom loop only
116
+ # applies them by consuming model.losses, which nothing did), and activating them at the
117
+ # declared, never-calibrated coefficients measurably degraded the model in a 5-seed A/B
118
+ # (recall@0.01FPR median .984 -> .954, worst seed .72; 1-4 latent dims per seed pushed
119
+ # below the active-units threshold). reg_loss is computed and recorded regardless, so
120
+ # every run observes what the penalties WOULD be; coefficient calibration is a tracked
121
+ # follow-up issue. Flipping this changes training numerics — A/B-gate like the other
122
+ # numerics flags.
123
+ regularization_active: bool = False
124
+
125
+
126
+ @dataclass
127
+ class ReproducibilityConfig:
128
+ """Random-state configuration (#279): ONE root seed for the whole pipeline"""
129
+
130
+ # Root seed for every random stream in BOTH pipelines: synthetic data generation, dataset
131
+ # split/shuffles, TF weight init, the VAE sampling layer (training AND inference), the
132
+ # Random Forest, UMAP/KMeans plot fits, and plot subsampling — each consumer derives an
133
+ # independent stream (see aetherscan.seeding). Defaults to a concrete value so runs are
134
+ # reproducible out of the box (#279 flipped this from the historical None); None restores
135
+ # OS entropy (non-reproducible, warned once). Bit-exact GPU reproducibility additionally
136
+ # needs tf_deterministic_ops and identical hardware/software (GPU count, TF version).
137
+ seed: int | None = 11
138
+ # Force deterministic TF/cuDNN op implementations (tf.config.experimental
139
+ # .enable_op_determinism) at some speed cost. Only meaningful alongside `seed`.
140
+ # Default ON (#298, maintainer decision): without it, cuDNN autotune may select
141
+ # different conv algorithms run to run, and the resulting low-bit latent drift is
142
+ # enough to flip near-threshold candidates between otherwise identical inference runs
143
+ # (measured live: 3 candidate flips between two identical unflagged runs; two flagged
144
+ # runs were bit-identical). Opt out with --no-tf-deterministic-ops.
145
+ tf_deterministic_ops: bool = True
146
+
147
+
148
+ @dataclass
149
+ class RandomForestConfig:
150
+ """Random Forest configuration"""
151
+
152
+ n_estimators: int = 1000 # Number of trees
153
+ bootstrap: bool = (
154
+ True # Whether to use bootstrap sampling when building each tree (True = bagging)
155
+ )
156
+ max_features: str = "sqrt" # Random feature selection (sqrt, log2, float)
157
+ n_jobs: int = -1 # Number of parallel jobs to run (-1 = use all available CPU cores)
158
+ # Explicit override for the RF random_state. None (the default) derives the seed from
159
+ # reproducibility.seed via STREAM_RF (#279); the deprecated --rf-seed flag sets this.
160
+ seed: int | None = None
161
+
162
+ # Latent-representation variant the RF consumes (#282). Training sweeps every variant on
163
+ # one shared dataset/split and records the empirical winner here before final_save, so
164
+ # config_{tag}.json tells inference exactly how to rebuild features (never hardcoded).
165
+ # See aetherscan.latent_variants.VARIANT_ORDER for the catalogue; "z" is the legacy baseline.
166
+ latent_variant: str = "z"
167
+ # ACTIVE latent dims measured at training (Burda et al.; None until a sweep ran) —
168
+ # persisted so inference rebuilds the z_mean_logvar_active layout identically
169
+ active_dims: list[int] | None = None
170
+ # Extra sampled-z draws per cadence for the z_aug variant's training rows
171
+ z_aug_draws: int = 4
172
+ # Variance floor for a latent dim to count as ACTIVE (Burda et al. AU with z_mean
173
+ # variance); gates the active-dims variant + the posterior-collapse guard (#282)
174
+ active_units_threshold: float = 0.01
175
+ # Variant selection (#282): primary metric is recall at this false-positive rate on the
176
+ # selection split; the winner must beat every simpler (fewer-feature) variant by more
177
+ # than a bootstrap CI of the recall difference, else the simpler variant wins the tie
178
+ selection_max_fpr: float = 0.01
179
+ selection_bootstrap_rounds: int = 500
180
+ # Probability calibration (#282): auto-fit a calibrator on the held-out calibration
181
+ # split when the winner's measured ECE exceeds max_ece; isotonic when the calibration
182
+ # split has at least calibration_min_isotonic rows, else sigmoid/Platt (isotonic
183
+ # overfits small sets); kept only if it improves ECE (and does not worsen Brier) on the
184
+ # held-out test split. calibration_active/method are RECORDED BY TRAINING for the saved
185
+ # config — inference reads them to decide whether to load rf_calibrator_{tag}.joblib.
186
+ max_ece: float = 0.05
187
+ calibration_min_isotonic: int = 1000
188
+ calibration_active: bool = False
189
+ calibration_method: str | None = None
190
+ # Fractions of the val split carved out for selection / calibration (remainder = the
191
+ # held-out test split that release metrics are reported on — best-of-N on the selection
192
+ # split alone would be optimistically biased)
193
+ val_selection_fraction: float = 0.5
194
+ val_calibration_fraction: float = 0.25
195
+ # Screening-threshold validation (#282): warn if the two-pass cascade loses more than
196
+ # this much recall vs MC-scoring everything at the science threshold on the test split
197
+ screen_recall_tolerance: float = 0.0
198
+
199
+
200
+ # NOTE: verify that our current GPU config gracefully handles cases where the node has a single GPU (vs multiple)
201
+ # TODO: add a way to specify (either number or name) the specific GPUs on a system we wish to use (currently defaults to all available). extend to cli.py too
202
+ # TODO: run performance benchmarks using different num_gpus on a single node (and in future, multi-node as well)
203
+ @dataclass
204
+ class GPUConfig:
205
+ """GPU runtime configuration"""
206
+
207
+ # If None, the strategy uses every GPU visible to TF. If set to a positive int N, the
208
+ # strategy is restricted to the first N physical GPUs (the rest are left untouched for
209
+ # other workloads on the node). Validated against batch/sample divisibility in cli.py
210
+ # before being applied; runtime mismatch (N > available GPUs) aborts in
211
+ # setup_gpu_strategy rather than silently downgrading, so we never propagate batch
212
+ # sizes that were validated against a different replica count.
213
+ num_replicas: int | None = None
214
+ per_gpu_memory_limit_mb: int | None = None
215
+ nccl_num_packs: int = 2
216
+ use_async_allocator: bool = True
217
+ # TF_GPU_THREAD_MODE: "gpu_private" gives each GPU dedicated kernel-launch threads that
218
+ # tf.data host work cannot steal — the standard NGC lever for input-pipeline h2d/scheduling
219
+ # interference (measured at ~7.6% of step throughput on blpc3, benchmarks/README.md
220
+ # "Corrected ceiling decomposition"). "global" restores TF's default shared pool;
221
+ # "gpu_shared" is the third TF-supported value. Applied in setup_gpu_strategy before the
222
+ # GPU runtime initializes; inert in the producer tree (CUDA-blanked). NOTE: this config is
223
+ # the source of truth — "global" actively CLEARS any shell-set TF_GPU_THREAD_MODE/COUNT
224
+ # (same semantics as use_async_allocator vs TF_GPU_ALLOCATOR above), so an env-only
225
+ # override will not survive; set the config field instead.
226
+ gpu_thread_mode: str = "gpu_private"
227
+ # TF_GPU_THREAD_COUNT: threads per GPU when gpu_thread_mode="gpu_private" (TF default 2)
228
+ gpu_thread_count: int = 2
229
+
230
+
231
+ # TODO: make sure the entire pipeline respects DataConfig() values, instead of hard coding
232
+ @dataclass
233
+ class DataConfig:
234
+ """Data processing configuration"""
235
+
236
+ num_observations: int = 6 # Per cadence snippet (3 ON, 3 OFF)
237
+ width_bin: int = 4096 # Frequency bins per observation
238
+ downsample_factor: int = 8 # Frequency bins downsampling factor
239
+ time_bins: int = 16 # Time bins per observation
240
+ freq_resolution: float = 2.7939677238464355 # Hz
241
+ time_resolution: float = 18.25361108 # seconds
242
+
243
+ # TODO: experiment with larger chunk sizes (how to track chunk processing efficiency)
244
+ # Note the following heuristics/constraints, which only apply to training
245
+ # max_backgrounds_per_file = max_chunks_per_file * background_load_chunk_size
246
+ # max_backgrounds_total = min(max_backgrounds_per_file * num_files, num_target_backgrounds)
247
+ num_target_backgrounds: int = 45000 # Number of background cadences to load
248
+ background_load_chunk_size: int = (
249
+ 15000 # Maximum cadences to process at once during load_train_data()
250
+ )
251
+ max_chunks_per_file: int = 1 # Maximum chunks to load from a single file
252
+
253
+ # NOTE: should this be in DataConfig, InferenceConfig, or a new dataclass entirely (e.g. PreprocessingConfig)
254
+ # NOTE: should be renamed test_background_load_chunk_size? or no because inference_files still generate .npy which uses this same chunk size?
255
+ # TODO: experiment with larger chunk sizes (how to track chunk processing efficiency)
256
+ # We only specify chunk size during inference, since we assume all backgrounds from all files must be loaded
257
+ inference_background_load_chunk_size: int = (
258
+ 50000 # Maximum cadences to process at once during load_inference_data()
259
+ )
260
+
261
+ # Data files
262
+ # Note, Python dataclasses don't allow mutable objects (e.g. lists) to be used as defaults,
263
+ # since Python will create that object once when the class is defined, rather than each time
264
+ # a new object of that class is instantiated. This means that all instances of that class
265
+ # would share the same mutable object in memory (i.e. if we modified train_files in one
266
+ # instance, it would affect all other instances -- a dangerous bug).
267
+ # The default_factory parameter takes a callable (lambda function) that's called each time a
268
+ # new instance is created, ensuring each instance gets its own independent list, preventing
269
+ # the shared-state bug. Note that once created, the list behaves identical to any other list
270
+ train_files: list[str] = field(
271
+ default_factory=lambda: [
272
+ "real_filtered_LARGE_HIP110750.npy",
273
+ "real_filtered_LARGE_HIP13402.npy",
274
+ "real_filtered_LARGE_HIP8497.npy",
275
+ ]
276
+ )
277
+ # TODO: add comment specifying test_files requirements & behavior (.npy instead of .csv containing individual .h5 files, in inference_files)
278
+ test_files: list[str] = field(default_factory=lambda: ["real_filtered_LARGE_test_HIP15638.npy"])
279
+ # Each entry is a CSV path (resolved via get_inference_file_path) whose rows
280
+ # describe individual .h5 observations to be grouped into cadences.
281
+ # If non-None, takes precedence over test_files during inference and triggers
282
+ # the energy detection preprocessing pipeline.
283
+ inference_files: list[str] | None = field(default_factory=lambda: None)
284
+
285
+
286
+ @dataclass
287
+ class TrainingConfig:
288
+ # Reproducibility now lives in ReproducibilityConfig (#279): one root seed shared by both
289
+ # pipelines instead of a training-only seed plus an independent rf.seed.
290
+
291
+ num_training_rounds: int = 20
292
+ epochs_per_round: int = 100
293
+
294
+ # Posterior-collapse guard (#282): a latent dim counts ACTIVE while its batch-mean KL
295
+ # exceeds kl_epsilon; a round alarms (advisory WARNING, never fatal) when the active
296
+ # fraction drops below min_active_units_fraction or any dim sits under epsilon for
297
+ # `patience` consecutive epochs
298
+ posterior_collapse_kl_epsilon: float = 0.01
299
+ min_active_units_fraction: float = 0.5
300
+ posterior_collapse_patience: int = 5
301
+
302
+ num_samples_beta_vae: int = 499200
303
+ num_samples_rf: int = 99840 # NOTE: come back to this later
304
+ train_val_split: float = 0.8
305
+
306
+ per_replica_batch_size: int = (
307
+ 128 # Throughput-optimal per GPU (see benchmarks/README.md GPU sweep)
308
+ )
309
+ # Gradient-accumulation target. 7680 = lcm(per_replica_batch_size * {4,5,6}), so effective_batch_size
310
+ # is divisible by per_replica_batch_size * num_replicas on 4-, 5-, or 6-GPU hosts (and divides the
311
+ # 399360-sample train split); it makes the defaults valid on every supported GPU count, including
312
+ # the 5-GPU Blackwell cluster where the previous 3072 failed. On a fixed GPU count you can override
313
+ # with a smaller multiple (e.g. --effective-batch-size 3072 on 4 or 6 GPUs) to accumulate over fewer
314
+ # micro-batches; per-GPU throughput is unchanged (set by per_replica_batch_size).
315
+ effective_batch_size: int = 7680
316
+ # Validation / viz / RF-latent-generation batch per GPU. Its global size (64 * num_replicas) must
317
+ # divide the val split (99840), num_samples_rf (99840), and latent_viz_num_cadences_per_type * 4;
318
+ # 64 is paired with latent_viz_num_cadences_per_type=960 (below) so all three hold on 4-, 5-, or
319
+ # 6-GPU hosts. Runs off the training hot path, but 64 (vs the bare divisibility floor of 16 at
320
+ # latent_viz=240) keeps validation / latent-generation out of the small-batch launch-overhead
321
+ # regime (see the encode sweep in benchmarks/README.md) — a net win over a full run's ~2000
322
+ # validations. Raising V requires raising latent_viz in lockstep (V=128 needs latent_viz=1920).
323
+ per_replica_val_batch_size: int = 64
324
+
325
+ # Signal injection params
326
+ # TODO: experiment with larger chunk sizes (how to track chunk processing efficiency)
327
+ signal_injection_chunk_size: int = (
328
+ 50000 # Maximum cadences to process at once during data generation
329
+ )
330
+ # Tuned to 64 via smoke-scale (n=8192) task-size sweeps on bla0 (96c) + blpc3 (32c):
331
+ # finer tasks load-balance the create_true_double straggler with negligible per-task
332
+ # overhead; 64 was near-optimal on both (256 ran ~2x slower on bla0).
333
+ # TODO: re-confirm at production sample sizes (~500k) before treating as final.
334
+ data_gen_task_size: int = 64 # Cadences per batched worker task (workers write results straight into the round memmap)
335
+ # On-disk dtype of the three round cadence arrays (main/true/false). "float16" halves the
336
+ # round footprint (~294.5 -> ~147 GB at full scale), the gather volume, and the page-cache
337
+ # working set — the lever that keeps overlapped epochs at page-cache speed once two rounds
338
+ # no longer fit in RAM — at a quantization cost of <= 2^-12 on the [0, 1] log-normalized
339
+ # inputs. DEFAULT FLIPPED to "float16" 2026-07-29 after passing the val-metric A/B gate:
340
+ # 4 seeds, every score metric inside the 6-seed control spread (val AUC >= .9979, recalls
341
+ # .9619-.9907 vs control .9449-.9907), AU within the controls' own 6-8 seed variation, no
342
+ # calibration trips. The gather map upcasts to float32 host-side, so the training graph
343
+ # and loss math are unchanged either way; labels + lognorm sidecars stay float32.
344
+ # Recorded in each round's .done manifest and validated on resume/reuse ("float32"
345
+ # restores the historical behavior byte-for-byte).
346
+ round_array_dtype: str = "float16"
347
+
348
+ # Round data pipeline params (disk-backed per-round datasets, see round_data.py)
349
+ round_data_dir: str | None = None # Defaults to get_training_file_path("round_data") at runtime
350
+ overlap_data_generation: bool = (
351
+ True # Generate round k+1's data in a background process while round k trains
352
+ )
353
+ keep_round_data: bool = (
354
+ False # Retain each round's on-disk data after its training completes (debugging)
355
+ )
356
+ # TODO: tune to include sufficient info without bottlenecking training
357
+ plot_injection_subsampling_count: int = (
358
+ 100000 # Max points per series in injection_stats scatter plots
359
+ )
360
+ # TODO: tune to include sufficient info without bottlenecking training
361
+ plot_injection_outlier_percentile: float = (
362
+ 99.0 # Always include injection_stats points beyond this percentile
363
+ )
364
+
365
+ # Latent space visualization params
366
+ # Cadences per signal type for the latent-space viz batch (total = 4×). 960 rather than a smaller
367
+ # value so latent_viz*4 (3840) is divisible by per_replica_val_batch_size * num_replicas on 4-, 5-,
368
+ # or 6-GPU hosts (this is the binding constraint that lets per_replica_val_batch_size be 64 instead
369
+ # of 16); it also yields denser latent-space plots. Must stay <= the val split (99840).
370
+ latent_viz_num_cadences_per_type: int = 960
371
+ latent_viz_step_interval: int = 10 # Capture snapshot every N training steps
372
+ latent_viz_umap_fit_max_samples: int = (
373
+ 100_000 # Max pooled vectors for UMAP fit (rest are projected via .transform())
374
+ )
375
+ # Note, Python dataclasses don't allow mutable objects (e.g. lists) to be used as defaults,
376
+ # since Python will create that object once when the class is defined, rather than each time
377
+ # a new object of that class is instantiated. This means that all instances of that class
378
+ # would share the same mutable object in memory (i.e. if we modified latent_viz_umap_n_neighbors
379
+ # in one instance, it would affect all other instances -- a dangerous bug).
380
+ # The default_factory parameter takes a callable (lambda function) that's called each time a
381
+ # new instance is created, ensuring each instance gets its own independent list, preventing
382
+ # the shared-state bug. Note that once created, the list behaves identical to any other list
383
+ latent_viz_umap_n_neighbors: list[int] = field(
384
+ # UMAP n_neighbors values to sweep
385
+ # n_neighbors constrains the size of the local neighborhood UMAP will look at when attempting
386
+ # to learn the manifold structure of the data. Lower values lead to better local structure,
387
+ # whereas larger values yield better global structure
388
+ # - 5: fine-grained local structure
389
+ # - 15: UMAP default, good baseline
390
+ # - 50: global topology emphasis
391
+ # (30 was dropped from the default sweep 2026-07: it sat between 15 and 50 without
392
+ # adding a distinct view, and each nn value costs 3 min_dist x 2 level combos)
393
+ default_factory=lambda: [5, 15, 50]
394
+ )
395
+ latent_viz_umap_min_dist: list[float] = field(
396
+ # UMAP min_dist values to sweep
397
+ # min_dist controls how tightly points in the lower-dimensional representation can be packed.
398
+ # Lower values result in clumpier embeddings (useful for observing clusters), whereas larger
399
+ # values preserve more of the broad topological structure
400
+ # - 0.0: maximum cluster tightness
401
+ # - 0.1: UMAP default, slight breathing room
402
+ # - 0.5: spread out, reveals continuous gradients
403
+ default_factory=lambda: [0.0, 0.1, 0.5]
404
+ )
405
+ latent_viz_gif_max_frames: int = 500 # Max frames in output GIF (log-spaced subsampling)
406
+ latent_viz_gif_duration_ms: int = 100 # Milliseconds per frame in output GIF
407
+
408
+ # Latent traversal params (decoder-based interpretation of the latent dims; see
409
+ # TrainingPipeline.plot_latent_traversal)
410
+ latent_traversal_every_round: bool = (
411
+ False # Also render traversal figures at the end of every training round
412
+ )
413
+ latent_traversal_num_steps: int = (
414
+ 7 # Steps per latent dim (odd & >= 3 so the center column is the unperturbed decode)
415
+ )
416
+ latent_traversal_max_sigma: float = (
417
+ 3.0 # Traversal range in per-dim standard deviations: steps span ±max_sigma (> 0)
418
+ )
419
+
420
+ # RF visualization params
421
+ shap_max_samples_summary: int = 5000 # Samples for SHAP summary/dependence computation
422
+ shap_max_samples_interaction: int = (
423
+ 1500 # Samples for SHAP interaction values (O(F^2) per sample)
424
+ )
425
+ shap_top_k_features_dependence: int = (
426
+ 48 # Number of dependence plot panels (all 6 obs * 8 dims by default)
427
+ )
428
+ rf_decision_boundary_grid_size: int = (
429
+ 150 # Grid resolution for decision boundary contour (grid_size x grid_size)
430
+ )
431
+ rf_decision_boundary_max_points: int = (
432
+ 5000 # Subsample val points for decision boundary plot legibility
433
+ )
434
+
435
+ # Model-quality gate params (issue #139 Gate 1)
436
+ min_val_auc: float = 0.0 # Opt-in floor on the RF's validation ROC-AUC; 0.0 disables the check. When set and unmet after the RF fit, training logs a loud WARNING (reaches the Slack summary) rather than failing the run
437
+
438
+ # Curriculum learning params
439
+ snr_base: int = 10
440
+ initial_snr_range: int = 40
441
+ final_snr_range: int = 10
442
+ curriculum_schedule: str = "exponential" # "linear", "exponential", "step"
443
+ exponential_decay_rate: float = -3.0 # How quickly schedule should progress from easy to hard (must be <0) (more negative = less easy rounds & more hard rounds)
444
+ # TODO: generalize this to receive a step schedule (as a list/dict?) validate that len(list/dict) is divisible by num_training_rounds
445
+ step_easy_rounds: int = 5 # Number of rounds with easy signals
446
+ step_hard_rounds: int = 15 # Number of rounds with challenging signals
447
+
448
+ # Adaptive LR params
449
+ base_learning_rate: float = 0.001
450
+ min_learning_rate: float = 1e-6
451
+ min_pct_improvement: float = 0.001 # 0.1% val loss improvement
452
+ patience_threshold: int = 3 # consecutive epochs with no improvement
453
+ reduction_factor: float = 0.2 # 20% LR reduction
454
+
455
+ # NOTE: should we try an exponential backoff?
456
+ # Fault tolerance params
457
+ max_retries: int = 3
458
+ retry_delay: int = 60 # seconds
459
+
460
+
461
+ @dataclass
462
+ class InferenceConfig:
463
+ """Inference configuration"""
464
+
465
+ # TODO: have a startup function that sets encoder_path, rf_path, and config_path using load_dir & load_tag if either/both are None
466
+ encoder_path: str = None
467
+ rf_path: str = None
468
+ config_path: str = None
469
+ # Per-replica encode batch in SNIPPETS (each snippet = num_observations independent
470
+ # encoder inputs, so 256 snippets = 1,536 observation forwards — near the measured
471
+ # ~1024-obs single-GPU encode throughput peak; see benchmarks/README.md). The old 2048
472
+ # default was a units conflation (#298 I4): 2048 SNIPPETS = a 12,288-obs per-replica
473
+ # forward, past the throughput peak and above the bench's documented >~8192-obs int32
474
+ # launch-config abort. Also the cap of _distributed_encode's bucketed batch geometry.
475
+ per_replica_batch_size: int = 256
476
+ classification_threshold: float = 0.99
477
+ # Two-pass cascade (#282). Pass 1 scores EVERY snippet deterministically (z_mean-based
478
+ # features) against this permissive screening threshold, tuned for recall — its only job
479
+ # is "definitely not a candidate". Pass 2 re-scores the survivors with mc_draws seeded
480
+ # latent samples and carries the actual science threshold (classification_threshold
481
+ # above, applied to the MC mean). The two are a cascade, not two ANDed criteria.
482
+ screening_threshold: float = 0.5
483
+ mc_draws: int = 32
484
+ # Reference cloud (#282): pass 2 also MC-scores a seeded uniform reservoir subsample of
485
+ # the pass-1 REJECTS, persisted per run, so the candidate uncertainty plot compares each
486
+ # candidate against the survey population rather than against other candidates. 0
487
+ # disables the cloud (and the plot's background).
488
+ reference_cloud_size: int = 10000
489
+ # Streaming-loop prefetch depth (#298 N2): cadences preprocessed+loaded concurrently
490
+ # ahead of the GPU stage, overlapping disk-bound energy detection with
491
+ # decompression-bound extraction and the per-cadence serial sections, at the cost of
492
+ # one in-flight cadence of RAM per unit of depth (stamps + loaded array — up to
493
+ # ~65 GB each for RFI-dense C-band cadences, so depth 3 budgets ~4 in-flight worst
494
+ # case ≈ 260 GB on a 503 GB host). Default 3 per the #301 on-cluster A/B (the same 8
495
+ # fresh /datag cadences as the #298 depth-1→2 A/B: depth 3 = 4,071 s vs depth 2 =
496
+ # 5,118 s wall, ~-20%, identical candidates with 0.0 score deltas; caveat: the
497
+ # depth-3 leg ran last and warmest, so treat the honest win as ~10-20% — same
498
+ # confound structure the 1→2 flip carried). Per-cadence outputs are identical at any
499
+ # depth (results are consumed in catalog order and seeding keys on the catalog
500
+ # index); depth 1 restores the historical serial behavior.
501
+ prefetch_depth: int = 3
502
+
503
+ # NOTE: come back to this later (is this the optimal grouping?)
504
+ # Energy detection preprocessing
505
+ cadence_group_by_cols: list[str] = field(
506
+ default_factory=lambda: ["Target", "Session", "Band", "Cadence ID", "Frequency"]
507
+ )
508
+ cadence_h5_path_col: str = ".h5 path"
509
+ cadence_expected_obs: int = 6 # expected observations per cadence (ABACAD)
510
+
511
+ # NOTE: come back to this later (are these params correct?)
512
+ coarse_channel_width: int = 1048576
513
+ # Progress-logging cadence for energy detection. None (default) logs ~25% milestones
514
+ # per ON file (#301 — the per-channel lines were 62% of a run's log volume, all
515
+ # Slack-bound); an explicit N restores the historical every-N-channels lines.
516
+ # Parallelism itself comes from the persistent worker pool (one fused task per coarse
517
+ # channel), not from this knob.
518
+ coarse_channel_log_interval: int | None = None
519
+ # Bandpass flattening method for energy detection: "pfb" divides each coarse channel by
520
+ # the instrument's static polyphase-filterbank response (computed once per run); "spline"
521
+ # fits and subtracts a spline per coarse channel per file (the historical, data-driven
522
+ # method).
523
+ bandpass_method: str = "pfb"
524
+ # PFB prototype-filter taps per coarse channel. Instrument-dependent: 12 is the GBT /
525
+ # Breakthrough Listen backend default, and it must match the backend that produced the
526
+ # .h5 files being processed.
527
+ pfb_taps_per_channel: int = 12
528
+ # Opt-in debug artifact: save a raw-vs-flattened overlay plot (a few sampled coarse
529
+ # channels per cadence) under {output_path}/plots/inference/.
530
+ bandpass_debug_plot: bool = False
531
+ spline_order: int = 16
532
+ detection_window_size: int = 256
533
+ detection_step_size: int = 128
534
+ stat_threshold: float = 2048.0
535
+ stamp_width: int = 4096
536
+ # Downsample stamps along frequency at extraction time (by data.downsample_factor), so
537
+ # the per-cadence .npy stores stamp_width // downsample_factor bins (~8x smaller at
538
+ # defaults). Disable to archive raw-resolution stamps; loading handles both layouts.
539
+ store_downsampled_stamps: bool = True
540
+
541
+ # NOTE: come back to this later (is overlap search implemented correctly? redo analysis from peter forwards search paper)
542
+ overlap_search: bool = True
543
+ overlap_fraction: float = 0.5
544
+
545
+ # NOTE: come back to this later (placeholder for future drop logic — wired but inert)
546
+ discard_side_channels: bool = False
547
+ side_channel_count: int = 0
548
+
549
+ # Per-cadence .npy output directory for energy-detection preprocessing. None (default)
550
+ # resolves per CSV to {data_path}/inference/preprocessed/<csv_stem>_ed<hash12>/, keyed
551
+ # on the ED-config fingerprint (#298 I3): runs sharing an ED config share stamps (a
552
+ # threshold sweep or re-inference with new weights skips preprocessing entirely), while
553
+ # any ED-config change lands in a different directory by construction. Set explicitly
554
+ # to pin one directory across runs and CSVs (the resume guard still verifies each
555
+ # sidecar's h5_paths and recorded fingerprint before reuse).
556
+ preprocess_output_dir: str | None = None
557
+ # Stamp-cache pruning (#302): delete a cadence's stamp .npy right after its 'inferred'
558
+ # manifest row lands (metadata .json always kept; resume rides the DB row; each
559
+ # candidate's snippet is snapshotted into a ~196 KB .candidates.npz sidecar so the
560
+ # candidate figures survive, and a bounded top-K pixel pool — persisted across the
561
+ # in-process retry attempts, #305 — keeps the stamp gallery whole within one run).
562
+ # Without pruning a full catalog writes ~30-90 TB of stamps vs ~2.7 TB free on /datax —
563
+ # the run dies on disk after a few hundred cadences. None (default) = AUTO: ON for the
564
+ # fingerprint-scoped default cache dir, OFF when preprocess_output_dir is explicitly set
565
+ # (an operator-curated cache is never destroyed implicitly). Only stamps THIS run
566
+ # extracted are pruned (a resumed run never deletes a handed cache; a failed-then-retried
567
+ # cadence of the same run IS pruned). Same-run resume/retry is science-unaffected.
568
+ # Known limitations (viz-only, graceful): a cross-PROCESS relaunch cannot recover an
569
+ # earlier process's pruned pixels, so its stamp gallery may show blank columns for
570
+ # earlier-run cadences; and concurrent runs sharing the default cache dir with pruning
571
+ # ON can race (one deletes/overwrites a .npy or .candidates.npz another is mid-read of —
572
+ # self-heals via retry). Use a per-run --preprocess-output-dir or --no-prune-stamps to
573
+ # run concurrently in one dir. Trade: pruning forfeits the cross-run stamp-reuse rerun
574
+ # win for pruned cadences (re-extraction ~5-15 min each).
575
+ prune_stamps: bool | None = None
576
+
577
+ # Visualization suite (aetherscan.inference_viz): rendered at the end of a streaming
578
+ # CSV inference run, saved under {output_path}/plots/inference/{save_tag}/ and uploaded
579
+ # to Slack. Every figure is individually exception-guarded — a plot bug can never kill
580
+ # a science run.
581
+ inference_viz_enabled: bool = True
582
+ # Which cadences the metadata-driven figures cover: "full" (default) renders the whole
583
+ # accumulated tag every successful pass; "new" renders only cadences inferred THIS
584
+ # pass (#301 — a resumed catalog campaign re-paid the full O(catalog) viz tail on
585
+ # every pass). DB-sourced candidate figures always cover the full tag either way.
586
+ inference_viz_scope: str = "full"
587
+ # Number of top-statistic stamps shown in the stamp gallery (6-obs waterfall grids).
588
+ stamp_gallery_top_k: int = 12
589
+ # Cap on per-candidate figures (candidate_{i}_{tag}.png), highest confidence first.
590
+ max_candidate_plots: int = 50
591
+
592
+ # NOTE: come back to this later (is this implemented correctly?)
593
+ # Fault tolerance
594
+ max_retries: int = 3
595
+ retry_delay: int = 60 # seconds
596
+
597
+
598
+ @dataclass
599
+ class HFConfig:
600
+ """HuggingFace Hub integration configuration"""
601
+
602
+ # Target model repo for weight upload (train) and download (inference).
603
+ repo_id: str = "zachtheyek/aetherscan"
604
+ # Opt-in: publish the final artifacts + a generated model card after training completes
605
+ # (requires HF_TOKEN in the environment; default off = local-only).
606
+ upload_after_training: bool = False
607
+ # Inference pin: HF revision (tag/branch/commit) to download artifacts from. None
608
+ # resolves to the latest release tag (highest semver vX.Y.Z); a release tag is required.
609
+ revision: str | None = None
610
+
611
+
612
+ @dataclass
613
+ class CheckpointConfig:
614
+ """Checkpoint configuration"""
615
+
616
+ load_dir: str | None = None
617
+ load_tag: str | None = None
618
+ start_round: int = 1
619
+ # Resolved once at runtime by cli.resolve_save_tag (in main(), before init_logger) to
620
+ # {command}_{YYYYMMDD_HHMMSS}. None until then — the pipeline always runs through the CLI,
621
+ # which sets it before any stage reads it.
622
+ save_tag: str | None = None
623
+ # Override the fail-early save-tag dedup guards (local artifact/DB collisions and the
624
+ # HF-side tag check) for an explicitly-provided save_tag.
625
+ force_tag: bool = False
626
+
627
+ def infer_start_round(self):
628
+ """Infer start_round from load_tag"""
629
+ if self.load_tag and self.load_tag.startswith("round_"):
630
+ self.start_round = (
631
+ int(self.load_tag.split("_", 1)[1]) + 1
632
+ ) # Start from the round proceeding model checkpoint (round_XX + 1)
633
+
634
+
635
+ class Config:
636
+ """Main configuration class"""
637
+
638
+ _instance = None # Stores singleton instance
639
+ _lock = threading.Lock() # Ensures thread safety on object initialization
640
+
641
+ # __new__ allocates the object in memory (constructor at the object-creation level)
642
+ # __init__ initializes the object's attributes after it's created
643
+ # since __new__ is called before __init__ every time we instantiate a class,
644
+ # by overriding __new__, we can short-circuit object creation entirely, and control whether a
645
+ # new instance is created, or just return the existing instance
646
+ def __new__(cls):
647
+ # Double-checked locking pattern:
648
+ # First check if _instance is None, without lock (for performance)
649
+ if cls._instance is None:
650
+ # If None, acquire the lock to serialize the initialization path,
651
+ # preventing race conditions (2 threads violating singleton semantics)
652
+ with cls._lock:
653
+ # Check if _instance is None again inside the lock
654
+ # (since multiple threads can be calling simultaneously)
655
+ if cls._instance is None:
656
+ # If still None, only then we construct the singleton instance
657
+ cls._instance = super().__new__(cls)
658
+ cls._instance._initialized = False # Mark as not initialized (for __init__)
659
+ # Return the same instance for all subsequent constructor calls
660
+ return cls._instance
661
+
662
+ def __init__(self):
663
+ """Initialize configuration"""
664
+ # Note, __init__ is triggered every time the class's constructor is called,
665
+ # even if __new__ returned the existing singleton instance
666
+ # Hence, we use the _initialized flag to make sure __init__ only runs once
667
+ if self._initialized:
668
+ return
669
+
670
+ self._initialized = True
671
+
672
+ self.db = DBConfig()
673
+ self.manager = ManagerConfig()
674
+ self.monitor = MonitorConfig()
675
+ self.logger = LoggerConfig()
676
+ self.reproducibility = ReproducibilityConfig()
677
+ self.beta_vae = BetaVAEConfig()
678
+ self.rf = RandomForestConfig()
679
+ self.gpu = GPUConfig()
680
+ self.data = DataConfig()
681
+ self.training = TrainingConfig()
682
+ self.inference = InferenceConfig()
683
+ self.hf = HFConfig()
684
+ self.checkpoint = CheckpointConfig()
685
+
686
+ # Paths
687
+ self.data_path = os.environ.get(
688
+ "AETHERSCAN_DATA_PATH", "/datax/scratch/zachy/data/aetherscan"
689
+ )
690
+ self.model_path = os.environ.get(
691
+ "AETHERSCAN_MODEL_PATH", "/datax/scratch/zachy/models/aetherscan"
692
+ )
693
+ self.output_path = os.environ.get(
694
+ "AETHERSCAN_OUTPUT_PATH", "/datax/scratch/zachy/outputs/aetherscan"
695
+ )
696
+
697
+ # Coupled-defaults guard: inference.stamp_width and data.width_bin are independent
698
+ # dataclass fields that must agree — energy-detection stamps are cut at stamp_width
699
+ # while every loader/model surface is sized off width_bin. A divergent source-level
700
+ # default edit would otherwise surface only at load time (load_inference_data fails
701
+ # safe, but late and per-file); fail here, at config init. CLI overrides are
702
+ # validated separately in cli.collect_validation_errors.
703
+ if self.inference.stamp_width != self.data.width_bin:
704
+ raise ValueError(
705
+ f"Config defaults diverged: inference.stamp_width "
706
+ f"({self.inference.stamp_width}) must equal data.width_bin "
707
+ f"({self.data.width_bin})"
708
+ )
709
+
710
+ def resolved_rf_seed(self) -> int:
711
+ """
712
+ The Random Forest random_state actually used this run (#279): rf.seed when explicitly
713
+ overridden (deprecated --rf-seed), else derived from the root seed via STREAM_RF.
714
+ Always a concrete int — with an unseeded root the derived value is OS entropy, so the
715
+ RF still gets a set random_state (the #279 constraint: never drop one that exists).
716
+ """
717
+ # Deferred import: config must stay importable before/without the rest of the package
718
+ from aetherscan.seeding import STREAM_RF, derive_seed # noqa: PLC0415
719
+
720
+ if self.rf.seed is not None:
721
+ return self.rf.seed
722
+ return derive_seed(self.reproducibility.seed, STREAM_RF)
723
+
724
+ @classmethod
725
+ def _reset(cls):
726
+ """
727
+ Teardown hook for thread-safe singleton
728
+ Resets the config instance to None
729
+
730
+ WARNING: Only use for testing or restarting the application
731
+ Calling this while the config is active will cause issues.
732
+ Do NOT call this method unless you know what you're doing
733
+ """
734
+ # Acquire lock to prevent race conditions
735
+ with cls._lock:
736
+ # Discard the singleton instance by removing the global reference
737
+ # Guarantees the next constructor call will produce a fresh instance
738
+ # Note, resources held by the old instance will remain alive unless explicitly closed beforehand
739
+ cls._instance = None
740
+
741
+ def get_training_file_path(self, filename: str, base_path: str | None = None) -> str:
742
+ """Get full path for training data files. base_path overrides self.data_path (used by
743
+ validate_args, which runs before a --data-path override is applied to the singleton)."""
744
+ return os.path.join(
745
+ base_path if base_path is not None else self.data_path, "training", filename
746
+ )
747
+
748
+ def get_test_file_path(self, filename: str, base_path: str | None = None) -> str:
749
+ """Get full path for testing data files. base_path overrides self.data_path (used by
750
+ validate_args, which runs before a --data-path override is applied to the singleton)."""
751
+ return os.path.join(
752
+ base_path if base_path is not None else self.data_path, "testing", filename
753
+ )
754
+
755
+ def get_inference_file_path(self, filename: str, base_path: str | None = None) -> str:
756
+ """Get full path for inference CSV files. base_path overrides self.data_path (used by
757
+ validate_args, which runs before a --data-path override is applied to the singleton)."""
758
+ return os.path.join(
759
+ base_path if base_path is not None else self.data_path, "inference", filename
760
+ )
761
+
762
+ def get_file_subset(self, filename: str) -> tuple[int | None, int | None]:
763
+ """Get subset parameters for a file (start, end indices)"""
764
+ # Option to define subsets for specific files to manage memory usage
765
+ subset_map = {
766
+ "real_filtered_LARGE_HIP110750.npy": (None, None), # Shape: (14567, 6, 16, 4096)
767
+ "real_filtered_LARGE_HIP13402.npy": (None, None), # Shape: (14567, 6, 16, 4096)
768
+ "real_filtered_LARGE_HIP8497.npy": (None, None), # Shape: (14567, 6, 16, 4096)
769
+ "real_filtered_LARGE_testHIP83043.npy": (None, None), # Shape: (14567, 6, 16, 4096)
770
+ }
771
+ return subset_map.get(filename, (None, None))
772
+
773
+ def to_dict(self) -> dict:
774
+ """Convert config to dictionary for serialization"""
775
+ return {
776
+ "paths": {
777
+ "data_path": self.data_path,
778
+ "model_path": self.model_path,
779
+ "output_path": self.output_path,
780
+ },
781
+ "db": {
782
+ "get_connection_timeout": self.db.get_connection_timeout,
783
+ "stop_writer_timeout": self.db.stop_writer_timeout,
784
+ "write_interval": self.db.write_interval,
785
+ "write_buffer_max_size": self.db.write_buffer_max_size,
786
+ "write_retry_delay": self.db.write_retry_delay,
787
+ "flush_timeout": self.db.flush_timeout,
788
+ "bulk_chunk_rows": self.db.bulk_chunk_rows,
789
+ "bulk_queue_max_items": self.db.bulk_queue_max_items,
790
+ "stop_drain_timeout": self.db.stop_drain_timeout,
791
+ },
792
+ "manager": {
793
+ "n_processes": self.manager.n_processes,
794
+ "chunks_per_worker": self.manager.chunks_per_worker,
795
+ "pool_terminate_timeout": self.manager.pool_terminate_timeout,
796
+ },
797
+ "monitor": {
798
+ "get_gpu_timeout": self.monitor.get_gpu_timeout,
799
+ "stop_monitor_timeout": self.monitor.stop_monitor_timeout,
800
+ "monitor_interval": self.monitor.monitor_interval,
801
+ "monitor_retry_delay": self.monitor.monitor_retry_delay,
802
+ "annotate_stages": self.monitor.annotate_stages,
803
+ "dashboard_enabled": self.monitor.dashboard_enabled,
804
+ "dashboard_port": self.monitor.dashboard_port,
805
+ "benchmark_report_enabled": self.monitor.benchmark_report_enabled,
806
+ },
807
+ "logger": {
808
+ "console_level": self.logger.console_level,
809
+ "file_level": self.logger.file_level,
810
+ "slack_level": self.logger.slack_level,
811
+ "slack_enabled": self.logger.slack_enabled,
812
+ "slack_channel": self.logger.slack_channel,
813
+ "slack_username": self.logger.slack_username,
814
+ "slack_timeout": self.logger.slack_timeout,
815
+ "slack_retry_attempts": self.logger.slack_retry_attempts,
816
+ "slack_buffer_size": self.logger.slack_buffer_size,
817
+ "slack_flush_interval": self.logger.slack_flush_interval,
818
+ "slack_broadcast_level": self.logger.slack_broadcast_level,
819
+ },
820
+ "beta_vae": {
821
+ "latent_dim": self.beta_vae.latent_dim,
822
+ "dense_layer_size": self.beta_vae.dense_layer_size,
823
+ "kernel_size": self.beta_vae.kernel_size,
824
+ "beta": self.beta_vae.beta,
825
+ "alpha": self.beta_vae.alpha,
826
+ "mixed_precision": self.beta_vae.mixed_precision,
827
+ "regularization_active": self.beta_vae.regularization_active,
828
+ },
829
+ "reproducibility": {
830
+ "seed": self.reproducibility.seed,
831
+ "tf_deterministic_ops": self.reproducibility.tf_deterministic_ops,
832
+ # Provenance only (#279): the concrete values derived from the root this run.
833
+ # Not a settable field — apply_saved_config skips unknown sub-keys, so a
834
+ # restored config re-derives from the root instead of pinning these.
835
+ "derived_rf_seed": self.resolved_rf_seed(),
836
+ },
837
+ "rf": {
838
+ "n_estimators": self.rf.n_estimators,
839
+ "bootstrap": self.rf.bootstrap,
840
+ "max_features": self.rf.max_features,
841
+ "n_jobs": self.rf.n_jobs,
842
+ # The override field (None = derived from reproducibility.seed; see
843
+ # reproducibility.derived_rf_seed for the value actually used)
844
+ "seed": self.rf.seed,
845
+ "latent_variant": self.rf.latent_variant,
846
+ "active_dims": self.rf.active_dims,
847
+ "z_aug_draws": self.rf.z_aug_draws,
848
+ "active_units_threshold": self.rf.active_units_threshold,
849
+ "selection_max_fpr": self.rf.selection_max_fpr,
850
+ "selection_bootstrap_rounds": self.rf.selection_bootstrap_rounds,
851
+ "max_ece": self.rf.max_ece,
852
+ "calibration_min_isotonic": self.rf.calibration_min_isotonic,
853
+ "calibration_active": self.rf.calibration_active,
854
+ "calibration_method": self.rf.calibration_method,
855
+ "val_selection_fraction": self.rf.val_selection_fraction,
856
+ "val_calibration_fraction": self.rf.val_calibration_fraction,
857
+ "screen_recall_tolerance": self.rf.screen_recall_tolerance,
858
+ },
859
+ "gpu": {
860
+ "num_replicas": self.gpu.num_replicas,
861
+ "per_gpu_memory_limit_mb": self.gpu.per_gpu_memory_limit_mb,
862
+ "nccl_num_packs": self.gpu.nccl_num_packs,
863
+ "use_async_allocator": self.gpu.use_async_allocator,
864
+ "gpu_thread_mode": self.gpu.gpu_thread_mode,
865
+ "gpu_thread_count": self.gpu.gpu_thread_count,
866
+ },
867
+ "data": {
868
+ "num_observations": self.data.num_observations,
869
+ "width_bin": self.data.width_bin,
870
+ "downsample_factor": self.data.downsample_factor,
871
+ "time_bins": self.data.time_bins,
872
+ "freq_resolution": self.data.freq_resolution,
873
+ "time_resolution": self.data.time_resolution,
874
+ "num_target_backgrounds": self.data.num_target_backgrounds,
875
+ "background_load_chunk_size": self.data.background_load_chunk_size,
876
+ "max_chunks_per_file": self.data.max_chunks_per_file,
877
+ "inference_background_load_chunk_size": self.data.inference_background_load_chunk_size,
878
+ "train_files": self.data.train_files,
879
+ "test_files": self.data.test_files,
880
+ "inference_files": self.data.inference_files,
881
+ },
882
+ "training": {
883
+ "num_training_rounds": self.training.num_training_rounds,
884
+ "epochs_per_round": self.training.epochs_per_round,
885
+ "posterior_collapse_kl_epsilon": self.training.posterior_collapse_kl_epsilon,
886
+ "min_active_units_fraction": self.training.min_active_units_fraction,
887
+ "posterior_collapse_patience": self.training.posterior_collapse_patience,
888
+ "num_samples_beta_vae": self.training.num_samples_beta_vae,
889
+ "num_samples_rf": self.training.num_samples_rf,
890
+ "train_val_split": self.training.train_val_split,
891
+ "per_replica_batch_size": self.training.per_replica_batch_size,
892
+ "effective_batch_size": self.training.effective_batch_size,
893
+ "per_replica_val_batch_size": self.training.per_replica_val_batch_size,
894
+ "signal_injection_chunk_size": self.training.signal_injection_chunk_size,
895
+ "data_gen_task_size": self.training.data_gen_task_size,
896
+ "round_array_dtype": self.training.round_array_dtype,
897
+ "round_data_dir": self.training.round_data_dir,
898
+ "overlap_data_generation": self.training.overlap_data_generation,
899
+ "keep_round_data": self.training.keep_round_data,
900
+ "plot_injection_subsampling_count": self.training.plot_injection_subsampling_count,
901
+ "plot_injection_outlier_percentile": self.training.plot_injection_outlier_percentile,
902
+ "latent_viz_num_cadences_per_type": self.training.latent_viz_num_cadences_per_type,
903
+ "latent_viz_step_interval": self.training.latent_viz_step_interval,
904
+ "latent_viz_umap_fit_max_samples": self.training.latent_viz_umap_fit_max_samples,
905
+ "latent_viz_umap_n_neighbors": self.training.latent_viz_umap_n_neighbors,
906
+ "latent_viz_umap_min_dist": self.training.latent_viz_umap_min_dist,
907
+ "latent_viz_gif_max_frames": self.training.latent_viz_gif_max_frames,
908
+ "latent_viz_gif_duration_ms": self.training.latent_viz_gif_duration_ms,
909
+ "latent_traversal_every_round": self.training.latent_traversal_every_round,
910
+ "latent_traversal_num_steps": self.training.latent_traversal_num_steps,
911
+ "latent_traversal_max_sigma": self.training.latent_traversal_max_sigma,
912
+ "shap_max_samples_summary": self.training.shap_max_samples_summary,
913
+ "shap_max_samples_interaction": self.training.shap_max_samples_interaction,
914
+ "shap_top_k_features_dependence": self.training.shap_top_k_features_dependence,
915
+ "rf_decision_boundary_grid_size": self.training.rf_decision_boundary_grid_size,
916
+ "rf_decision_boundary_max_points": self.training.rf_decision_boundary_max_points,
917
+ "min_val_auc": self.training.min_val_auc,
918
+ "snr_base": self.training.snr_base,
919
+ "initial_snr_range": self.training.initial_snr_range,
920
+ "final_snr_range": self.training.final_snr_range,
921
+ "curriculum_schedule": self.training.curriculum_schedule,
922
+ "exponential_decay_rate": self.training.exponential_decay_rate,
923
+ "step_easy_rounds": self.training.step_easy_rounds,
924
+ "step_hard_rounds": self.training.step_hard_rounds,
925
+ "base_learning_rate": self.training.base_learning_rate,
926
+ "min_learning_rate": self.training.min_learning_rate,
927
+ "min_pct_improvement": self.training.min_pct_improvement,
928
+ "patience_threshold": self.training.patience_threshold,
929
+ "reduction_factor": self.training.reduction_factor,
930
+ "max_retries": self.training.max_retries,
931
+ "retry_delay": self.training.retry_delay,
932
+ },
933
+ "inference": {
934
+ "encoder_path": self.inference.encoder_path,
935
+ "rf_path": self.inference.rf_path,
936
+ "config_path": self.inference.config_path,
937
+ "per_replica_batch_size": self.inference.per_replica_batch_size,
938
+ "classification_threshold": self.inference.classification_threshold,
939
+ "screening_threshold": self.inference.screening_threshold,
940
+ "mc_draws": self.inference.mc_draws,
941
+ "reference_cloud_size": self.inference.reference_cloud_size,
942
+ "prefetch_depth": self.inference.prefetch_depth,
943
+ "cadence_group_by_cols": self.inference.cadence_group_by_cols,
944
+ "cadence_h5_path_col": self.inference.cadence_h5_path_col,
945
+ "cadence_expected_obs": self.inference.cadence_expected_obs,
946
+ "coarse_channel_width": self.inference.coarse_channel_width,
947
+ "coarse_channel_log_interval": self.inference.coarse_channel_log_interval,
948
+ "bandpass_method": self.inference.bandpass_method,
949
+ "pfb_taps_per_channel": self.inference.pfb_taps_per_channel,
950
+ "bandpass_debug_plot": self.inference.bandpass_debug_plot,
951
+ "spline_order": self.inference.spline_order,
952
+ "detection_window_size": self.inference.detection_window_size,
953
+ "detection_step_size": self.inference.detection_step_size,
954
+ "stat_threshold": self.inference.stat_threshold,
955
+ "stamp_width": self.inference.stamp_width,
956
+ "store_downsampled_stamps": self.inference.store_downsampled_stamps,
957
+ "overlap_search": self.inference.overlap_search,
958
+ "overlap_fraction": self.inference.overlap_fraction,
959
+ "discard_side_channels": self.inference.discard_side_channels,
960
+ "side_channel_count": self.inference.side_channel_count,
961
+ "preprocess_output_dir": self.inference.preprocess_output_dir,
962
+ # NOTE: any key added here also enters BOTH inference fingerprints unless
963
+ # it joins the run_state.py denylists — prune_stamps and
964
+ # inference_viz_scope are excluded there (#301/#302: retention/viz only)
965
+ "prune_stamps": self.inference.prune_stamps,
966
+ "inference_viz_enabled": self.inference.inference_viz_enabled,
967
+ "inference_viz_scope": self.inference.inference_viz_scope,
968
+ "stamp_gallery_top_k": self.inference.stamp_gallery_top_k,
969
+ "max_candidate_plots": self.inference.max_candidate_plots,
970
+ "max_retries": self.inference.max_retries,
971
+ "retry_delay": self.inference.retry_delay,
972
+ },
973
+ "hf": {
974
+ "repo_id": self.hf.repo_id,
975
+ "upload_after_training": self.hf.upload_after_training,
976
+ "revision": self.hf.revision,
977
+ },
978
+ "checkpoint": {
979
+ "load_dir": self.checkpoint.load_dir,
980
+ "load_tag": self.checkpoint.load_tag,
981
+ "start_round": self.checkpoint.start_round,
982
+ "save_tag": self.checkpoint.save_tag,
983
+ "force_tag": self.checkpoint.force_tag,
984
+ },
985
+ }
986
+
987
+
988
+ def init_config() -> Config:
989
+ """
990
+ Initialize global config instance (call once at startup)
991
+ """
992
+ config = Config()
993
+ return config
994
+
995
+
996
+ # NOTE: suppress None return type for now to reduce pyright errors
997
+ # def get_config() -> Config | None:
998
+ def get_config() -> Config:
999
+ """
1000
+ Get the global config instance
1001
+ """
1002
+ return Config._instance