Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Thanks for your interest. This page is the short version: how to build, test, li
## Prerequisites

- [rustup](https://rustup.rs/) - the Rust toolchain is pinned by `rust-toolchain.toml` (stable + rustfmt + clippy).
Stable 1.85 or newer is required: `sha2` 0.11 and its dependency chain (`digest`, `crypto-common`, `hybrid-array`) are edition 2024.
- [uv](https://docs.astral.sh/uv/) - manages the Python workspace and its single `uv.lock`.
torch is pinned to the CPU-only PyTorch wheel index in the root `pyproject.toml`, so `uv sync` installs the ~100MB `+cpu` build instead of the default Linux wheel and its ~3GB of NVIDIA CUDA libraries.
macOS wheels are unaffected (MPS still works); if you need CUDA locally, point the `torch` entry in `[tool.uv.sources]` at a CUDA index and re-lock, but don't commit that.
Expand Down
57 changes: 28 additions & 29 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ anyhow = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["clock"] }
inventory = "0.3"
sha2 = "0.10"
sha2 = "0.11"

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
32 changes: 29 additions & 3 deletions crates/a2d-run/src/rundir.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Run-directory layout and `manifest.json` lifecycle.

use std::fs;
use std::io::Read;
use std::path::Path;

use a2d_contracts::{Manifest, RunStatus};
Expand Down Expand Up @@ -68,14 +69,39 @@ pub fn reopen_run_dir(run_dir: &Path) -> Result<Manifest> {
Ok(manifest)
}

/// sha256 (hex) of the primary source safetensors file, for manifest provenance.
/// sha256 (lowercase hex) of the primary source safetensors file, for manifest
/// provenance. Read in fixed-size chunks, so peak memory does not grow with the
/// weights.
///
/// The digest string is a cross-language contract: eval re-derives it with
/// Python's `hashlib.sha256(...).hexdigest()` and refuses the AR baseline on
/// mismatch (`a2d_core/eval/likelihood.py`), so the encoding must stay
/// hexdigest-identical.
///
/// ponytail: hash the primary `model.safetensors`; upgrade to a header +
/// shard-manifest digest if big-model / sharded provenance gets expensive.
pub fn source_hash(model_dir: &Path) -> Result<String> {
let path = model_dir.join("model.safetensors");
let mut file = fs::File::open(&path).with_context(|| format!("opening {}", path.display()))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher).with_context(|| format!("hashing {}", path.display()))?;
Ok(format!("{:x}", hasher.finalize()))
// digest 0.11 dropped the `io::Write` impl on hashers, so stream the file by
// hand rather than reading a multi-GB safetensors blob into memory.
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = match file.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
return Err(e).with_context(|| format!("hashing {}", path.display()));
}
};
hasher.update(&buf[..n]);
}
// digest 0.11 returns a `hybrid_array::Array`, which has no `LowerHex` impl.
Ok(hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
15 changes: 15 additions & 0 deletions crates/a2d-run/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,4 +567,19 @@ printf '%s\n' '{"schema_version":"0.1.0","job_id":"test-job","seq":3,"ts":"2026-
);
let _ = fs::remove_dir_all(&base);
}

#[test]
fn source_hash_spans_read_chunks() {
// 200_000 bytes forces several passes of the 64 KiB read loop, so a
// chunking or hex-encoding regression fails here rather than silently
// invalidating manifest provenance of already-converted models.
let base = std::env::temp_dir().join(format!("a2d-run-test-{}", Uuid::new_v4()));
fs::create_dir_all(&base).unwrap();
fs::write(base.join("model.safetensors"), vec![b'a'; 200_000]).unwrap();
assert_eq!(
rundir::source_hash(&base).unwrap(),
"2287d207f24a941ff3b56c04c8a25ad56b63e3023207b3bb5b4ac0c9869d74be"
);
let _ = fs::remove_dir_all(&base);
}
}
7 changes: 4 additions & 3 deletions packages/a2d-worker-hf/src/a2d_core/eval/likelihood.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ def mdlm_bound(
Corruption (the t-schedule + masking, which drives the RNG) always runs over the FULL
chunk set, so the result is independent of ``eval_batch_size``; only the model forward is
split into sub-batches of ``eval_batch_size`` chunks (``<= 0`` => one forward over all
chunks). Per-sequence rows do not attend to each other, so a sub-batched forward is
numerically identical to the single-batch one - it just caps peak memory at the sub-batch
size instead of at ``max_eval_tokens`` (avoids OOM on a single giant forward).
chunks). Per-sequence rows do not attend to each other, so a sub-batched forward matches the
single-batch one up to float32 round-off (the batch dim changes the forward's GEMM shapes,
so the last bits of the logits can move) - it just caps peak memory at the sub-batch size
instead of at ``max_eval_tokens`` (avoids OOM on a single giant forward).
"""
from a2d_core.objectives.mdlm import MDLM

Expand Down
4 changes: 3 additions & 1 deletion packages/a2d-worker-hf/src/a2d_core/transform/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
At ``alpha=0`` the annealed patch reproduces base causality to the bit, so a correct
patch yields ``max_abs_diff == 0.0`` on CPU float32. The gate ALWAYS runs float32 on
CPU regardless of ``--dtype`` (Risk 2). Grow adds a logit COLUMN, so patched logits
are sliced to ``base_vocab`` before comparing (Decision 7 / Risk 3).
are sliced to ``base_vocab`` before comparing (Decision 7 / Risk 3) - and because that
column also widens the logit GEMM, some CPU kernels round the shared columns a couple
of ulps apart, which is why the verdict is ``<= tolerance`` and not ``== 0.0``.

This gate CANNOT prove the patch reaches the model's causality - a no-op seam that
leaves the model fully causal passes it too - so bidirectionality is proven separately
Expand Down
13 changes: 12 additions & 1 deletion packages/a2d-worker-hf/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,18 @@ def test_patched_at_alpha0_is_bit_identical_to_base(tiny_gpt2: Callable[..., Any
probe = torch.randint(0, base_vocab, (2, 8))
result = check_identity(base, patched, state, probe, base_vocab)

# Grow widens the tied lm_head from base_vocab to base_vocab+1 columns, so the two
# models run DIFFERENTLY SHAPED logit GEMMs; some CPU kernels round the shared columns
# a couple of ulps apart (2.2e-8 seen on x86 CI). That is the resize, not the patch, so
# the grown pair is held to IDENTITY_TOLERANCE and bit-exactness is asserted below at
# equal lm_head width, where the patch is the only difference between the two models.
assert result.passed
assert result.tolerance == IDENTITY_TOLERANCE
assert result.max_abs_diff <= IDENTITY_TOLERANCE
assert result.max_abs_diff == 0.0 # eager + fp32 is exact, not merely within tolerance

ungrown_state = AnnealState()
ungrown = tiny_gpt2(0)
ungrown.load_state_dict(base.state_dict())
install_anneal_patch(ungrown, ungrown_state)
exact = check_identity(base, ungrown, ungrown_state, probe, base_vocab)
assert exact.max_abs_diff == 0.0 # eager + fp32 is exact, not merely within tolerance
12 changes: 9 additions & 3 deletions packages/a2d-worker-hf/tests/test_likelihood.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,20 @@ def test_mdlm_bound_sub_batch_matches_single_batch(convert_setup: ConvertSetup)
single = mdlm_bound(model, tokenizer, mask_id, eval_batch_size=0, **kw) # one big forward
subbed = mdlm_bound(model, tokenizer, mask_id, eval_batch_size=4, **kw) # 8 sub-batches
tiny = mdlm_bound(model, tokenizer, mask_id, eval_batch_size=1, **kw) # one seq per forward
# Sub-batching changes the forward's batch dim, so fp32 kernels round the logits a few
# ulps apart; per-sequence nats are then summed in float64 in the same order, leaving only
# that round-off (1.7e-9 relative seen on x86 CI). A real splitting bug - wrong rows, wrong
# weights, resampled corruption - moves the bound by percent, so 1e-6 (~8 fp32 eps) still
# catches it while staying above what a shape-dependent GEMM kernel can do.
rel_tol = 1e-6
for other in (subbed, tiny):
assert math.isclose(
single.nats_per_token, other.nats_per_token, rel_tol=1e-9, abs_tol=1e-12
single.nats_per_token, other.nats_per_token, rel_tol=rel_tol, abs_tol=1e-12
)
assert math.isclose(
single.bits_per_token, other.bits_per_token, rel_tol=1e-9, abs_tol=1e-12
single.bits_per_token, other.bits_per_token, rel_tol=rel_tol, abs_tol=1e-12
)
assert math.isclose(single.std_error, other.std_error, rel_tol=1e-9, abs_tol=1e-12)
assert math.isclose(single.std_error, other.std_error, rel_tol=rel_tol, abs_tol=1e-12)
assert single.n_tokens == subbed.n_tokens == tiny.n_tokens
assert single.mc_samples == subbed.mc_samples == tiny.mc_samples

Expand Down