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/main.py ADDED
@@ -0,0 +1,1269 @@
1
+ # NOTE: remove time filters and strictly query db using tag. would need to ensure save-tag is unique at the start of each run
2
+ # NOTE: is there a way to parallelize the CPU-GPU processing (e.g. while GPU is working on training/inference, CPU starts working on data_generation/preprocessing)
3
+ """
4
+ Entry point for Aetherscan Pipeline
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import gc
11
+ import importlib.util
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ from collections import deque
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import tensorflow as tf
23
+ from dotenv import find_dotenv, load_dotenv
24
+
25
+ from aetherscan.benchmark import stage_timer
26
+ from aetherscan.candidate_figures import write_candidate_snippet_sidecar
27
+ from aetherscan.cli import (
28
+ apply_args_to_config,
29
+ apply_saved_config,
30
+ resolve_save_tag,
31
+ setup_argument_parser,
32
+ validate_args,
33
+ )
34
+ from aetherscan.config import get_config, init_config
35
+ from aetherscan.dashboard_launcher import launch_dashboard
36
+ from aetherscan.db import get_db, init_db
37
+ from aetherscan.hf_hub import resolve_inference_artifacts
38
+ from aetherscan.inference import InferencePipeline, run_inference_pipeline, summarize_confidences
39
+ from aetherscan.inference_viz import InferenceVizCollector, render_inference_visualizations
40
+ from aetherscan.logger import get_logger, init_logger
41
+ from aetherscan.manager import get_manager, init_manager, register_logger
42
+ from aetherscan.monitor import init_monitor
43
+ from aetherscan.preprocessing import (
44
+ CadenceResult,
45
+ DataPreprocessor,
46
+ PendingCadence,
47
+ derive_cadence_provenance,
48
+ )
49
+ from aetherscan.run_state import inference_config_fingerprint
50
+ from aetherscan.tag_guards import enforce_tag_guards
51
+ from aetherscan.train import run_training_pipeline
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ # NOTE: verify that our current GPU config gracefully handles cases where the node has a single GPU (vs multiple)
57
+ # TODO: run performance benchmarks using different num_gpus on a single node (and in future, multi-node as well)
58
+ # 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
59
+ # TEST: make sure this works, especially with _build_optimizer() in train.py (do they conflict? is one unnecessary vs the other?)
60
+ def _warmup_collective(strategy):
61
+ """Trigger a tiny cross-device reduction to surface NCCL failures at setup time.
62
+
63
+ MirroredStrategy construction with NcclAllReduce never fails on its own — NCCL
64
+ errors only surface on the first actual collective. Doing a 1-element reduce
65
+ here lets us catch (and fall back from) NCCL failures before training starts,
66
+ rather than mid-epoch.
67
+ """
68
+
69
+ @tf.function
70
+ def _per_replica():
71
+ return tf.constant(1.0)
72
+
73
+ per_replica_value = strategy.run(_per_replica)
74
+ _ = strategy.reduce(tf.distribute.ReduceOp.SUM, per_replica_value, axis=None)
75
+
76
+
77
+ def setup_gpu_strategy():
78
+ """Configure GPU memory growth, memory limits, multi-GPU strategy with load balancing & async allocator"""
79
+
80
+ config = get_config()
81
+ if config is None:
82
+ raise ValueError("get_config() returned None")
83
+
84
+ # Both env vars here are read lazily by TF when the GPU runtime first initializes (that is,
85
+ # on first GPU memory allocation), which happens below on set_memory_growth()
86
+ if config.gpu.use_async_allocator:
87
+ # Prevent memory fragmentation within each GPU.
88
+ os.environ["TF_GPU_ALLOCATOR"] = "cuda_malloc_async"
89
+ else:
90
+ # Explicitly clear so a previous run's env doesn't leak in.
91
+ os.environ.pop("TF_GPU_ALLOCATOR", None)
92
+
93
+ # Enable aggressive cleanup of intermediate tensors
94
+ os.environ["TF_ENABLE_GPU_GARBAGE_COLLECTION"] = "true"
95
+
96
+ # Dedicated per-GPU kernel-launch threads (see GPUConfig.gpu_thread_mode). Read lazily by
97
+ # TF at GPU-runtime init like the allocator var above.
98
+ valid_thread_modes = {"global", "gpu_private", "gpu_shared"}
99
+ if config.gpu.gpu_thread_mode not in valid_thread_modes:
100
+ raise ValueError(
101
+ f"gpu.gpu_thread_mode must be one of {sorted(valid_thread_modes)}, "
102
+ f"got {config.gpu.gpu_thread_mode!r}"
103
+ )
104
+ if config.gpu.gpu_thread_mode == "global":
105
+ # Explicitly clear so a previous run's env doesn't leak in.
106
+ os.environ.pop("TF_GPU_THREAD_MODE", None)
107
+ os.environ.pop("TF_GPU_THREAD_COUNT", None)
108
+ else:
109
+ os.environ["TF_GPU_THREAD_MODE"] = config.gpu.gpu_thread_mode
110
+ os.environ["TF_GPU_THREAD_COUNT"] = str(config.gpu.gpu_thread_count)
111
+
112
+ gpus = tf.config.list_physical_devices("GPU")
113
+ if not gpus:
114
+ logger.warning("No GPUs detected")
115
+ return None
116
+
117
+ # Apply config.gpu.num_replicas. None means "use every visible GPU" (default);
118
+ # a positive int wired through set_visible_devices restricts TF to the first N
119
+ # GPUs and leaves the rest untouched for other workloads
120
+ #
121
+ # Note that config.gpu.num_replicas is guaranteed to be None or in [1, total_gpus].
122
+ # Any other values are caught at validate_args time before reaching this point. The
123
+ # remaining edge-case where 0-GPUs are reported by TF is handled by train_command()
124
+ # and inference_command() individually (here we simply return None)
125
+ total_gpus = len(gpus)
126
+ requested = config.gpu.num_replicas
127
+ if requested is not None and requested < total_gpus:
128
+ gpus = gpus[:requested]
129
+ # set_visible_devices must run before any GPU memory-growth or logical-device
130
+ # call, since those initialize the GPU runtime and freeze the visible set.
131
+ tf.config.set_visible_devices(gpus, "GPU")
132
+ logger.info(
133
+ f"Restricting TF to {requested} of {total_gpus} GPUs "
134
+ f"(per config.gpu.num_replicas={requested}); GPUs "
135
+ f"{list(range(requested, total_gpus))} are left untouched."
136
+ )
137
+
138
+ try:
139
+ for gpu in gpus:
140
+ # set_memory_growth stayed under tf.config.experimental in TF 2.17.
141
+ tf.config.experimental.set_memory_growth(gpu, True)
142
+ # When per_gpu_memory_limit_mb is None we skip this call entirely and
143
+ # rely on memory-growth only (recommended default on ~96 GB Blackwell cards).
144
+ if config.gpu.per_gpu_memory_limit_mb is not None:
145
+ # set_logical_device_configuration is the stable replacement for the
146
+ # deprecated experimental.set_virtual_device_configuration.
147
+ tf.config.set_logical_device_configuration(
148
+ gpu,
149
+ [
150
+ tf.config.LogicalDeviceConfiguration(
151
+ memory_limit=config.gpu.per_gpu_memory_limit_mb
152
+ )
153
+ ],
154
+ )
155
+
156
+ num_packs = config.gpu.nccl_num_packs
157
+
158
+ # Set distributed strategy to prevent uneven VRAM usage
159
+ # Try NCCL first; fall back to HierarchicalCopyAllReduce only if the
160
+ # warmup all-reduce actually fails. NCCL 2.25.1 is the first NCCL with
161
+ # official sm_120 (Blackwell) support, so this path is especially load-bearing
162
+ # on the Blackwell cluster.
163
+ try:
164
+ strategy = tf.distribute.MirroredStrategy(
165
+ cross_device_ops=tf.distribute.NcclAllReduce(num_packs=num_packs)
166
+ )
167
+ _warmup_collective(strategy)
168
+ logger.info("Using NcclAllReduce for optimal NVIDIA GPU performance")
169
+ except Exception as e:
170
+ logger.warning(
171
+ f"NCCL warmup all-reduce failed ({e}), falling back to HierarchicalCopyAllReduce"
172
+ )
173
+ strategy = tf.distribute.MirroredStrategy(
174
+ cross_device_ops=tf.distribute.HierarchicalCopyAllReduce(num_packs=num_packs)
175
+ )
176
+ _warmup_collective(strategy)
177
+
178
+ logger.info(f"Distributed strategy: {strategy.num_replicas_in_sync} GPUs")
179
+ return strategy
180
+
181
+ except Exception as e:
182
+ # This exception catch is broad on purpose:
183
+ # catches RuntimeError from device-config calls plus
184
+ # non-RuntimeError failures (ValueError, tf.errors.*) from the fallback
185
+ # warmup, so any GPU setup failure resolves to the graceful return-None
186
+ # path the callers expect rather than escaping this function.
187
+ logger.error(f"GPU configuration error: {e}")
188
+ return None
189
+
190
+
191
+ def _report_final_training_status(pipeline) -> None:
192
+ """
193
+ Emit the terminal training status and exit nonzero on any permanently-failed stage.
194
+
195
+ Extracted from train_command so the exit contract is unit-testable: a fully-successful run
196
+ exits 0, a run with any recorded non-critical stage failure (vae_plots/rf_plots/hf_upload that
197
+ never recovered across attempts) exits 1, and a missing pipeline exits 1 rather than reporting
198
+ a false success. A run that skipped RF training because a pre-loaded RF was already trained
199
+ exits 0 but with a warning annotation instead of the unqualified success line (issue #142).
200
+ """
201
+ if pipeline is None:
202
+ # Defensive: the retry loop always sets pipeline or sys.exit(1)s first, and
203
+ # --max-retries >= 1 is validated, so this is unreachable in practice — but never
204
+ # report success without a completed pipeline.
205
+ logger.error("Training produced no pipeline — treating as failure")
206
+ sys.exit(1)
207
+
208
+ # Plot stages are non-critical (a broken plot mustn't cost a retry cycle including data
209
+ # regeneration), but their failures are recorded in the run manifest — surface them
210
+ # loudly and exit nonzero so lost artifacts can't go unnoticed.
211
+ failed_stages = pipeline.run_state.stages_failed
212
+ if failed_stages:
213
+ logger.error("=" * 60)
214
+ logger.error(
215
+ f"Training finished, but non-critical stage(s) permanently failed: "
216
+ f"{', '.join(failed_stages)}"
217
+ )
218
+ logger.error(
219
+ "Re-run the identical command to retry them — completed stages are skipped "
220
+ "via the run manifest"
221
+ )
222
+ logger.error("=" * 60)
223
+ sys.exit(1)
224
+
225
+ # Qualified success (issue #142): a run whose RF stage was skipped because an
226
+ # already-trained Random Forest was pre-loaded (e.g. resumed from the wrong tag) must not
227
+ # report unqualified success — the saved RF was never trained on this run's encoder.
228
+ # Ordering is intentional: the failed_stages branch above exits(1) first, so a run that
229
+ # both had a permanent stage failure AND skipped RF reports the failure (the stronger
230
+ # signal), never this qualified-success annotation.
231
+ skipped_rf_tag = getattr(pipeline, "rf_training_skipped_from_tag", None)
232
+ if skipped_rf_tag:
233
+ logger.warning("=" * 60)
234
+ logger.warning(
235
+ f"Training completed, but Random Forest training was SKIPPED — the saved RF was "
236
+ f"loaded from tag '{skipped_rf_tag}', not trained during this run"
237
+ )
238
+ logger.warning("=" * 60)
239
+ return
240
+
241
+ logger.info("=" * 60)
242
+ logger.info("Training completed successfully!")
243
+ logger.info("=" * 60)
244
+
245
+
246
+ def _post_benchmark_report(tag: str) -> None:
247
+ """
248
+ Render the end-of-run benchmark report (utils/benchmark_report.py) and post it to Slack.
249
+
250
+ Runs at the tail of train_command/inference_command rather than after monitor shutdown:
251
+ the monitor never writes pipeline_stages rows (stage spans land via the async DB write
252
+ queue), and by the time main()'s finally block stops the monitor the Slack handler is
253
+ being torn down too. An explicit db.flush() is what guarantees every span row is on disk
254
+ before the report reads them. Fully guarded: any failure (including the report tool's
255
+ SystemExit) logs an error and never fails the run.
256
+ """
257
+ try:
258
+ config = get_config()
259
+ if config is None:
260
+ raise ValueError("get_config() returned None")
261
+ if not config.monitor.benchmark_report_enabled:
262
+ logger.info("Benchmark report disabled (--no-benchmark-report)")
263
+ return
264
+
265
+ db = get_db()
266
+ if db is None:
267
+ raise ValueError("get_db() returned None")
268
+ # Stage spans reach the DB through the async write queue — drain it so the report
269
+ # sees every row of this run
270
+ db.flush(timeout=config.db.flush_timeout)
271
+
272
+ # utils/benchmark_report.py is deliberately import-free of aetherscan (stdlib
273
+ # sqlite3 + numpy + matplotlib) so it stays cluster-portable — load it by file path
274
+ # instead of importing, same pattern as tests/unit/test_benchmark.py. The
275
+ # sys.modules registration must precede exec_module: the module's @dataclass
276
+ # resolves its own module by name at class-creation time (PEP 563 string
277
+ # annotations).
278
+ report_path = Path(__file__).resolve().parents[2] / "utils" / "benchmark_report.py"
279
+ if not report_path.exists():
280
+ # e.g. a pip-installed package without the repo checkout alongside
281
+ logger.warning(f"Benchmark report skipped: {report_path} does not exist")
282
+ return
283
+ spec = importlib.util.spec_from_file_location("benchmark_report", report_path)
284
+ benchmark_report = importlib.util.module_from_spec(spec)
285
+ sys.modules["benchmark_report"] = benchmark_report
286
+ spec.loader.exec_module(benchmark_report)
287
+
288
+ rows = benchmark_report.load_rows(db.db_path, tag)
289
+ if not rows:
290
+ logger.warning(f"Benchmark report skipped: no pipeline_stages rows for tag {tag!r}")
291
+ return
292
+ root = benchmark_report.build_stage_tree(rows)
293
+ png_path = os.path.join(config.output_path, "plots", f"benchmark_report_{tag}.png")
294
+ benchmark_report.render_report_png(root, rows, tag, png_path)
295
+ logger.info(f"Benchmark report saved to {png_path}")
296
+
297
+ # Bottleneck suggestions ride along as the upload's comment, landing in the run
298
+ # thread right next to the figure
299
+ suggestions = benchmark_report.build_suggestions(root, db.db_path, tag)
300
+ message = "\n".join(f"- {s}" for s in suggestions) if suggestions else None
301
+
302
+ logger_instance = get_logger()
303
+ if logger_instance is None:
304
+ raise ValueError("get_logger() returned None")
305
+ if not logger_instance.upload_image_to_slack(
306
+ png_path, title=f"Benchmark Report - {tag}", message=message
307
+ ):
308
+ logger.warning("Benchmark report rendered but Slack upload was skipped or failed")
309
+
310
+ except (Exception, SystemExit) as e:
311
+ # SystemExit included: load_rows raises it on a pre-benchmarking-schema DB.
312
+ # Observability must never fail an otherwise-finished run. Runs at the very tail
313
+ # of the command (after all real work is done), so this also swallowing a
314
+ # KeyboardInterrupt-adjacent exit here has minimal blast radius — a Ctrl-C during
315
+ # report generation still leaves the run's actual results intact.
316
+ # TODO: if utils/benchmark_report.py's load_rows ever stops raising SystemExit for
317
+ # a pre-benchmarking-schema DB, narrow this back to `except Exception`.
318
+ logger.error(f"Benchmark report generation failed: {e}")
319
+
320
+
321
+ def train_command():
322
+ """Execute training pipeline with distributed strategy & fault tolerance"""
323
+ logger.info("=" * 60)
324
+ logger.info("Starting Aetherscan Training Pipeline")
325
+ logger.info("=" * 60)
326
+
327
+ config = get_config()
328
+ if config is None:
329
+ raise ValueError("get_config() returned None")
330
+
331
+ # NOTE: come back to this later (print more descriptive info)
332
+ logger.info("Configuration:")
333
+ logger.info(f" Data path: {config.data_path}")
334
+ logger.info(f" Model path: {config.model_path}")
335
+ logger.info(f" Output path: {config.output_path}")
336
+ logger.info(f" Number of rounds: {config.training.num_training_rounds}")
337
+ logger.info(f" Epochs per round: {config.training.epochs_per_round}")
338
+
339
+ # Setup GPU strategy
340
+ try:
341
+ strategy = setup_gpu_strategy()
342
+ except Exception as e:
343
+ logger.error(f"Failed to setup GPU strategy: {e}")
344
+ sys.exit(1)
345
+
346
+ # NOTE: come back to this later (should we provide a CPU-only mode?)
347
+ if strategy is None:
348
+ logger.error("No GPU strategy available. Training requires GPU.")
349
+ sys.exit(1)
350
+
351
+ # Initialize preprocessor & load training backgrounds
352
+ # Note, we load this in train_command() to avoid reloading backgrounds on training pipeline retries
353
+ # This gives us faster startup times at the expense of holding onto more memory during training
354
+ # Should be fine since backgrounds only take up low ~10^1 Gb in RAM (benchmarked: Dec '25)
355
+ # However, if we decide to trade off reduced memory pressure for slower startup times in future,
356
+ # then we should consider moving this into TrainingPipeline proper
357
+ try:
358
+ preprocessor = DataPreprocessor()
359
+ with stage_timer("train.load_backgrounds"):
360
+ background_data = preprocessor.load_train_data().astype(np.float32)
361
+ # NOTE: do we need to close preprocessing pools and/or shared memory?
362
+ except Exception as e:
363
+ logger.error(f"Failed to load train backgrounds: {e}")
364
+ sys.exit(1)
365
+
366
+ # Train models with fault tolerance
367
+ logger.info("Starting training pipeline...")
368
+
369
+ max_retries = config.training.max_retries
370
+ retry_delay = config.training.retry_delay
371
+ pipeline = None
372
+
373
+ for attempt in range(max_retries):
374
+ try:
375
+ logger.info(f"Training attempt: {attempt + 1}/{max_retries}")
376
+
377
+ # Reinitialize the training pipeline on each attempt so no corrupted state is
378
+ # persisted. The persisted run manifest (run_state_{save_tag}.json) tells the new
379
+ # pipeline which rounds/stages already completed, so the attempt resumes exactly
380
+ # where the previous one died. The save_tag is fixed for the life of the process, so
381
+ # these in-process retries resume automatically; a full process relaunch mints a fresh
382
+ # datetime tag and only resumes when re-invoked with --load-tag {full-tag}.
383
+ pipeline = run_training_pipeline(background_data=background_data, strategy=strategy)
384
+
385
+ break # If we get here, training succeeded
386
+
387
+ except KeyboardInterrupt:
388
+ # Don't retry on user interruption
389
+ # Re-raise to propagate traceback
390
+ logger.info("Training interrupted by user")
391
+ raise
392
+
393
+ except Exception as e:
394
+ logger.error(f"Training attempt {attempt + 1} failed with error: {e}")
395
+
396
+ if attempt < max_retries - 1:
397
+ logger.info(
398
+ f"Attempting to recover from failure: attempt {attempt + 2}/{max_retries}"
399
+ )
400
+ logger.info(f"Waiting {retry_delay} seconds before retry...")
401
+
402
+ # Collect garbage
403
+ gc.collect()
404
+ time.sleep(retry_delay)
405
+
406
+ else:
407
+ # Max retries exceeded
408
+ logger.error(f"Training attempts exceeded maximum retries ({max_retries})")
409
+ logger.error(f"Final error: {e}")
410
+ sys.exit(1)
411
+
412
+ # Post the end-of-run benchmark report before the terminal status report: the latter
413
+ # sys.exit(1)s when any non-critical stage permanently failed, and the timing data is
414
+ # exactly as valuable on those runs
415
+ _post_benchmark_report(config.checkpoint.save_tag)
416
+
417
+ # Note, the training configuration JSON is saved by the pipeline's final_save stage
418
+ # (so it's covered by the retry machinery), not here
419
+ _report_final_training_status(pipeline)
420
+
421
+
422
+ class NonRetryableInferenceError(RuntimeError):
423
+ """A permanent inference failure (bad catalog/config) that retrying cannot fix.
424
+
425
+ inference_command's retry loop re-raises this immediately instead of burning retry
426
+ attempts on it; transient failures (I/O hiccups, GPU errors) stay plain exceptions and
427
+ keep the existing retry semantics.
428
+ """
429
+
430
+
431
+ def _prefetch_cadence(preprocessor: DataPreprocessor, unit: PendingCadence) -> tuple:
432
+ """
433
+ Prefetch-thread task for one cadence: preprocess (energy detection -> stamp .npy), then
434
+ load + log-norm the stamps (#298 I5-overlap — the load_lognorm span used to run on the
435
+ GPU main thread between cadences; here it hides under the prefetch pipeline).
436
+
437
+ Returns (cadence_result, cadence_data). cadence_result is None when the cadence produced
438
+ no stamps. cadence_data is None when the load failed — the load is retried on the
439
+ inference thread inside _infer_cadence, whose per-cadence failure containment covers it
440
+ (a prefetch-side load exception must not abort the whole pass one iteration later).
441
+ """
442
+ cadence_result = preprocessor.process_pending_cadence(unit)
443
+ if cadence_result is None:
444
+ return None, None
445
+ try:
446
+ # copy=False: the loader already returns float32; don't duplicate GBs of stamps.
447
+ # parallel=False: this loads exactly one already-downsampled cadence .npy whose
448
+ # per-cadence work is one vectorized log-norm pass, while the persistent
449
+ # energy-detection pool is busy at full n_processes width — forking a second
450
+ # chunk pool here would double-subscribe the CPU.
451
+ with stage_timer("load_lognorm"):
452
+ cadence_data = preprocessor.load_inference_data(
453
+ override_filepaths=[cadence_result.npy_path], parallel=False
454
+ ).astype(np.float32, copy=False)
455
+ except Exception as e:
456
+ logger.error(
457
+ f"Cadence {cadence_result.key}: prefetch-side load failed ({e}); "
458
+ f"the inference thread will retry the load"
459
+ )
460
+ cadence_data = None
461
+ return cadence_result, cadence_data
462
+
463
+
464
+ def _resolve_prune_stamps(config) -> bool:
465
+ """Resolve the stamp-cache pruning mode (#302): an explicit inference.prune_stamps
466
+ wins; the None default means AUTO — ON for the fingerprint-scoped default cache
467
+ directory, OFF when --preprocess-output-dir pins an operator-curated one (a handed
468
+ cache is never destroyed implicitly)."""
469
+ if config.inference.prune_stamps is not None:
470
+ return bool(config.inference.prune_stamps)
471
+ return config.inference.preprocess_output_dir is None
472
+
473
+
474
+ def _prune_cadence_stamps(
475
+ cadence_result: CadenceResult, results: dict, collector: InferenceVizCollector | None
476
+ ) -> None:
477
+ """Delete one scored cadence's stamp .npy (#302), keeping everything the rest of the
478
+ run needs: the metadata .json (provenance + viz + resume guard), a ~196 KB
479
+ .candidates.npz snippet sidecar per candidate (the candidate figures' read path), and
480
+ the collector's bounded top-K pixel pool (the stamp gallery's). Runs strictly AFTER
481
+ the cadence's 'inferred' manifest row and viz collection — resume rides the DB row
482
+ and never touches the .npy. Best-effort: a pruning failure keeps the stamps and the
483
+ run continues (disk pressure is a slow failure; a science pass must not die for it)."""
484
+ npy_path = cadence_result.npy_path
485
+ try:
486
+ candidate_idx = np.nonzero(np.asarray(results["predictions"]) == 1)[0]
487
+ if len(candidate_idx):
488
+ write_candidate_snippet_sidecar(npy_path, candidate_idx)
489
+ if collector is not None:
490
+ collector.pool_gallery_pixels(cadence_result.metadata_path, npy_path)
491
+ size_gb = 0.0
492
+ with contextlib.suppress(OSError):
493
+ size_gb = os.path.getsize(npy_path) / 1e9
494
+ os.remove(npy_path)
495
+ logger.info(
496
+ f"Pruned stamp cache for cadence {cadence_result.key}: removed {npy_path} "
497
+ f"({size_gb:.2f} GB; metadata kept, {len(candidate_idx)} candidate snippet(s) "
498
+ f"sidecarred)"
499
+ )
500
+ except Exception as e:
501
+ logger.error(
502
+ f"Stamp pruning failed for cadence {cadence_result.key} ({e}); stamps kept; "
503
+ f"run continues"
504
+ )
505
+
506
+
507
+ def _infer_cadence(
508
+ pipeline: InferencePipeline,
509
+ preprocessor: DataPreprocessor,
510
+ unit: PendingCadence,
511
+ cadence_result: CadenceResult,
512
+ config_fingerprint: str,
513
+ cadence_data: np.ndarray | None = None,
514
+ ) -> dict:
515
+ """
516
+ Run the inference stage for one preprocessed cadence: derive provenance, load its stamps
517
+ (memmap -> log-norm), encode on the GPUs, classify with the RF, and record the result in
518
+ both DB tables. Returns run_inference's results dict augmented with the provenance dict
519
+ and the stage's duration_s. Exceptions propagate to the caller's per-cadence containment.
520
+
521
+ Supersede-on-retry ordering (single-writer FIFO makes each step atomic w.r.t. the next):
522
+ 1. mark_superseded(inference_results, tag, npy_path) — partial positives written by a
523
+ dead attempt can't mix with this attempt's rows;
524
+ 2. run_inference writes the fresh positives;
525
+ 3. mark_superseded(inference_cadences, tag, npy_path) — retires the 'preprocessed' row
526
+ and any 'failed'/stale 'inferred' rows;
527
+ 4. write_inference_cadence(status='inferred') — the row the stage-aware resume keys on,
528
+ carrying the aggregate stats (n_stamps/n_candidates/confidence summary) so run-level
529
+ artifacts don't depend on the positives-only inference_results table.
530
+ """
531
+ config = get_config()
532
+ if config is None:
533
+ raise ValueError("get_config() returned None")
534
+ db = get_db()
535
+ if db is None:
536
+ raise ValueError("get_db() returned None")
537
+ tag = config.checkpoint.save_tag
538
+
539
+ # Per-cadence provenance from the group key + metadata JSON
540
+ try:
541
+ with open(cadence_result.metadata_path) as f:
542
+ metadata = json.load(f)
543
+ except (OSError, json.JSONDecodeError) as e:
544
+ logger.warning(
545
+ f"Cadence {cadence_result.key}: could not read metadata at "
546
+ f"{cadence_result.metadata_path} ({e}); provenance will be sparse"
547
+ )
548
+ metadata = {"h5_paths": cadence_result.h5_paths}
549
+ provenance = derive_cadence_provenance(
550
+ key=cadence_result.key,
551
+ group_by_cols=config.inference.cadence_group_by_cols,
552
+ metadata=metadata,
553
+ )
554
+
555
+ stage_start = time.time()
556
+
557
+ # Umbrella stage span for this cadence's inference phase — the encode / rf / db_write
558
+ # sub-stages inside nest under it via thread-local naming (load_lognorm normally ran
559
+ # on the prefetch thread already — see _prefetch_cadence)
560
+ with stage_timer(f"inference.infer_cadence_{unit.index:03d}"):
561
+ if cadence_data is None:
562
+ # Fallback: the prefetch task's load failed (or a caller passed none) — load
563
+ # here so the per-cadence containment around this function covers a bad .npy
564
+ with stage_timer("load_lognorm"):
565
+ cadence_data = preprocessor.load_inference_data(
566
+ override_filepaths=[cadence_result.npy_path], parallel=False
567
+ ).astype(np.float32, copy=False)
568
+
569
+ # Step 1: retire any partial rows from a dead attempt before fresh ones land
570
+ db.mark_superseded("inference_results", tag, npy_path=cadence_result.npy_path)
571
+
572
+ results = pipeline.run_inference(
573
+ data=cadence_data,
574
+ npy_path=cadence_result.npy_path,
575
+ # Stable per-catalog cadence index -> reproducible per-cadence TF stream (#279)
576
+ seed_key=unit.index,
577
+ **provenance,
578
+ )
579
+ # cadence_data is a plain ndarray (no cycles): the del refcount-frees it, and
580
+ # run_inference's finally block already ran this cadence's one gc pass (#298 I7)
581
+ del cadence_data
582
+
583
+ duration_s = time.time() - stage_start
584
+
585
+ # Steps 3 + 4: new-row-plus-supersede on the run manifest
586
+ confidence_summary = summarize_confidences(
587
+ results["proba_true"], config.inference.classification_threshold
588
+ )
589
+ db.mark_superseded("inference_cadences", tag, npy_path=cadence_result.npy_path)
590
+ db.write_inference_cadence(
591
+ npy_path=cadence_result.npy_path,
592
+ status="inferred",
593
+ tag=tag,
594
+ csv_path=unit.group.csv_path,
595
+ cadence_key=cadence_result.key,
596
+ n_stamps=results["n_cadence_snippets"],
597
+ n_candidates=results["n_candidates"],
598
+ confidence_summary=confidence_summary,
599
+ duration_s=duration_s,
600
+ config_fingerprint=config_fingerprint,
601
+ )
602
+
603
+ results["provenance"] = provenance
604
+ results["duration_s"] = duration_s
605
+ return results
606
+
607
+
608
+ def _run_streaming_csv_inference(
609
+ preprocessor: DataPreprocessor,
610
+ strategy: tf.distribute.Strategy,
611
+ gallery_pool: list | None = None,
612
+ ) -> dict:
613
+ """
614
+ Per-cadence streaming inference over the configured CSV catalogs, with stage-aware
615
+ resume off the inference_cadences run manifest.
616
+
617
+ Flow (peak memory = up to inference.prefetch_depth in-flight cadences of stamps +
618
+ loaded arrays plus the one being inferred, independent of catalog size):
619
+
620
+ units = plan_cadences() # cadence groups + .npy paths, no work yet
621
+ skip units with a live 'inferred' manifest row for this tag (fold their stored
622
+ aggregates into the totals); for the rest:
623
+ start the ED pool + first prefetch, then InferencePipeline(strategy) — models load
624
+ once, hidden under the first cadence's energy detection
625
+ for each pending cadence (prefetch depth = inference.prefetch_depth, #298 N2):
626
+ [prefetch thread(s)] preprocess + load/log-norm upcoming cadences (energy
627
+ detection skipped when the stamp .npy already exists —
628
+ preprocessing-artifact resume; see _prefetch_cadence)
629
+ [main thread] encode cadence i on GPUs -> RF -> write per-cadence
630
+ results + manifest row (_infer_cadence), in catalog order
631
+
632
+ Failure containment mirrors preprocessing's: a cadence whose inference stage fails is
633
+ logged, recorded as status='failed' in the manifest, and the loop continues; the pass
634
+ raises at the end so inference_command's retry loop re-attempts — and the manifest skip
635
+ means only the failed cadences re-run. Raises NonRetryableInferenceError when the
636
+ catalog yields no work units or no cadence produces a stamp .npy — permanent conditions
637
+ the retry loop must not retry. On a fully successful pass the visualization suite is
638
+ rendered (config.inference.inference_viz_enabled; every figure is exception-guarded).
639
+
640
+ Returns aggregate {n_cadence_snippets, n_processed, n_candidates, n_cadences,
641
+ n_skipped}, where skipped (resumed) cadences contribute their manifest aggregates.
642
+ """
643
+ config = get_config()
644
+ if config is None:
645
+ raise ValueError("get_config() returned None")
646
+ db = get_db()
647
+ if db is None:
648
+ raise ValueError("get_db() returned None")
649
+ tag = config.checkpoint.save_tag
650
+
651
+ units = preprocessor.plan_cadences()
652
+ if not units:
653
+ raise NonRetryableInferenceError(
654
+ "No cadence work units produced from the configured inference CSVs"
655
+ )
656
+
657
+ totals = {
658
+ "n_cadence_snippets": 0,
659
+ "n_processed": 0,
660
+ "n_candidates": 0,
661
+ "n_cadences": 0,
662
+ "n_skipped": 0,
663
+ }
664
+ # gallery_pool (a run-scoped list from inference_command) persists the stamp-gallery
665
+ # pixel pool across the in-process retry attempts (#305): a fresh collector per attempt
666
+ # would otherwise blank the gallery for cadences an earlier attempt pruned.
667
+ collector = (
668
+ InferenceVizCollector(gallery_pool=gallery_pool)
669
+ if config.inference.inference_viz_enabled
670
+ else None
671
+ )
672
+
673
+ # Stage-aware resume: a live 'inferred' manifest row for (tag, npy_path) means the cadence
674
+ # completed on an earlier attempt — skip it and reuse its aggregates, but ONLY when it was
675
+ # written under the same inference config. The fingerprint guard stops a reused --save-tag
676
+ # with a changed threshold/model/geometry from silently serving stale results (the inference
677
+ # counterpart of the training-side config_fingerprint guard).
678
+ # Flush first so rows queued by this process (an in-process retry) are visible.
679
+ current_fingerprint = inference_config_fingerprint(config.to_dict())
680
+ db.flush()
681
+ inferred_rows = {
682
+ row["npy_path"]: row for row in db.query_inference_cadences(tag=tag, status="inferred")
683
+ }
684
+
685
+ pending = []
686
+ stale_config = 0
687
+ for unit in units:
688
+ manifest_row = inferred_rows.get(unit.npy_path)
689
+ if manifest_row is None:
690
+ pending.append(unit)
691
+ continue
692
+ if manifest_row.get("config_fingerprint") != current_fingerprint:
693
+ # Live 'inferred' row, but written under a different inference config -> don't reuse.
694
+ # Re-infer; _infer_cadence's supersede step retires the stale row.
695
+ stale_config += 1
696
+ pending.append(unit)
697
+ continue
698
+ n_snippets = int(manifest_row.get("n_stamps") or 0)
699
+ n_candidates = int(manifest_row.get("n_candidates") or 0)
700
+ totals["n_cadence_snippets"] += n_snippets
701
+ totals["n_processed"] += n_snippets
702
+ totals["n_candidates"] += n_candidates
703
+ totals["n_cadences"] += 1
704
+ totals["n_skipped"] += 1
705
+ if collector is not None:
706
+ # Viz collection must never fail the pass: a collector bug degrades the plots,
707
+ # not the science (the render phase has the same contract via _viz_safe)
708
+ try:
709
+ collector.record_skipped(
710
+ unit.group.key,
711
+ unit.npy_path,
712
+ DataPreprocessor.cadence_metadata_path(unit.npy_path),
713
+ manifest_row,
714
+ )
715
+ except Exception as e:
716
+ logger.error(
717
+ f"Viz collection failed for skipped cadence {unit.group.key} ({e}); "
718
+ f"plots will be degraded; run continues"
719
+ )
720
+ logger.info(
721
+ f"Cadence {unit.group.key}: already inferred under tag {tag} "
722
+ f"({n_snippets} snippets, {n_candidates} candidate(s)); skipping"
723
+ )
724
+
725
+ if stale_config:
726
+ logger.warning(
727
+ f"{stale_config} cadence(s) under tag {tag} have an 'inferred' manifest row written "
728
+ f"with a DIFFERENT inference config; re-inferring rather than reusing stale results "
729
+ f"(a reused --save-tag with a changed threshold/model/geometry is not resumed). Use a "
730
+ f"fresh --save-tag to keep separate configs' results separate."
731
+ )
732
+
733
+ logger.info(
734
+ f"Streaming inference over {len(pending)} cadence(s) "
735
+ f"({totals['n_skipped']} already inferred, resumed from manifest)"
736
+ )
737
+
738
+ prune_stamps = _resolve_prune_stamps(config)
739
+ logger.info(
740
+ f"Stamp-cache pruning: {'ON' if prune_stamps else 'OFF'} "
741
+ f"({'explicit' if config.inference.prune_stamps is not None else 'auto'}; "
742
+ f"{'default fingerprint-scoped cache dir' if config.inference.preprocess_output_dir is None else 'explicit --preprocess-output-dir'})"
743
+ )
744
+
745
+ failed_keys: list[tuple] = []
746
+ if pending:
747
+ # Start the persistent energy-detection pool from the main thread BEFORE any
748
+ # background thread exists (forking after threads spin up risks inheriting
749
+ # mid-operation locks in the children)
750
+ preprocessor.start_energy_detection_pool()
751
+ try:
752
+ # Prefetch depth (#298 N2): `depth` cadences preprocess+load concurrently ahead
753
+ # of the GPU stage. Futures are consumed strictly in catalog order, so results,
754
+ # manifest/supersede ordering, the reference-cloud offer order, and per-cadence
755
+ # seeding (keyed on unit.index — the catalog position, not execution order) are
756
+ # identical at any depth. Cost: up to `depth` in-flight cadences of RAM.
757
+ depth = max(1, config.inference.prefetch_depth)
758
+ with ThreadPoolExecutor(
759
+ max_workers=depth, thread_name_prefix="preproc_prefetch"
760
+ ) as prefetch:
761
+ futures = deque(
762
+ prefetch.submit(_prefetch_cadence, preprocessor, unit)
763
+ for unit in pending[:depth]
764
+ )
765
+ next_submit = len(futures)
766
+
767
+ # Load models AFTER the first prefetch is in flight (#298 rider): the
768
+ # 10-60 s encoder+RF+calibrator load hides under the first cadence's
769
+ # energy detection. The worker pool was forked before any thread existed,
770
+ # and every cadence reuses this one pipeline.
771
+ pipeline = InferencePipeline(strategy=strategy)
772
+
773
+ for i, unit in enumerate(pending):
774
+ # NOTE: an exception inside a prefetched preprocessing task surfaces
775
+ # here, `depth` iterations after it was submitted, when its future is
776
+ # resolved — and then propagates to inference_command's retry loop. In
777
+ # practice process_pending_cadence swallows per-cadence failures
778
+ # (returns None) and _prefetch_cadence contains load failures, so only
779
+ # infrastructure-level errors (e.g. a broken worker pool) raise.
780
+ cadence_result, cadence_data = futures.popleft().result()
781
+
782
+ # Keep `depth` cadences in flight while the main thread encodes this one
783
+ if next_submit < len(pending):
784
+ futures.append(
785
+ prefetch.submit(_prefetch_cadence, preprocessor, pending[next_submit])
786
+ )
787
+ next_submit += 1
788
+
789
+ if cadence_result is None:
790
+ logger.info(f"Cadence {unit.group.key}: no stamps produced; skipping")
791
+ continue
792
+
793
+ # Inference-stage failure containment: log, record in the manifest,
794
+ # move on — one bad cadence must not abort the catalog. The pass
795
+ # raises after the loop so the retry loop re-attempts failed cadences.
796
+ try:
797
+ results = _infer_cadence(
798
+ pipeline,
799
+ preprocessor,
800
+ unit,
801
+ cadence_result,
802
+ current_fingerprint,
803
+ cadence_data=cadence_data,
804
+ )
805
+ # Release the prefetched array as soon as _infer_cadence returns
806
+ # (its own del only clears the callee's reference): with
807
+ # prefetch_depth cadences already loading behind this one, holding
808
+ # it until the next iteration's rebind would stack an extra
809
+ # cadence-sized array on peak RAM
810
+ del cadence_data
811
+ except Exception as e:
812
+ logger.error(
813
+ f"Cadence {cadence_result.key}: inference stage failed ({e}); "
814
+ f"continuing with remaining cadences"
815
+ )
816
+ failed_keys.append(cadence_result.key)
817
+ db.write_inference_cadence(
818
+ npy_path=cadence_result.npy_path,
819
+ status="failed",
820
+ tag=tag,
821
+ csv_path=unit.group.csv_path,
822
+ cadence_key=cadence_result.key,
823
+ # Despite the historical field name, CadenceResult.n_hits is
824
+ # the .npy's stamp-row count — the same quantity as the
825
+ # 'preprocessed'/'inferred' rows' n_stamps, so the manifest
826
+ # stays consistent across all three statuses.
827
+ n_stamps=cadence_result.n_hits,
828
+ )
829
+ continue
830
+
831
+ totals["n_cadence_snippets"] += results["n_cadence_snippets"]
832
+ totals["n_processed"] += results["n_processed"]
833
+ totals["n_candidates"] += results["n_candidates"]
834
+ totals["n_cadences"] += 1
835
+
836
+ if collector is not None:
837
+ # Same contract as record_skipped above: an exception here would
838
+ # abort the whole pass after the cadence's inference already
839
+ # succeeded — swallow it and degrade the plots instead
840
+ try:
841
+ collector.record_processed(
842
+ cadence_result.key,
843
+ cadence_result.npy_path,
844
+ cadence_result.metadata_path,
845
+ results["provenance"],
846
+ results,
847
+ results["duration_s"],
848
+ )
849
+ except Exception as e:
850
+ logger.error(
851
+ f"Viz collection failed for cadence {cadence_result.key} "
852
+ f"({e}); plots will be degraded; run continues"
853
+ )
854
+
855
+ # Stamp-cache pruning (#302): strictly after the 'inferred' manifest
856
+ # row (inside _infer_cadence) and viz collection above, and only for
857
+ # stamps THIS run freshly extracted — a resumed run never deletes a
858
+ # cache it was handed
859
+ if prune_stamps and cadence_result.freshly_extracted:
860
+ _prune_cadence_stamps(cadence_result, results, collector)
861
+
862
+ logger.info(
863
+ f"Cadence {cadence_result.key} ({totals['n_cadences']} done, "
864
+ f"{len(pending) - i - 1} to go): {results['n_processed']} snippets, "
865
+ f"{results['n_candidates']} candidate(s)"
866
+ )
867
+ del results
868
+ finally:
869
+ preprocessor.stop_energy_detection_pool()
870
+ # Release TF dataset/iterator state once per run, after the loaded models are done
871
+ tf.keras.backend.clear_session()
872
+ logger.info("Cleared TensorFlow session state")
873
+
874
+ if failed_keys:
875
+ # Whole-pass retry over the remaining failed set: cadences that completed are
876
+ # skipped via their 'inferred' manifest rows on the next attempt
877
+ raise RuntimeError(
878
+ f"Inference stage failed for {len(failed_keys)} cadence(s): {failed_keys}"
879
+ )
880
+
881
+ # Reference cloud (#282): MC-score the seeded reject reservoir once per successful
882
+ # pass. On a resumed run only the final attempt's cadences feed the reservoir
883
+ # (manifest-skipped cadences never re-offer their rejects) — the npz records the
884
+ # subsample size and rejects seen for provenance. Best-effort: the cloud degrades
885
+ # the uncertainty plot, never the science.
886
+ try:
887
+ with stage_timer("inference.reference_cloud"):
888
+ pipeline.finalize_reference_cloud()
889
+ except Exception as e:
890
+ logger.error(
891
+ f"Reference-cloud finalization failed ({e}); the candidate uncertainty "
892
+ f"plot will lack the survey background; run continues"
893
+ )
894
+
895
+ if totals["n_cadences"] == 0:
896
+ # Preserve the historical contract: preprocessing producing no stamp .npy at all is
897
+ # an error (bad paths/catalog), not a legitimate empty result
898
+ raise NonRetryableInferenceError("No cadence results produced by preprocessing")
899
+
900
+ if collector is not None:
901
+ # Every figure is individually exception-guarded — a plot bug can't fail the pass
902
+ with stage_timer("inference.viz"):
903
+ render_inference_visualizations(collector, preprocessor, totals)
904
+
905
+ return totals
906
+
907
+
908
+ def _run_legacy_test_files_inference(
909
+ preprocessor: DataPreprocessor, strategy: tf.distribute.Strategy
910
+ ) -> dict:
911
+ """Legacy --test-files path: load + infer in one shot. The load repeats on retry (the
912
+ old cross-attempt cadence_data cache is gone — the manifest made it obsolete on the
913
+ streaming path, and holding a catalog-sized array across attempts was its only
914
+ remaining use).
915
+
916
+ Before the fresh positives land, any inference_results rows a dead attempt wrote for
917
+ this npy_path are retired — the legacy counterpart of _infer_cadence's
918
+ supersede-on-retry step 1; without it a failure after partial writes plus a retry
919
+ would leave duplicate live candidate rows for the same npy_path.
920
+ """
921
+ config = get_config()
922
+ if config is None:
923
+ raise ValueError("get_config() returned None")
924
+ db = get_db()
925
+ if db is None:
926
+ raise ValueError("get_db() returned None")
927
+ npy_path = config.data.test_files[0] # TODO: handle multiple test_files properly
928
+
929
+ with stage_timer("inference.load_lognorm"):
930
+ cadence_data = preprocessor.load_inference_data().astype(np.float32)
931
+
932
+ db.mark_superseded("inference_results", config.checkpoint.save_tag, npy_path=npy_path)
933
+ results = run_inference_pipeline(
934
+ cadence_data=cadence_data,
935
+ npy_path=npy_path,
936
+ strategy=strategy,
937
+ )
938
+ del cadence_data
939
+ return results
940
+
941
+
942
+ # NOTE: we need to load the saved config from the corresponding training run, but when/where should we do that, and how does that play with apply_args_to_config()?
943
+ def inference_command():
944
+ """Execute inference pipeline with distributed strategy & fault tolerance"""
945
+ logger.info("=" * 60)
946
+ logger.info("Starting Aetherscan Inference Pipeline")
947
+ logger.info("=" * 60)
948
+
949
+ config = get_config()
950
+ if config is None:
951
+ raise ValueError("get_config() returned None")
952
+
953
+ # The artifact trio (encoder/rf/config paths) is populated upstream — either explicit
954
+ # local paths validated by collect_validation_errors() in cli.py, or HuggingFace-
955
+ # downloaded cache paths from resolve_inference_artifacts() — and stamp_width ==
956
+ # width_bin is enforced by validation, so by the time inference_command() runs those
957
+ # preconditions are guaranteed to hold.
958
+ # TODO: add a sanity check that verifies encoder, RF, and config path all have the same tag. throw a warning if false
959
+
960
+ # NOTE: come back to this later (print more descriptive info)
961
+ logger.info("Configuration:")
962
+ logger.info(f" Data path: {config.data_path}")
963
+ logger.info(f" Model path: {config.model_path}")
964
+ logger.info(f" Output path: {config.output_path}")
965
+ logger.info(f" Encoder path: {config.inference.encoder_path}")
966
+ logger.info(f" Random Forest path: {config.inference.rf_path}")
967
+ logger.info(f" Config path: {config.inference.config_path}")
968
+ if config.data.inference_files is not None:
969
+ logger.info(f" Inference CSVs to process: {config.data.inference_files}")
970
+ else:
971
+ logger.info(f" Files to process: {config.data.test_files}")
972
+ logger.info(f" Classification threshold: {config.inference.classification_threshold}")
973
+
974
+ # Setup GPU strategy
975
+ try:
976
+ strategy = setup_gpu_strategy()
977
+ except Exception as e:
978
+ logger.error(f"Failed to setup GPU strategy: {e}")
979
+ sys.exit(1)
980
+
981
+ # NOTE: come back to this later (should we provide a CPU-only mode?)
982
+ if strategy is None:
983
+ logger.error("No GPU strategy available. Inference requires GPU.")
984
+ sys.exit(1)
985
+
986
+ # Run preprocessing + inference with fault tolerance.
987
+ # Recovery is state-based, not checkpoint-based: preprocessing resumes off each
988
+ # cadence's on-disk stamp .npy, and the inference stage resumes off the
989
+ # inference_cadences run manifest in the DB (a live status='inferred' row means the
990
+ # cadence is skipped entirely) — so a retry, in-process or a full relaunch of the
991
+ # identical command, re-runs only what the previous attempt didn't finish.
992
+ preprocessor = DataPreprocessor()
993
+ max_retries = config.inference.max_retries
994
+ retry_delay = config.inference.retry_delay
995
+ results = None
996
+ # Run-scoped stamp-gallery pixel pool: persists across the retry attempts below so a
997
+ # cadence pruned in an earlier attempt still renders in the final attempt's gallery
998
+ # (#305). Bounded to top-K pixels (~2.4 MB); the preprocessor likewise persists, so its
999
+ # freshly-extracted set survives too.
1000
+ gallery_pool: list = []
1001
+
1002
+ for attempt in range(max_retries):
1003
+ try:
1004
+ logger.info(f"Inference attempt: {attempt + 1}/{max_retries}")
1005
+
1006
+ if config.data.inference_files is not None:
1007
+ # Streaming CSV path: per-cadence preprocess -> load -> encode -> RF ->
1008
+ # write, with models loaded once and inference.prefetch_depth cadences in
1009
+ # flight (see _run_streaming_csv_inference). Memory stays independent of
1010
+ # catalog size.
1011
+ results = _run_streaming_csv_inference(preprocessor, strategy, gallery_pool)
1012
+ else:
1013
+ if not config.data.test_files:
1014
+ logger.error(
1015
+ "Neither --inference-files nor --test-files is configured; "
1016
+ "nothing to load for inference"
1017
+ )
1018
+ sys.exit(1)
1019
+ # Legacy --test-files path: load + infer in one shot, with stale rows from
1020
+ # a dead attempt superseded first (see _run_legacy_test_files_inference)
1021
+ results = _run_legacy_test_files_inference(preprocessor, strategy)
1022
+ break # success
1023
+
1024
+ except KeyboardInterrupt:
1025
+ # Don't retry on user interruption; re-raise to propagate traceback
1026
+ logger.info("Inference interrupted by user")
1027
+ raise
1028
+
1029
+ except NonRetryableInferenceError as e:
1030
+ # Permanent failure (empty/invalid catalog): retrying can't fix it, so fail
1031
+ # fast instead of burning the remaining attempts
1032
+ logger.error(f"Inference failed permanently: {e}")
1033
+ sys.exit(1)
1034
+
1035
+ except Exception as e:
1036
+ logger.error(f"Inference attempt {attempt + 1} failed with error: {e}")
1037
+ if attempt < max_retries - 1:
1038
+ logger.info(f"Retrying in {retry_delay} seconds...")
1039
+ gc.collect()
1040
+ time.sleep(retry_delay)
1041
+ else:
1042
+ logger.error(f"Exceeded max retries ({max_retries}). Final error: {e}")
1043
+ sys.exit(1)
1044
+
1045
+ # NOTE: come back to this later (should we create dedicated (tagged) directories inside output_path to store inference results (plots, configs, etc.)? note, data still written to db regardless)
1046
+ # Save inference configuration
1047
+ config_path = os.path.join(config.output_path, f"config_{config.checkpoint.save_tag}.json")
1048
+ os.makedirs(os.path.dirname(config_path), exist_ok=True) # Create dir if it doesn't exist
1049
+
1050
+ with open(config_path, "w") as f:
1051
+ json.dump(config.to_dict(), f, indent=2)
1052
+ logger.info(f"Inference configuration saved to {config_path}")
1053
+
1054
+ logger.info("=" * 60)
1055
+ logger.info("Inference completed successfully!")
1056
+ logger.info("Summary:")
1057
+ if "n_cadences" in results:
1058
+ logger.info(f" Total cadences: {results['n_cadences']}")
1059
+ if results.get("n_skipped"):
1060
+ logger.info(f" Resumed from a previous attempt (skipped): {results['n_skipped']}")
1061
+ logger.info(f" Total cadence snippets: {results['n_cadence_snippets']}")
1062
+ logger.info(f" Processed: {results['n_processed']}")
1063
+ logger.info(f" Candidates found: {results['n_candidates']}")
1064
+ logger.info("=" * 60)
1065
+
1066
+ _post_benchmark_report(config.checkpoint.save_tag)
1067
+
1068
+
1069
+ def main():
1070
+ """Main entry point to Aetherscan pipeline"""
1071
+ # Auto-load <repo>/.env (searched upward from CWD) into os.environ so
1072
+ # environment variables land in the process env before any aetherscan
1073
+ # module reads them — covers the Ampere conda workflow without needing
1074
+ # "source .env" or an inline VAR=val prefix, and harmlessly redundant in
1075
+ # the container workflow (utils/run_container.sh already passes --env for
1076
+ # the same keys, and load_dotenv() by default won't override existing
1077
+ # values). Multiprocess workers spawned later inherit os.environ from us.
1078
+ load_dotenv(find_dotenv())
1079
+
1080
+ # Initialize config
1081
+ try:
1082
+ init_config()
1083
+ except Exception as e:
1084
+ # Note, can't log before init_logger() — write to stderr so the failure isn't silent
1085
+ # (print() is banned outside utils/ by the T20 lint rule).
1086
+ sys.stderr.write(f"Failed to initialize config: {e}\n")
1087
+ sys.exit(1)
1088
+
1089
+ # Set up the CLI parser and parse arguments BEFORE init_logger so the logger can name this
1090
+ # run's log file with its effective save_tag (aetherscan_{save_tag}.log instead of a single
1091
+ # overwritten aetherscan.log). Arg parsing has no dependency on the logger/manager; it is
1092
+ # hoisted here only to resolve the tag. Like init_config above, these steps run before the
1093
+ # logger exists and so cannot log.
1094
+ try:
1095
+ parser = setup_argument_parser()
1096
+ except Exception as e:
1097
+ # Note, can't log before init_logger() — write to stderr so the failure isn't silent
1098
+ # (print() is banned outside utils/ by the T20 lint rule).
1099
+ sys.stderr.write(f"Failed to set up argument parser: {e}\n")
1100
+ sys.exit(1)
1101
+
1102
+ # Parse arguments
1103
+ try:
1104
+ args = parser.parse_args()
1105
+ except SystemExit as e:
1106
+ # argparse calls sys.exit(2) on parse errors (invalid types, missing required args, etc.),
1107
+ # which is why we catch SystemExit instead of Exception. argparse already prints its own
1108
+ # error message + usage to stderr; we re-print full help and re-raise to preserve exit
1109
+ # code 2. No logger exists yet (see above), so nothing is logged here.
1110
+ if e.code == 2: # argparse error (syntax/type error)
1111
+ parser.print_help()
1112
+ raise
1113
+
1114
+ # Resolve the run's save-tag ONCE, here, at runtime: a full {command}_{datetime} --load-tag
1115
+ # resumes that run in place (its tag is adopted); otherwise {--save-tag prefix or subcommand}_
1116
+ # {datetime}. Done before init_logger() (which names the log file by tag) and validate_args()
1117
+ # so the log file, config, and every artifact share a single datetime. --save-tag stays a bare
1118
+ # prefix on `args` for validate_args to check; only the resolved full tag lands on the config.
1119
+ config = get_config()
1120
+ if config is None:
1121
+ raise ValueError("get_config() returned None")
1122
+ config.checkpoint.save_tag = resolve_save_tag(
1123
+ getattr(args, "command", None),
1124
+ getattr(args, "save_tag", None),
1125
+ getattr(args, "load_tag", None),
1126
+ )
1127
+
1128
+ # Initialize logger, naming the run's log file with the resolved save_tag.
1129
+ try:
1130
+ init_logger(save_tag=config.checkpoint.save_tag)
1131
+ logger.info("Logger initialization successful, but not yet registered for cleanup.")
1132
+ logger.info("Awaiting resource manager initialization. Do not terminate the process!")
1133
+ except Exception as e:
1134
+ # Note, can't log if init_logger() fails
1135
+ sys.exit(1)
1136
+
1137
+ # A full --load-tag resumes that run in place (its tag is adopted as the save_tag). If the user
1138
+ # ALSO passed an explicit --save-tag prefix, it was silently overridden — surface that so the
1139
+ # save-tag not taking effect isn't a mystery.
1140
+ if getattr(args, "save_tag", None) and config.checkpoint.save_tag == getattr(
1141
+ args, "load_tag", None
1142
+ ):
1143
+ logger.info(
1144
+ f"Resuming in place under --load-tag '{args.load_tag}' — the provided --save-tag "
1145
+ f"'{args.save_tag}' was ignored (a full --load-tag adopts that run's tag)."
1146
+ )
1147
+
1148
+ # Initialize resource manager
1149
+ try:
1150
+ init_manager()
1151
+ logger.info("Resource manager initialized successfully")
1152
+ except Exception as e:
1153
+ logger.error(f"Failed to initialize resource manager: {e}")
1154
+ sys.exit(1)
1155
+
1156
+ # Register logger
1157
+ try:
1158
+ register_logger()
1159
+ logger.info("Logger registered successfully")
1160
+ except Exception as e:
1161
+ logger.error(f"Failed to register logger: {e}")
1162
+ sys.exit(1)
1163
+
1164
+ # Inference mode: resolve the model artifact trio before anything reads it. When the
1165
+ # user provided none of --encoder-path/--rf-path/--config-path, the pinned
1166
+ # (--hf-revision) or latest-release revision is downloaded from the HuggingFace Hub
1167
+ # and the cached paths are written onto `args` — exactly as if they were passed on the
1168
+ # CLI — so validation, apply_saved_config, and the model-load path run unchanged.
1169
+ # Explicit local paths take precedence; a partial trio falls through to validate_args,
1170
+ # which reports the missing paths.
1171
+ if args.command == "inference":
1172
+ try:
1173
+ resolve_inference_artifacts(args)
1174
+ except Exception as e:
1175
+ logger.error(f"Failed to resolve inference model artifacts: {e}")
1176
+ sys.exit(1)
1177
+
1178
+ # NOTE: come back to this later
1179
+ # Inference mode: if the user pointed --config-path at a saved JSON config,
1180
+ # layer its values onto the singleton *before* validate_args runs. That way
1181
+ # validate_args sees the merged (saved + CLI) view via _resolve, and any
1182
+ # invariants that involve fields stored in the saved config (e.g. width_bin /
1183
+ # stamp_width / latent_dim / dense_layer_size) are checked against the actual
1184
+ # values inference will use rather than the dataclass defaults. Train mode
1185
+ # is unaffected.
1186
+ if args.command == "inference" and getattr(args, "config_path", None) is not None:
1187
+ try:
1188
+ apply_saved_config(args.config_path)
1189
+ logger.info(f"Saved config loaded from {args.config_path}")
1190
+ except Exception as e:
1191
+ parser.print_help()
1192
+ logger.error(f"Failed to load saved config: {e}")
1193
+ logger.error("See usage")
1194
+ sys.exit(1)
1195
+
1196
+ # Validate arguments (handles everything else parse_args() missed)
1197
+ try:
1198
+ validate_args(args)
1199
+ logger.info("CLI arguments validated. No issues found")
1200
+ except Exception as e:
1201
+ # Print help message & exit if validate_args() fails
1202
+ parser.print_help()
1203
+ logger.error(f"Invalid CLI arguments received: {e}")
1204
+ logger.error("See usage")
1205
+ sys.exit(1)
1206
+
1207
+ # Override default config values with CLI arguments
1208
+ try:
1209
+ apply_args_to_config(args)
1210
+ logger.info("CLI arguments applied successfully")
1211
+ except Exception as e:
1212
+ logger.error(f"Failed to apply CLI args: {e}")
1213
+ sys.exit(1)
1214
+
1215
+ # Initialize database
1216
+ try:
1217
+ init_db()
1218
+ logger.info("Database initialized successfully")
1219
+ except Exception as e:
1220
+ logger.error(f"Failed to initialize database: {e}")
1221
+ sys.exit(1)
1222
+
1223
+ # Initialize resource monitoring
1224
+ try:
1225
+ init_monitor()
1226
+ logger.info("Resource monitor initialized successfully")
1227
+ except Exception as e:
1228
+ logger.error(f"Failed to initialize resource monitor: {e}")
1229
+ sys.exit(1)
1230
+
1231
+ # Auto-launch the live monitoring dashboard (opt out with --no-dashboard). Fully guarded:
1232
+ # a missing streamlit or a spawn failure only warns — the dashboard is optional observability
1233
+ # and must never abort the run.
1234
+ try:
1235
+ launch_dashboard()
1236
+ except Exception as e:
1237
+ logger.warning(f"Dashboard launch skipped: {e}")
1238
+
1239
+ try:
1240
+ # Fail-early save-tag dedup guards: hard-stop before any expensive work when an
1241
+ # explicitly-provided --save-tag collides with a previous run's artifacts/DB rows
1242
+ # (resumable-run manifests exempt same-tag retries), and check the HF repo for a
1243
+ # tag collision when --hf-upload is set. --force-tag overrides.
1244
+ enforce_tag_guards(args)
1245
+
1246
+ # Execute command
1247
+ if args.command == "train":
1248
+ train_command()
1249
+ elif args.command == "inference":
1250
+ inference_command()
1251
+ else:
1252
+ # Print help message & exit if no valid command provided
1253
+ parser.print_help()
1254
+ logger.error("Invalid CLI command received")
1255
+ logger.error("See usage")
1256
+ sys.exit(1)
1257
+
1258
+ finally:
1259
+ # Explicitly call cleanup_all() before exiting to avoid deadlock
1260
+ # Without this, non-daemon threads block sys.exit() from running atexit handlers (race condition)
1261
+ # NOTE: do the other sys.exit() calls in main.py get blocked by non-daemon threads as well?
1262
+ # BUG: sys.exit() calls DO get blocked. directly call manager.cleanup_all() instead. actually sometimes it works? further testing required
1263
+ manager = get_manager() # NOTE: is this needed? since manager initialized in main?
1264
+ if manager:
1265
+ manager.cleanup_all()
1266
+
1267
+
1268
+ if __name__ == "__main__":
1269
+ main()