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/hf_hub.py ADDED
@@ -0,0 +1,547 @@
1
+ """
2
+ HuggingFace Hub integration for Aetherscan model artifacts.
3
+
4
+ One public model repo (config.hf.repo_id, default zachtheyek/aetherscan) carries the released
5
+ weights at stable filenames in the repo root — vae_encoder.keras, vae_decoder.keras,
6
+ random_forest.joblib, config.json, plus an auto-generated model card README.md — with HF git
7
+ tags carrying the versioning. Two tag families exist on the Hub: training tags (= a run's
8
+ save_tag, e.g. train_20260101_120000, created by upload_run_to_hf after training) and release
9
+ tags (vX.Y.Z, added by the release runbook pointing at the blessed weights commit).
10
+
11
+ Upload (train, opt-in via --hf-upload) stages the four run artifacts under the stable names,
12
+ commits them with the save_tag as the commit message, and tags the commit. Download
13
+ (inference, the default when no local artifact paths are given) resolves a revision —
14
+ explicit --hf-revision, else v{__version__} when running as an installed release (see
15
+ version_default_revision — this is what makes `pip install aetherscan==1.0.0` + bare
16
+ inference pull exactly the v1.0.0 weights), else the highest semver vX.Y.Z release tag — and
17
+ pulls the artifact trio revision-pinned via hf_hub_download. A no-artifact download requires a
18
+ release tag; training tags never name the default revision.
19
+
20
+ Auth: uploads require HF_TOKEN in the environment (loaded from the gitignored .env);
21
+ huggingface_hub reads it implicitly. The repo is public, so downloads need no token. The
22
+ token value is never logged.
23
+
24
+ huggingface_hub is imported lazily inside the thin _hf_api()/_hf_hub_download() seams so this
25
+ module stays importable (and unit-testable with those seams monkeypatched) without the
26
+ dependency installed — e.g. in the pre-rebuild NGC container, where only actual Hub calls
27
+ should fail.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import contextlib
34
+ import copy
35
+ import json
36
+ import logging
37
+ import os
38
+ import platform
39
+ import re
40
+ import shutil
41
+ import tempfile
42
+ from typing import Any
43
+
44
+ from aetherscan.config import get_config
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Stable artifact filenames at the HF repo root (versioning lives in git tags, not names).
49
+ HF_ENCODER_FILENAME = "vae_encoder.keras"
50
+ HF_DECODER_FILENAME = "vae_decoder.keras"
51
+ HF_RF_FILENAME = "random_forest.joblib"
52
+ HF_CONFIG_FILENAME = "config.json"
53
+ HF_CALIBRATOR_FILENAME = "rf_calibrator.joblib" # optional: only calibrated runs (#282)
54
+ HF_CARD_FILENAME = "README.md"
55
+
56
+ GITHUB_URL = "https://github.com/zachtheyek/Aetherscan"
57
+
58
+ # Release tags (vX.Y.Z, from the release runbook) name the default download revision. Training
59
+ # tags (the {command}_{datetime} run tags) never name it — a no-artifact inference download
60
+ # requires a blessed release tag.
61
+ _SEMVER_TAG_PATTERN = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
62
+
63
+
64
+ def _hf_api():
65
+ """Construct an HfApi client (lazy import — see module docstring). HF_TOKEN is picked up
66
+ from the environment implicitly for authenticated calls."""
67
+ from huggingface_hub import HfApi # noqa: PLC0415
68
+
69
+ return HfApi()
70
+
71
+
72
+ def _hf_hub_download(**kwargs) -> str:
73
+ """Thin seam over huggingface_hub.hf_hub_download (lazy import — see module docstring)."""
74
+ from huggingface_hub import hf_hub_download # noqa: PLC0415
75
+
76
+ return hf_hub_download(**kwargs)
77
+
78
+
79
+ def _disable_hub_progress_bars() -> None:
80
+ """Disable huggingface_hub's tqdm progress bars for this process. The pipeline redirects
81
+ sys.stdout/sys.stderr to loggers (StreamToLogger), so interactive progress rendering is
82
+ line-spam at best — and tty/capability probing by the progress machinery must never be a
83
+ failure surface for uploads/downloads (hf_upload once died on a stdout isatty() probe).
84
+ Tolerant of a missing huggingface_hub: the subsequent Hub call raises its own error."""
85
+ try:
86
+ from huggingface_hub.utils import disable_progress_bars # noqa: PLC0415
87
+ except Exception:
88
+ return
89
+ disable_progress_bars()
90
+
91
+
92
+ def _is_tag_conflict(exc: Exception) -> bool:
93
+ """True when `exc` is the Hub's tag-already-exists HTTP 409 conflict."""
94
+ try:
95
+ from huggingface_hub.errors import HfHubHTTPError # noqa: PLC0415
96
+ except Exception:
97
+ return False
98
+ if not isinstance(exc, HfHubHTTPError):
99
+ return False
100
+ return getattr(getattr(exc, "response", None), "status_code", None) == 409
101
+
102
+
103
+ def list_hf_tags(repo_id: str) -> list[str]:
104
+ """Return the names of every git tag on the HF model repo; [] when the repo doesn't exist
105
+ yet (first upload creates it). Network/auth errors propagate to the caller."""
106
+ from huggingface_hub.errors import RepositoryNotFoundError # noqa: PLC0415
107
+
108
+ try:
109
+ refs = _hf_api().list_repo_refs(repo_id, repo_type="model")
110
+ except RepositoryNotFoundError:
111
+ return []
112
+ return [ref.name for ref in refs.tags]
113
+
114
+
115
+ def hf_tag_exists(repo_id: str, tag: str) -> bool:
116
+ """True when `tag` already exists on the HF repo (used by the fail-early dedup guard)."""
117
+ return tag in list_hf_tags(repo_id)
118
+
119
+
120
+ def select_default_revision(tags: list[str]) -> str | None:
121
+ """
122
+ Pick the default download revision from a repo's tag list: the highest semver vX.Y.Z
123
+ release tag, or None if the repo carries no release tag. Comparison is numeric
124
+ (v1.10.0 > v1.9.9); training tags (the {command}_{datetime} run tags) never win — a
125
+ no-artifact inference download requires a blessed release tag.
126
+ """
127
+ semver = [
128
+ (tuple(int(g) for g in m.groups()), t) for t in tags if (m := _SEMVER_TAG_PATTERN.match(t))
129
+ ]
130
+ if semver:
131
+ return max(semver)[1]
132
+ return None
133
+
134
+
135
+ def version_default_revision() -> str | None:
136
+ """
137
+ The version-coupled default HF revision — f"v{__version__}" — or None when this run's
138
+ version can't name a release tag. This is the runtime half of the release contract
139
+ (docs/RELEASE.md): `pip install aetherscan==1.0.0` + inference with no model-path flags
140
+ downloads exactly the v1.0.0-blessed weights, because the installed package version is
141
+ the default revision.
142
+
143
+ Guard: the default only activates when v{__version__} lands in the release-tag family
144
+ (_SEMVER_TAG_PATTERN, strict vX.Y.Z). The importlib.metadata fallback ("0.0.0.dev0" —
145
+ source-tree/container runs), .dev pre-releases (e.g. "0.9.0.dev0" — a pip install of a
146
+ between-releases tree), and rc/post/local versions all fail the match and fall through
147
+ to latest-release resolution instead. Deliberately NOT existence-checked: an installed
148
+ release whose weights tag is missing must fail the download loudly (the release
149
+ runbook's blessing step was skipped) rather than silently pull some other version's
150
+ weights.
151
+ """
152
+ import aetherscan # noqa: PLC0415 (late import so tests can monkeypatch __version__)
153
+
154
+ candidate = f"v{aetherscan.__version__}"
155
+ if not _SEMVER_TAG_PATTERN.match(candidate):
156
+ return None
157
+ return candidate
158
+
159
+
160
+ def resolve_hf_revision(repo_id: str, revision: str | None) -> str:
161
+ """
162
+ Resolve the HF revision inference should download from: an explicitly requested revision
163
+ is returned as-is (existence is checked by the download itself); otherwise an installed
164
+ release pins its own version's tag (version_default_revision — also download-checked);
165
+ otherwise the repo's tags are listed and select_default_revision picks the latest
166
+ release. Raises RuntimeError with guidance when nothing is resolvable.
167
+ """
168
+ if revision is not None:
169
+ return revision
170
+ version_pinned = version_default_revision()
171
+ if version_pinned is not None:
172
+ logger.info(
173
+ f"Resolved HF revision '{version_pinned}' (pinned to the installed aetherscan "
174
+ f"release; override with --hf-revision)"
175
+ )
176
+ return version_pinned
177
+ try:
178
+ tags = list_hf_tags(repo_id)
179
+ except Exception as e:
180
+ raise RuntimeError(
181
+ f"Could not list tags on HF repo '{repo_id}' to resolve the latest release: {e}. "
182
+ f"Check that --hf-repo-id is correct, the repo is public (or HF_TOKEN grants "
183
+ f"access), and this host can reach huggingface.co — or pin a revision with "
184
+ f"--hf-revision / pass all three local artifact paths."
185
+ ) from e
186
+ selected = select_default_revision(tags)
187
+ if selected is None:
188
+ raise RuntimeError(
189
+ f"No release tag (vX.Y.Z) found on HF repo "
190
+ f"'{repo_id}' to download model artifacts from. Either pin a revision with "
191
+ f"--hf-revision, point --hf-repo-id at a repo with released weights, or pass "
192
+ f"all three local artifact paths (--encoder-path/--rf-path/--config-path)."
193
+ )
194
+ logger.info(f"Resolved HF revision '{selected}' (latest release tag on {repo_id})")
195
+ return selected
196
+
197
+
198
+ def download_inference_artifacts(repo_id: str, revision: str) -> tuple[str, str, str]:
199
+ """
200
+ Download the (encoder, random forest, config) artifact trio from the HF repo at the
201
+ pinned revision. Returns the local cache paths (under HF_HOME / ~/.cache/huggingface;
202
+ repeated runs hit the cache). Public repo — no token required.
203
+ """
204
+ _disable_hub_progress_bars()
205
+ try:
206
+ paths = tuple(
207
+ _hf_hub_download(repo_id=repo_id, filename=filename, revision=revision)
208
+ for filename in (HF_ENCODER_FILENAME, HF_RF_FILENAME, HF_CONFIG_FILENAME)
209
+ )
210
+ # Optional calibrator (#282): lands in the same snapshot dir as the RF, where
211
+ # InferencePipeline.init_models derives its path from rf_path. Absent for
212
+ # uncalibrated runs and pre-#282 uploads — the downloaded config's
213
+ # rf.calibration_active decides whether inference requires it.
214
+ try:
215
+ _hf_hub_download(repo_id=repo_id, filename=HF_CALIBRATOR_FILENAME, revision=revision)
216
+ except Exception:
217
+ logger.info(
218
+ f"No {HF_CALIBRATOR_FILENAME} at {repo_id}@{revision} (expected for "
219
+ "uncalibrated or pre-#282 runs)"
220
+ )
221
+ except Exception as e:
222
+ # Wrap raw huggingface_hub errors (404s, network failures, auth) with operator
223
+ # guidance — this surfaces via main.py's resolution path at startup.
224
+ raise RuntimeError(
225
+ f"Failed to download model artifacts from {repo_id}@{revision}: {e}. Check "
226
+ f"that the revision exists (--hf-revision), the repo id is correct "
227
+ f"(--hf-repo-id), the repo is public (or HF_TOKEN grants access), and this "
228
+ f"host can reach huggingface.co."
229
+ ) from e
230
+ logger.info(f"Downloaded model artifacts from {repo_id}@{revision}")
231
+ return paths
232
+
233
+
234
+ def resolve_inference_artifacts(args: argparse.Namespace) -> None:
235
+ """
236
+ Ensure the inference artifact trio (encoder/rf/config paths) is populated on `args`
237
+ before validation runs, in resolution order: explicit local paths (highest) >
238
+ --hf-revision > v{__version__} when running as an installed release (see
239
+ version_default_revision) > latest release tag on the HF repo.
240
+
241
+ When none of the three paths were given, the resolved revision's artifacts are
242
+ downloaded and their cache paths written onto `args` — exactly as if the user had passed
243
+ them on the CLI, so the existing validate_args / apply_saved_config / model-load path is
244
+ reused unchanged. The resolved revision is also written to args.hf_revision so it lands
245
+ in config.hf.revision (and thus the saved inference config) for provenance.
246
+
247
+ A partial set of local paths is left untouched: collect_validation_errors reports the
248
+ missing ones (mixing local and Hub-sourced artifacts would silently pair mismatched
249
+ models). Raises RuntimeError (via resolve_hf_revision) or huggingface_hub errors when HF
250
+ resolution fails — the caller exits with the message as guidance.
251
+ """
252
+ provided = [
253
+ getattr(args, name, None) is not None for name in ("encoder_path", "rf_path", "config_path")
254
+ ]
255
+ if all(provided):
256
+ if getattr(args, "hf_revision", None) is not None:
257
+ logger.info("Explicit local artifact paths take precedence — ignoring --hf-revision")
258
+ return
259
+ if any(provided):
260
+ # Partial trio: leave args as-is; validation reports the missing paths.
261
+ return
262
+
263
+ config = get_config()
264
+ if config is None:
265
+ raise ValueError("get_config() returned None")
266
+ repo_id = getattr(args, "hf_repo_id", None) or config.hf.repo_id
267
+ revision = resolve_hf_revision(repo_id, getattr(args, "hf_revision", None))
268
+ logger.info(
269
+ f"No local artifact paths given — downloading model artifacts from HF repo "
270
+ f"'{repo_id}' at revision '{revision}'"
271
+ )
272
+ args.encoder_path, args.rf_path, args.config_path = download_inference_artifacts(
273
+ repo_id, revision
274
+ )
275
+ args.hf_revision = revision
276
+
277
+
278
+ def _collect_library_versions() -> dict[str, str]:
279
+ """Best-effort version stamps for the model card; import failures record 'unknown'."""
280
+ versions: dict[str, str] = {"python": platform.python_version()}
281
+ for card_name, module_name in (
282
+ ("tensorflow", "tensorflow"),
283
+ ("numpy", "numpy"),
284
+ ("scikit-learn", "sklearn"),
285
+ ("huggingface_hub", "huggingface_hub"),
286
+ ):
287
+ try:
288
+ module = __import__(module_name)
289
+ versions[card_name] = str(module.__version__)
290
+ except Exception:
291
+ versions[card_name] = "unknown"
292
+ return versions
293
+
294
+
295
+ def compute_rf_metrics(model_path: str, tag: str) -> dict[str, Any] | None:
296
+ """
297
+ Derive validation metrics for the model card from the run's persisted RF eval artifacts
298
+ (rf_eval_artifacts_{tag}.joblib, written by train_random_forest). Returns None when the
299
+ artifact is missing or unreadable — the card simply omits its metrics section.
300
+ """
301
+ artifact_path = os.path.join(model_path, f"rf_eval_artifacts_{tag}.joblib")
302
+ if not os.path.exists(artifact_path):
303
+ logger.info(f"No RF eval artifacts at {artifact_path} — model card omits metrics")
304
+ return None
305
+ try:
306
+ import joblib # noqa: PLC0415
307
+ from sklearn.metrics import average_precision_score, roc_auc_score # noqa: PLC0415
308
+
309
+ artifacts = joblib.load(artifact_path)
310
+ labels = artifacts["val_binary_labels"]
311
+ probas = artifacts["val_probas"]
312
+ return {
313
+ "val_roc_auc": float(roc_auc_score(labels, probas)),
314
+ "val_average_precision": float(average_precision_score(labels, probas)),
315
+ "classification_threshold": float(artifacts["classification_threshold"]),
316
+ "n_val": int(len(labels)),
317
+ }
318
+ except Exception as e:
319
+ logger.warning(f"Failed to compute RF metrics from {artifact_path}: {e}")
320
+ return None
321
+
322
+
323
+ def generate_model_card(
324
+ *,
325
+ tag: str,
326
+ config_dict: dict[str, Any],
327
+ metrics: dict[str, Any] | None,
328
+ versions: dict[str, str],
329
+ ) -> str:
330
+ """
331
+ Render the repo card (README.md) uploaded alongside the weights: pipeline description,
332
+ training tag, config summary (rounds / samples / SNR schedule), validation metrics when
333
+ available, library versions, and the GitHub link + citation pointer.
334
+ """
335
+ training = config_dict.get("training", {})
336
+ beta_vae = config_dict.get("beta_vae", {})
337
+ rf = config_dict.get("rf", {})
338
+
339
+ def row(name: str, section: dict[str, Any], key: str) -> str:
340
+ return f"| {name} | `{section.get(key, 'n/a')}` |"
341
+
342
+ config_rows = "\n".join(
343
+ [
344
+ row("Training rounds", training, "num_training_rounds"),
345
+ row("Epochs per round", training, "epochs_per_round"),
346
+ row("Beta-VAE samples per round", training, "num_samples_beta_vae"),
347
+ row("Random Forest samples", training, "num_samples_rf"),
348
+ row("Curriculum schedule", training, "curriculum_schedule"),
349
+ row("SNR base", training, "snr_base"),
350
+ row("Initial SNR range", training, "initial_snr_range"),
351
+ row("Final SNR range", training, "final_snr_range"),
352
+ row("Latent dimensions", beta_vae, "latent_dim"),
353
+ row("Beta (KL weight)", beta_vae, "beta"),
354
+ row("Alpha (clustering weight)", beta_vae, "alpha"),
355
+ row("RF estimators", rf, "n_estimators"),
356
+ ]
357
+ )
358
+
359
+ if metrics is not None:
360
+ metrics_section = (
361
+ "## Evaluation (validation split)\n\n"
362
+ "| Metric | Value |\n|---|---|\n"
363
+ f"| ROC AUC | {metrics['val_roc_auc']:.4f} |\n"
364
+ f"| Average precision | {metrics['val_average_precision']:.4f} |\n"
365
+ f"| Classification threshold | {metrics['classification_threshold']} |\n"
366
+ f"| Validation samples | {metrics['n_val']} |\n"
367
+ )
368
+ else:
369
+ metrics_section = "## Evaluation\n\nNo evaluation artifacts were available for this run.\n"
370
+
371
+ versions_rows = "\n".join(f"| {name} | `{ver}` |" for name, ver in versions.items())
372
+
373
+ return f"""---
374
+ license: bsd-3-clause
375
+ library_name: keras
376
+ tags:
377
+ - seti
378
+ - radio-astronomy
379
+ - anomaly-detection
380
+ - beta-vae
381
+ - random-forest
382
+ ---
383
+
384
+ # Aetherscan
385
+
386
+ [Breakthrough Listen](https://breakthroughinitiatives.org/initiative/1)'s deep-learning SETI
387
+ pipeline: a two-stage architecture where a **Beta-VAE encoder** compresses each observation of
388
+ a 6-observation cadence (3 ON / 3 OFF, ABACAD) into an 8-dimensional latent, and a **Random
389
+ Forest** classifies the cadence's concatenated latents as a technosignature candidate or not.
390
+
391
+ This repository carries the released model weights at stable filenames, versioned via git
392
+ tags: training tags match the pipeline run's save tag (e.g. `train_20260101_120000`), and
393
+ release tags (`vX.Y.Z`) mark blessed weights.
394
+
395
+ **Training tag**: `{tag}`
396
+
397
+ ## Files
398
+
399
+ | File | Description |
400
+ |---|---|
401
+ | `{HF_ENCODER_FILENAME}` | Beta-VAE encoder (Keras) — the inference feature extractor |
402
+ | `{HF_DECODER_FILENAME}` | Beta-VAE decoder (Keras) — for reconstruction/traversal analysis |
403
+ | `{HF_RF_FILENAME}` | Random Forest cadence classifier (joblib) |
404
+ | `{HF_CONFIG_FILENAME}` | Full resolved training configuration for this run |
405
+
406
+ ## Training configuration
407
+
408
+ | Parameter | Value |
409
+ |---|---|
410
+ {config_rows}
411
+
412
+ The complete configuration is in `{HF_CONFIG_FILENAME}`.
413
+
414
+ {metrics_section}
415
+ ## Library versions
416
+
417
+ | Library | Version |
418
+ |---|---|
419
+ {versions_rows}
420
+
421
+ ## Usage
422
+
423
+ Pin this training tag with `--hf-revision` to download exactly these weights (a bare no-artifact
424
+ inference download resolves to the latest `vX.Y.Z` release tag instead, never a training tag):
425
+
426
+ ```bash
427
+ python -m aetherscan.main inference --hf-revision {tag} --inference-files <catalog.csv>
428
+ ```
429
+
430
+ ## Links & citation
431
+
432
+ Source code, documentation, and issue tracker: [{GITHUB_URL}]({GITHUB_URL}).
433
+ If you use Aetherscan in your research, please cite it via the repository's `CITATION.cff`.
434
+ """
435
+
436
+
437
+ # Environment-specific config fields stripped before config.json is published to the PUBLIC Hub
438
+ # repo (HFSEC-1): whole sections that are pure host layout, and per-field absolute paths / real
439
+ # observation filenames. None are reproducible off-host, and they would otherwise disclose the
440
+ # operator's username, directory layout, and dataset file names on huggingface.co.
441
+ _UPLOAD_REDACT_SECTIONS = ("paths",)
442
+ _UPLOAD_REDACT_FIELDS = {
443
+ "data": ("train_files", "test_files", "inference_files"),
444
+ "inference": ("encoder_path", "rf_path", "config_path", "preprocess_output_dir"),
445
+ "checkpoint": ("load_dir",),
446
+ }
447
+
448
+
449
+ def _sanitize_config_for_upload(config_dict: dict) -> dict:
450
+ """Return a copy of the run config with host-specific fields (absolute paths, real dataset
451
+ filenames) stripped, so the config.json published to the public Hub repo carries only
452
+ reproducibility-relevant hyperparameters. See HFSEC-1."""
453
+ sanitized = copy.deepcopy(config_dict)
454
+ for section in _UPLOAD_REDACT_SECTIONS:
455
+ sanitized.pop(section, None)
456
+ for section, fields in _UPLOAD_REDACT_FIELDS.items():
457
+ block = sanitized.get(section)
458
+ if isinstance(block, dict):
459
+ for field in fields:
460
+ block.pop(field, None)
461
+ return sanitized
462
+
463
+
464
+ def upload_run_to_hf(
465
+ *, repo_id: str, tag: str, model_path: str, output_path: str, force: bool = False
466
+ ) -> None:
467
+ """
468
+ Publish one training run's final artifacts to the HF model repo: stage the four artifacts
469
+ under their stable names plus a generated model card, commit them (commit message = the
470
+ run's save_tag), and tag the commit with the save_tag. Creates the (public) repo when it
471
+ doesn't exist yet. With force=True a pre-existing identical tag is moved to the new
472
+ commit (the startup dedup guard was consciously overridden via --force-tag).
473
+
474
+ Raises on any failure — the caller (the hf_upload training stage) records it in the run
475
+ manifest without failing the run.
476
+ """
477
+ sources = {
478
+ HF_ENCODER_FILENAME: os.path.join(model_path, f"vae_encoder_{tag}.keras"),
479
+ HF_DECODER_FILENAME: os.path.join(model_path, f"vae_decoder_{tag}.keras"),
480
+ HF_RF_FILENAME: os.path.join(model_path, f"random_forest_{tag}.joblib"),
481
+ HF_CONFIG_FILENAME: os.path.join(output_path, f"config_{tag}.json"),
482
+ }
483
+ missing = [path for path in sources.values() if not os.path.exists(path)]
484
+ if missing:
485
+ raise FileNotFoundError(
486
+ f"Cannot upload run '{tag}' to HF: missing artifact(s): {', '.join(missing)}"
487
+ )
488
+
489
+ # Optional fifth artifact (#282): the probability calibrator, present only when training
490
+ # fitted-and-kept one (config.json's rf.calibration_active records which)
491
+ calibrator_source = os.path.join(model_path, f"rf_calibrator_{tag}.joblib")
492
+ if os.path.exists(calibrator_source):
493
+ sources[HF_CALIBRATOR_FILENAME] = calibrator_source
494
+
495
+ with open(sources[HF_CONFIG_FILENAME]) as f:
496
+ config_dict = json.load(f)
497
+ # The repo is PUBLIC, so strip environment-specific fields before publishing config.json:
498
+ # absolute host paths (username + internal layout) and real observation filenames, none of
499
+ # which are reproducible off-host (HFSEC-1). The model card renders only hyperparameters, so
500
+ # it is safe to build from the sanitized config too.
501
+ public_config = _sanitize_config_for_upload(config_dict)
502
+ card = generate_model_card(
503
+ tag=tag,
504
+ config_dict=public_config,
505
+ metrics=compute_rf_metrics(model_path, tag),
506
+ versions=_collect_library_versions(),
507
+ )
508
+
509
+ _disable_hub_progress_bars()
510
+ api = _hf_api()
511
+ with tempfile.TemporaryDirectory(prefix="aetherscan_hf_upload_") as staging:
512
+ for stable_name, source in sources.items():
513
+ if stable_name == HF_CONFIG_FILENAME:
514
+ with open(os.path.join(staging, stable_name), "w") as f:
515
+ json.dump(public_config, f, indent=2)
516
+ else:
517
+ shutil.copy2(source, os.path.join(staging, stable_name))
518
+ with open(os.path.join(staging, HF_CARD_FILENAME), "w") as f:
519
+ f.write(card)
520
+
521
+ api.create_repo(repo_id, repo_type="model", private=False, exist_ok=True)
522
+ api.upload_folder(repo_id=repo_id, folder_path=staging, commit_message=tag)
523
+
524
+ if force:
525
+ # --force-tag semantics: repoint the existing tag at the fresh commit rather than
526
+ # failing (delete errors are ignored — the tag may simply not exist yet).
527
+ # NOTE: delete_tag -> create_tag is not atomic (the Hub has no tag-move primitive):
528
+ # a crash between the two calls leaves the tag missing until the stage is retried —
529
+ # hf_upload stays un-done in the run manifest, so re-running the identical command
530
+ # recreates it. Acceptable for a consciously-forced override.
531
+ with contextlib.suppress(Exception):
532
+ api.delete_tag(repo_id, tag=tag)
533
+ try:
534
+ api.create_tag(repo_id, tag=tag)
535
+ except Exception as e:
536
+ if _is_tag_conflict(e):
537
+ # TOCTOU: the startup dedup guard checked this tag hours ago (a full-scale run
538
+ # trains for ~30 h) — a concurrent run may have created it since. The artifacts
539
+ # are safely uploaded on the main branch; only the tag is missing.
540
+ raise RuntimeError(
541
+ f"Tag '{tag}' already exists on {repo_id} (created after this run's "
542
+ f"startup check, e.g. by a concurrent run). The artifacts were uploaded "
543
+ f"but left untagged — re-run the identical command with --force-tag to "
544
+ f"move the tag to this run's upload."
545
+ ) from e
546
+ raise
547
+ logger.info(f"Uploaded run '{tag}' to https://huggingface.co/{repo_id} and tagged the commit")