build(a2d-run): port source_hash to sha2 0.11 / digest 0.11 traits - #20
Merged
Conversation
sha2 0.11 rides the breaking digest 0.11 trait release, which drops two
things `source_hash` relied on:
- digest 0.11 removes the `std` feature entirely, so hashers no longer
implement `io::Write` and `std::io::copy` no longer compiles. Replaced
with an explicit 64 KiB read loop rather than slurping a potentially
multi-GB `model.safetensors` into memory.
- `finalize()` now returns a `hybrid_array::Array`, which has no
`LowerHex` impl, so `format!("{:x}", ..)` no longer compiles. Encode
the bytes directly.
Hash output is unchanged: `source_hash` still emits a lowercase 64-char
hex digest. That matters because it is durable provenance written to
`manifest.source_hash`, and the Python eval worker re-derives it with
`hashlib.sha256().hexdigest()` and refuses to trust the AR baseline on a
mismatch (`eval/likelihood.py`). Both Rust expectations are pinned
against values computed by hashlib, so the cross-language contract is
anchored by an independent implementation. Added a 200_000-byte case so
the new multi-pass read loop is covered, not just a single-chunk input.
Only one `digest` major is linked (0.11.3); `generic-array` drops out of
the lock entirely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Port a2d to sha2 0.11.0, replacing dependabot PR #3 (#3), whose naive version bump fails the rust CI job. Despite the minor-looking version number, sha2 0.11 rides the breaking digest 0.11 trait release, so this is a real port rather than a lockfile bump.
Two compile breaks had to be fixed in crates/a2d-run/src/rundir.rs source_hash(), and both were reproduced locally first against the same errors CI reported on #3:
Hash-output compatibility was the risk that actually mattered here and was verified, not assumed. source_hash feeds manifest.source_hash, which is durable provenance, and the Python eval worker independently re-derives it with hashlib.sha256().hexdigest() and refuses to trust the AR baseline on mismatch (packages/a2d-worker-hf/src/a2d_core/eval/likelihood.py). Output is unchanged: still a lowercase 64-char hex digest. The pre-existing sha256("abc") test was intentionally left untouched because its passing unmodified IS the before/after compatibility proof.
One test was added: source_hash_spans_read_chunks in crates/a2d-run/src/worker.rs, feeding 200_000 bytes so the new multi-pass read loop is actually exercised, since the existing 3-byte case only ever runs one iteration. Both expected digests were computed with Python hashlib, an independent implementation, so the cross-language contract is anchored rather than self-confirming.
Also checked, per the task: only one digest major is linked (0.11.3), crypto-common is 0.2.2 only, and generic-array drops out of Cargo.lock entirely, so there is no dual-major situation to resolve.
Context on this being a re-run: an earlier run (01KYQ2DC2W4VMG5RJAJ4D3GBG0) on this exact commit was cancelled mid-flight when the shared no-mistakes daemon was terminated during a container OOM sweep - it was not a test failure, and no fix commits were produced, so the branch is unchanged at 781267f. That cancelled run's review had completed and raised two latent, non-blocking notes, recorded here so this round has the context rather than rediscovering it cold: (a) rundir.rs - the hand-rolled read loop drops the ErrorKind::Interrupted retry that std::io::copy does internally, currently unreachable because the workspace installs no signal handlers, but it would become a spurious failure on a multi-GB hash the day a Ctrl-C/cancel handler lands; (b) Cargo.toml - the bump raises the effective MSRV to 1.85 because hybrid-array, crypto-common and digest are edition 2024, which is harmless today since rust-toolchain.toml pins channel = stable and no crate declares rust-version. Treat both as open questions for this review rather than as settled, and flag them if you disagree.
Constraints the reviewer should know: the change is deliberately kept small (35 insertions, 3 deletions excluding Cargo.lock, well under a 500-line budget); no GPU or accelerated validation is possible on this box (aarch64, no Metal/MPS/CUDA); and this ships on its own branch fm/a2d-dep-sha2-t2 rather than being pushed to the dependabot branch, which is intentional - firstmate closes #3 as superseded once this lands.
What Changed
sha2dependency from 0.10 to 0.11, which rides the breakingdigest0.11 release:digest0.11.3,crypto-common0.2.2 andhybrid-array0.4.13 enterCargo.lockandgeneric-arraydrops out entirely.source_hashincrates/a2d-run/src/rundir.rsfor the new traits.digest0.11 dropped the hasherio::Writeimpl, sostd::io::copyis replaced by an explicit 64 KiB read loop (with anErrorKind::Interruptedretry arm) that keeps peak memory flat on multi-GBmodel.safetensors;finalize()now returns ahybrid_array::Arraywith noLowerHeximpl, so the digest is hex-encoded inline withformat!("{b:02x}"). Output stays a lowercase 64-char hex digest, keepingmanifest.source_hashidentical to thehashlib.sha256().hexdigest()the Python eval worker re-derives.source_hash_spans_read_chunksincrates/a2d-run/src/worker.rs(200,000 bytes, so the multi-pass read loop is actually exercised - the pre-existing 3-byte case only ran one iteration), documented the cross-language digest contract onsource_hash, and recorded the resulting stable-1.85 floor inCONTRIBUTING.mdsince the new dependency chain is edition 2024. Norust-versionkey was added, so the floor is prose-only and cargo does not enforce it.Risk Assessment
✅ Low: Tightly scoped dependency port with one call site, an independently verified hash-output contract (both pinned digests reproduce under Python hashlib), a new test covering the multi-pass read path, and the only prior finding (missing EINTR retry) already fixed in 0d3ea1a.
Testing
Baseline
cargo test --workspaceis fully green (48 tests) and the two Python eval test files that carry source_hash pass. Beyond unit tests, I drove the actual product surface:a2d converton a 5 MiB+1 byte weights file (so the new 64 KiB read loop makes ~81 passes including a partial tail) and captured the resulting manifest.json, then proved the durable-provenance digest is byte-identical to a locally rebuilt pre-port sha2 0.10 CLI binary, to GNU sha256sum, and to Python hashlib, and that the real downstream gate in the Python eval worker accepts the Rust hash and rejects it after a single flipped byte. The new chunk-spanning test also passes against the base sha2 0.10 code, which makes its expected digest a genuine before/after invariant. The only part I could not exercise is the ErrorKind::Interrupted retry - a regular-file read has no signal source in this environment and the workspace installs no handlers - and no GPU/accelerated path exists on this aarch64 box, neither of which this change touches. No product or test failures found.Evidence: a2d convert CLI transcript (end-user run that writes manifest.source_hash)
$ a2d convert /tmp/a2d-sha2-e2e/model --out /tmp/a2d-sha2-e2e/run --data ./fixtures/data/tiny.jsonl --max-steps 10 --anneal-steps 5 --worker-cmd "sh /tmp/a2d-sha2-e2e/fake_worker.sh" job started (worker: fake-worker) identity gate: PASS (max_abs_diff=0.00e0, tolerance=1e-6) job completed run 285ccef1-01a0-461a-baac-e57cce25412b completed -> /tmp/a2d-sha2-e2e/run exit=0Evidence: manifest.source_hash vs coreutils sha256sum vs Python hashlib (5,242,881-byte weights)
== independent oracles over the same 5242881-byte model.safetensors == manifest.source_hash (a2d, Rust sha2 0.11) : 8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea sha256sum (GNU coreutils) : 8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea python hashlib.sha256().hexdigest() : 8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea len=64 lowercase-hex=yes MATCH: all three agreeEvidence: Before/after A/B: same file, same CLI, sha2 0.10 vs sha2 0.11 builds
== before/after A/B: same 5 MiB model.safetensors, same CLI, two sha2 majors == a2d built against sha2 0.10 -> manifest.source_hash = 8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea a2d built against sha2 0.11 -> manifest.source_hash = 8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea IDENTICAL: durable provenance unchanged across the port (binaries verified distinct: a2d(sha2 0.10) links digest-0.10.7 generic-array-0.14.7 sha2-0.10.9; a2d(sha2 0.11) links digest-0.11.3 sha2-0.11.0)Evidence: Python eval worker re-derives and gates on the Rust-written hash (with negative control)
eval worker re-derives manifest.source_hash=8bd83e68831344d036c3cd254d77fe6401fb907d4cdbbd853a2881b43557bbea trust AR baseline? reason = None negative control: one byte of model.safetensors flipped trust AR baseline? reason = 'source weights hash mismatch (source changed since conversion)' byte restored trust AR baseline? reason = NonePipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 2 issues found → auto-fixed ✅
crates/a2d-run/src/rundir.rs:84- The hand-rolled read loop that replacedstd::io::copydrops theErrorKind::Interruptedretry thatio::copyperforms internally, so an EINTR fromFile::readnow abortssource_hashwith a "hashing <path>" error instead of resuming. Unreachable today (the workspace installs no signal handlers - grep for signal/SIGINT/ctrlc across crates/ finds none), but it becomes a spurious failure on a multi-GB hash the moment a Ctrl-C/cancel handler lands, and is already reachable on EINTR-prone mounts (NFS soft mounts, FUSE). Restore parity with aErr(e) if e.kind() == std::io::ErrorKind::Interrupted => continuearm by matching onfile.read(&mut buf)instead of using?.Cargo.toml:14- The sha2 0.10 -> 0.11 bump pulls in digest 0.11.3, crypto-common 0.2.2 and hybrid-array 0.4.13, which are edition 2024 crates, so the effective MSRV becomes 1.85. Nothing in the repo declaresrust-version,rust-toolchain.tomlpinschannel = "stable", and.github/workflows/ci.ymlusessetup-rust-toolchain@v1with no version pin, so this is inert today - noting it so a future MSRV policy does not get set below 1.85 by accident.🔧 Fix: retry EINTR in source_hash read loop
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
cargo test --workspace(48 tests, all ok; includesworker::tests::source_hash_spans_read_chunksandsource_hash_of_primary_safetensors)Manual E2E CLI run:target/debug/a2d convert <model> --out <run> --data ./fixtures/data/tiny.jsonl --max-steps 10 --anneal-steps 5 --worker-cmd "sh fake_worker.sh"on a 5,242,881-bytemodel.safetensors(80 full 64 KiB reads + 1-byte final partial chunk), then readmanifest.source_hashout of the emittedmanifest.jsonCross-implementation digest check on the same file:manifest.source_hashvssha256sumvspython3 -c 'hashlib.sha256(...).hexdigest()'- all three identical, 64 chars, lowercase hexBefore/after A/B: temporarily checked out baseCargo.toml/Cargo.lock/rundir.rs(sha2 0.10), built a seconda2dbinary withcargo build --offline -p a2d-cli, restored the worktree, then ran both binaries over the same weights file - identicalmanifest.source_hash. Verified the binaries genuinely differ (grep -ashows sha2-0.10.9/digest-0.10.7/generic-array-0.14.7 vs sha2-0.11.0/digest-0.11.3)Ran the newsource_hash_spans_read_chunkstest against the base sha2 0.10 implementation (cargo test --offline -p a2d-run source_hash) to confirm its expected digest is a real before/after invariant rather than fitted to the new read loopDownstream consumer gate:uv run pythoncallinga2d_core.eval.likelihood.source_reason(model_path, manifest.source_hash)- returnsNone(AR baseline trusted), returns'source weights hash mismatch (source changed since conversion)'after flipping one byte at offset 3,000,000, returnsNoneagain after restoring ituv run pytest tests/test_eval_main.py tests/test_eval_report.py -q(5 passed) - the Python-side paths that carrysource_hashthrough eval request/reportLockfile audit:grep -n -A1 '^name = "digest"|"crypto-common"|"generic-array"|"sha2"|"hybrid-array"' Cargo.lock- digest 0.11.3 only, crypto-common 0.2.2 only, hybrid-array 0.4.13, generic-array absentMSRV latent-note check:rust-toolchain.tomlpinschannel = "stable", norust-versionin any Cargo.toml,.github/workflows/ci.ymlrust job uses that pin withcargo test --workspacegit status --porcelainafter the A/B checkout dance - worktree restored clean, scratch dir removedCargo.toml:14- The Rust 1.85 floor is now documented only in CONTRIBUTING.md prose. Declaringrust-version = "1.85"in the workspaceCargo.tomlwould make cargo enforce it instead of leaving a contributor on an older stable to hit a rawedition2024 is requirederror. That is a manifest/config change, not documentation, so it is out of scope for this pass - a human should decide whether to codify the MSRV or keep it prose-only alongside therust-toolchain.tomlstable pin.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.