HuggingFace Hub integration: opt-in weight upload after training, default weight download for inference, tag dedup guards#130
Conversation
huggingface_hub>=1.21,<1.22 in both dependency manifests, per SECURITY.md's Version Selection Policy: latest stable is 1.23.0 (2026-07-09), two minors back is 1.21 (2026-06-25), which is newer than the newest >=6-month-old release — so 1.21 is the target. Clusters need a container image rebuild to pick it up. utils/run_container.sh forwards HF_TOKEN (from the gitignored .env) into the container alongside SLACK_*/AETHERSCAN_*; the value is never logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation New HFConfig (repo_id / upload_after_training / revision) and checkpoint.force_tag on the config singleton, serialized via to_dict(). Flags: train --hf-upload/--no-hf-upload and both-mode --hf-repo-id, inference --hf-revision, both-mode --force-tag (tri-state BooleanOptionalAction like the other bool flags; effective default off). Inference's --encoder-path/--rf-path/--config-path become optional as an all-or-none trio: all three must exist on disk, none defers to the HuggingFace download path, and a partial trio is a validation error (mixing local and Hub-sourced artifacts would pair mismatched models). --hf-repo-id gets a namespace/name format check shared by both modes. README CLI Reference regenerated verbatim (print_cli_help.py all); docs/CONFIG_AND_CLI.md tables and audit counts updated; README .env example documents HF_TOKEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One public model repo carries the weights at stable filenames (vae_encoder.keras, vae_decoder.keras, random_forest.joblib, config.json) with HF git tags carrying the versioning. - upload_run_to_hf: stages the four run artifacts under the stable names plus a generated model card, commits with the save_tag as the message (create_repo exist_ok, public), then tags the commit; force moves an existing identical tag to the new commit. - resolve_inference_artifacts: explicit local paths > --hf-revision > latest semver vX.Y.Z tag > latest final_vX tag > error with guidance; downloads are revision-pinned via hf_hub_download and the resolved revision is written back to args for provenance. - Model card: pipeline description, training tag, config summary, val ROC AUC / AP from the run's rf_eval_artifacts when available, library versions, GitHub link + citation pointer. huggingface_hub is imported lazily behind small seams so the module imports without the dependency (pre-rebuild containers) and unit tests mock the seams — no network anywhere in the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New opt-in stage 6 in the training stage machine: when config.hf.upload_after_training is set, publish the final artifacts + model card to the Hub. Non-critical like the plot stages — failure is logged and recorded in the run manifest without failing the run (the weights are already safe locally), and re-running the identical command retries just this stage. Re-running with the upload disabled clears a stale recorded failure via the new TrainingRunState.clear_stage_failure so it can't force a nonzero exit forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inference: when none of the three artifact paths are given, main() resolves and downloads the pinned/latest revision from the Hub before validation, writing the cache paths onto args so the existing apply_saved_config + strategy-scoped model-load path runs unchanged. New aetherscan.tag_guards, enforced right before command dispatch (post-validation, post-DB-init): an explicitly-provided --save-tag that collides with a previous run's artifacts, saved config, or non-superseded DB rows hard-stops with the options listed, while resumable runs (train: run-state manifest; inference: inference_cadences manifest rows without the completed-run config JSON) are exempt so same-tag retries keep working. With --hf-upload, the save_tag is also checked against existing HF repo tags at startup instead of after ~30h of training; a failed HF check only warns. Default datetime tags are immune by construction; --force-tag consciously overrides everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @zachtheyek's task in 2m 29s —— View job PR Review: HuggingFace Hub Integration
SummaryThis is a well-structured, thoroughly tested feature addition. The architecture — lazy imports for the HF dependency, thin seams for testability, non-critical upload stage that never fails a training run, clean resolution-order for inference artifacts — is sound and integrates cleanly with the existing stage machine and manifest-based resume system. Strengths
Issues and SuggestionsI've left detailed inline comments on the relevant lines. The key items:
None of these are blockers. The PR is in good shape for merge once the live round-trip smoke test (blocked on Security
Test CoverageGood coverage across all new code paths: revision selection/resolution, artifact download routing, upload staging/arg shapes, model card content, tag guard matrix (local + HF, resume exemptions, force override), stage machine integration (opt-in/skip/failure/clear semantics), CLI validation (all-or-none trio, HF repo ID format, force-tag routing). |
|
|
||
| def hf_tag_exists(repo_id: str, tag: str) -> bool: | ||
| """True when `tag` already exists on the HF repo (used by the fail-early dedup guard).""" | ||
| return tag in list_hf_tags(repo_id) |
There was a problem hiding this comment.
Nit: hf_tag_exists downloads the entire tag list and throws it away after a single in check. The dedup guard calls this once, so it's fine in practice, but if there's ever a second call site it'd be worth caching the tag list or switching to a targeted API check.
Also, the list_hf_tags call could theoretically be paginated for repos with very many tags — list_repo_refs doesn't paginate (it returns all refs), so this is safe with the current huggingface_hub API. Just flagging for future awareness.
There was a problem hiding this comment.
Agreed on both counts. Keeping the simple form for now: the guard is the only call site and runs once per launch, and list_repo_refs returns all refs unpaginated in huggingface_hub 1.21. If a second call site appears we'll cache the tag list (the inference resolution path already reuses one list_hf_tags result).
| if force: | ||
| # --force-tag semantics: repoint the existing tag at the fresh commit rather than | ||
| # failing (delete errors are ignored — the tag may simply not exist yet). | ||
| with contextlib.suppress(Exception): | ||
| api.delete_tag(repo_id, tag=tag) | ||
| api.create_tag(repo_id, tag=tag) |
There was a problem hiding this comment.
There's a subtle race window between upload_folder and create_tag: if the process dies between these two calls, the commit exists on the Hub but is untagged. The next retry would then upload a duplicate commit and tag it, which is fine but leaves an orphaned commit.
More importantly, in the force=True path, delete_tag + create_tag is not atomic — a concurrent reader could see no tag between the two calls. Since this is single-user / single-run, it's not a real concern, but worth a comment noting the non-atomicity for the future.
Also: with force=False, if the tag already exists (e.g. a previous partial run committed but then successfully tagged), create_tag will raise. The startup _guard_hf_tag check should have caught this, but there's a TOCTOU gap (~30 h of training between check and upload). Might be worth a targeted try/except on create_tag with a clear error message pointing at --force-tag.
There was a problem hiding this comment.
Fixed in 8cf9832: create_tag now has a targeted 409-conflict handler that names the TOCTOU window and points at --force-tag (the artifacts stay uploaded, only the tag is missing), and the force path carries a comment documenting the non-atomic delete_tag -> create_tag sequence and why a crash between the calls is recoverable (the stage stays un-done in the run manifest, so a re-run recreates the tag). The orphaned-commit-on-retry case is accepted as-is — a duplicate commit on main is harmless and the tag lands on the newest one.
| paths = tuple( | ||
| _hf_hub_download(repo_id=repo_id, filename=filename, revision=revision) | ||
| for filename in (HF_ENCODER_FILENAME, HF_RF_FILENAME, HF_CONFIG_FILENAME) | ||
| ) | ||
| logger.info(f"Downloaded model artifacts from {repo_id}@{revision}") | ||
| return paths |
There was a problem hiding this comment.
Minor: download_inference_artifacts builds a tuple via a generator expression, which means the three downloads are strictly sequential. That's probably the right call (avoids hammering the Hub API), but worth noting that parallelizing them (e.g. via ThreadPoolExecutor) could shave a few seconds off cold-cache inference startup if this ever becomes a bottleneck.
There was a problem hiding this comment.
Keeping the downloads sequential: the trio is dominated by the encoder file, so parallelizing saves little, and cold-cache startup is a one-time cost per revision (later runs hit the local cache). Will revisit with a ThreadPoolExecutor if it ever shows up in startup timings.
| def _exit_on_collisions(tag: str, collisions: list[str], resume_hint: str) -> None: | ||
| """Log the collision list plus the user's options, then hard-exit.""" | ||
| logger.error("=" * 60) | ||
| logger.error(f"--save-tag '{tag}' collides with existing state from a previous run:") | ||
| for collision in collisions: | ||
| logger.error(f" - {collision}") | ||
| logger.error("Options:") | ||
| logger.error(" - pick a new --save-tag") | ||
| logger.error(f" - {resume_hint}") | ||
| logger.error(" - pass --force-tag to consciously override this guard") | ||
| logger.error("=" * 60) | ||
| sys.exit(1) |
There was a problem hiding this comment.
_exit_on_collisions calls sys.exit(1) directly. This is consistent with the module's documented contract ("hard-stop"), but it means the function is untestable without pytest.raises(SystemExit) — which the tests do correctly use. Just noting that returning a sentinel or raising a custom exception instead of sys.exit would make the guard testable without that ceremony and keep the exit decision at the call site. A minor style preference, not a blocker.
There was a problem hiding this comment.
Keeping sys.exit(1) here, with rationale: the guard's callers live in main.py's startup path, which already uses sys.exit(1) pervasively for fail-fast conditions (init failures, GPU strategy failures, resolution failures), and the guard logs its multi-line collision list + options at the failure site where the context lives. A custom exception whose only consumer immediately exits would add a type + call-site handling without changing behavior, and the existing tests' pytest.raises(SystemExit) is the standard idiom for exactly this contract. Happy to revisit if the guard ever grows a non-CLI caller.
| if args.command == "inference": | ||
| try: | ||
| resolve_inference_artifacts(args) | ||
| except Exception as e: | ||
| logger.error(f"Failed to resolve inference model artifacts: {e}") | ||
| sys.exit(1) |
There was a problem hiding this comment.
Good placement — resolving artifacts before apply_saved_config / validate_args is the right sequencing. One observation: if the HF download fails (network error, repo doesn't exist), the user gets a sys.exit(1) with the raw exception message. The error from resolve_hf_revision already includes actionable guidance (the three alternatives), which is great. But hf_hub_download failures from huggingface_hub itself can be opaque (HTTP 404, auth errors). Consider wrapping the download path in a try/except that re-raises a more helpful RuntimeError with "is the revision correct? does the repo carry the expected files?" guidance.
There was a problem hiding this comment.
Fixed in 8cf9832: both failure surfaces in the resolution path are now wrapped with operator guidance — download_inference_artifacts re-raises raw huggingface_hub errors as a RuntimeError covering revision/repo-id correctness, repo visibility / HF_TOKEN, and network reachability, and resolve_hf_revision wraps tag-listing failures the same way. Unit tests cover both wrappers.
| current=hf_repo_id, | ||
| message=f"--hf-repo-id must be a HuggingFace repo id of the form namespace/name, got {hf_repo_id!r}", | ||
| fix_kind="format", | ||
| ) |
There was a problem hiding this comment.
The _HF_REPO_ID_PATTERN check is a useful guardrail. Just noting that HuggingFace also allows repo IDs with dots and special characters in the namespace component — the current regex [A-Za-z0-9][A-Za-z0-9._-]* should cover all valid cases. Looks correct.
There was a problem hiding this comment.
Thanks for double-checking the character classes — agreed the pattern covers the valid namespace/name forms (dots, dashes, underscores), so no change needed.
- create_tag is wrapped in a targeted 409-conflict handler: a tag created between the startup dedup check and the post-upload create_tag (~30h window on a full-scale run) now surfaces as a clear error pointing at --force-tag, noting the artifacts are uploaded but untagged. - Raw huggingface_hub errors from the inference resolution path (tag listing + artifact downloads) are wrapped with operator guidance (revision/repo-id correctness, public repo / HF_TOKEN, network). - Comment documents the non-atomic delete_tag -> create_tag force path and why a crash between the calls is recoverable via the run manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Review addressed in 8cf9832 (all four items):
Affected tests + |
…led HF progress bars The live train smoke's hf_upload stage died with "'StreamToLogger' object has no attribute 'isatty'": the pipeline redirects sys.stdout/sys.stderr to loggers, and upload_folder's tqdm progress machinery probes the stream for interactivity. Two independent fixes so neither ever regresses alone: - StreamToLogger now answers the file-protocol probes like the non-interactive pipe it is: isatty() False, writable() True, readable() False, fileno() raising io.UnsupportedOperation (the io.StringIO convention, subclassing both OSError and ValueError). Any library probing the redirected streams benefits, not just HF. - hf_hub.py force-disables huggingface_hub's progress bars (disable_progress_bars()) before any upload/download call, so Hub transfers never depend on tty probing and don't spam the log with progress lines. The failure containment worked as designed on the live run: training completed, hf_upload was recorded failed in the run manifest, and the run exited nonzero with re-run guidance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Live round-trip completed on blpc3 — full evidence now in the PR body's Verification section. Highlights:
Full local suite after all fixes: 519 passed, 3 skipped. |
Add HF_TOKEN (HuggingFace Hub token, PR #130) alongside SLACK_BOT_TOKEN: - new row in the Tokens and API Keys table - a note on its opt-in upload role (train --hf-upload), public-repo downloads needing no token, .env sourcing, run_container.sh --env forwarding, and never logging it - a Token Rotation subsection mirroring the Slack one Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
SECURITY.md now documents |
|
@claude please perform a second-pass code review of this PR. Re-review the current diff for any remaining correctness bugs, security issues, or significant quality problems, and post your findings. If the diff is clean, please say so explicitly. (Final verification round before merge.) |
Second-pass code review — PR #130 (HuggingFace Hub integration)Verdict: the diff is clean. I reviewed all new/modified files for correctness bugs, security issues, and significant quality problems. No blocking issues found. Security ✅
Correctness ✅
Minor observations (non-blocking, no action needed)
Test coverage74 new test cases covering: revision selection/sorting, inference artifact resolution (all branches), upload staging/arg shapes/filenames, model card content, tag dedup guard matrix (train + inference × collision/clean/resume/force), StreamToLogger probes, stage machine semantics for the new LGTM — ready to merge. 🫡 |
Closes #128
Stacking note
This PR's base is
integration/pr0910-base— coordinator scaffolding that merges the finished train chain (stage machine + run manifest, #122/#126) and inference chain (streaming inference +inference_cadencesmanifest + viz suite, #120/#125, and PR-08's #129). Only the commits onfeature/huggingface-integrationare this PR's diff; review it against the integration base.What this adds (PLAN PR-10)
Dependency —
huggingface_hub>=1.21,<1.22inrequirements-container.txt+environment.yml(pip section). Version per SECURITY.md's Version Selection Policy: latest stable at pin time is 1.23.0 (2026-07-09); two minors back is 1.21 (1.21.0, 2026-06-25), which is newer than the newest ≥6-month-old release (pre-1.6, before 2026-01-12), so 1.21 is the target; no known advisory affects it. Clusters need a container image rebuild (singularity build aetherscan-ngc25.02.sif aetherscan.def) before production use. Pre-rebuild workaround (validated on blpc3):./utils/run_container.sh python -m pip install --user "huggingface_hub==1.21.0", then launch withSINGULARITYENV_PYTHONNOUSERSITE=— the NGC image setsPYTHONNOUSERSITE=1, which disables the user site the--userinstall lands in.Auth —
HF_TOKENvia the gitignored.env, forwarded byutils/run_container.sh(new entry in the env allowlist), documented in the README.envexample. Never logged. Downloads hit a public repo and need no token; only--hf-uploadneeds one.Config/CLI — new
HFConfig(repo_id=zachtheyek/aetherscan,upload_after_training= off,revision= None) +checkpoint.force_tag; flags: train--hf-upload/--no-hf-upload, both modes--hf-repo-id, inference--hf-revision, both modes--force-tag(tri-state BooleanOptionalAction like the other bool flags; effective default off).to_dict()updated; README CLI Reference regenerated verbatim;docs/CONFIG_AND_CLI.mdtables/audit counts updated.Training upload — new manifest-tracked
hf_uploadstage afterfinal_save: stages the four artifacts under stable names (vae_encoder.keras,vae_decoder.keras,random_forest.joblib,config.json) plus a generated model card (pipeline description, tag, config summary, val ROC AUC/AP fromrf_eval_artifactswhen available, library versions, GitHub link/citation), commit message = save_tag, thencreate_tag(save_tag). Creates the public repo on first upload (create_repo(exist_ok=True)). Non-critical: failure is logged + recorded in the run manifest, never fails the run; re-running the identical command retries just this stage, and re-running with the upload disabled clears a stale recorded failure. Hub progress bars are force-disabled andStreamToLoggeranswers file-protocol probes (isatty/writable/readable/fileno), so transfers never depend on tty probing of the redirected streams.Inference download —
--encoder-path/--rf-path/--config-pathbecome optional as an all-or-none trio. Resolution order: explicit local paths →--hf-revision→ latest semverv*tag → latestfinal_vXtag → error with guidance. Downloads are revision-pinned viahf_hub_download; the cached trio is written ontoargsbefore validation, so the existingapply_saved_config+ strategy-scoped model-load path runs unchanged, and the resolved revision lands inconfig.hf.revisionfor provenance.Tag dedup guards (fail-early) — enforced right before command dispatch (post-validation, post-DB-init):
vae_encoder_{tag}.keras,config_{tag}.json, or non-supersededtraining_statsrows hard-exit with the options listed — unless a resumable run-state manifest exists (same-tag retries keep working).--hf-upload, the save_tag is checked against existing repo tags at startup, not after ~30 h of training; a failed check (network) only warns.config_{tag}.json) or stale legacy-path DB rows collide; in-progress streaming runs (manifest rows, no config JSON) resume as before.--force-tagconsciously overrides (and moves the HF tag on upload).Verification
Unit suite —
PYTHONPATH=src pytest -m "not gpu and not cluster" -q→ 519 passed, 3 skipped (local arm64 venv, TF 2.17.1; baseline on the integration base was 445 passed). New tests cover upload arg shapes/staged filenames, revision resolution order, semver sorting, the dedup guard matrix, model-card content, hf_upload stage-machine semantics, StreamToLogger's file-protocol probes, and the progress-bar disabling —huggingface_hubfully mocked, no network in the suite.pre-commit run --all-filesgreen on every commit.Live round-trip on blpc3 (completed) —
huggingface_hub==1.21.0installed in the container via the pre-rebuild workaround above;HF_TOKENpresent in.env(never printed/logged).--hf-upload(5-GPU smoke config, fresh tagtest_v26): training completed; the first upload attempt exposed a real bug —'StreamToLogger' object has no attribute 'isatty'fromupload_folder's progress machinery probing the redirected stdout. Failure containment worked exactly as designed: the run finished,hf_uploadwas recorded failed in the manifest, and the process exited nonzero with re-run guidance. Fixed in 7cd19e1 (bothStreamToLoggerprobes and disabled Hub progress bars).zachtheyek/aetherscanPUBLIC withvae_encoder.keras,vae_decoder.keras,random_forest.joblib,config.json,README.md(generated model card with correct frontmatter + this run's config summary) onmain; tagtest_v26pointing at the main head.inference --hf-revision test_v26 --inference-files subset_test.csvresolved and downloaded the revision-pinned trio into the container's~/.cache/huggingface(HOME bind-mount caching confirmed working — noHF_HOMEoverride needed), fed the downloadedconfig.jsonthroughapply_saved_config, loaded the encoder insidestrategy.scope()from the cache path, and completed: 2 cadences, 94,667 snippets processed, 0 candidates, exit 0 (~22 min wall for the 2-cadence subset).test_v26deleted from the Hub viadelete_tag(repo + main-branch artifacts left in place; tags now[]); cluster scratch//tmp//dev/shmcleaned, repo back onmaster.AI disclosure
Implemented with Claude Code (Anthropic), end-to-end: design from the v1 plan, implementation, tests, live cluster validation, and this PR. Human-specified requirements and review by @zachtheyek.
🤖 Generated with Claude Code