Harness: tools/benchmark-campaign/offline_full_validation.py. Tests:
tools/benchmark-campaign/test_offline_full_validation.py. Addresses issue #34
and implements the frozen parity contract from issue #46.
Both Bitcoin Core 31.1 and bitcoin-rs construct chainstate from the same hash-pinned Core-framed archive. The comparator never reaches inside either node. A ratio is computed only after every custody and correctness gate passes. This repository does not claim a live C150 or Cmodern campaign: CI proves the harness with fixture nodes.
One offline-full-validation-config-v1 document binds every arm:
- Archive: Bitcoin Core
blk*.datframing only — 4-byte message start, 4-byte little-endian payload length, consensus-serialized block. No padding, stale-chain records, or backend-specific bytes. The comparator opens the fileO_NOFOLLOW, hashes it, and walks every record against the manifest. Trailing bytes, a magic mismatch, a length mismatch, or a header hash that does not match the manifest refuse the run before any child starts. Block hash is Bitcoin CoreCBlockHeader::GetHash: double-SHA256 of the 80-byte header, displayed little-endian. The helper is checked against the published mainnet genesis header (000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26ffrom CoreCMainParams), not only against synthetic fixture bytes hashed with the same algorithm. - Manifest:
core-framed-archive-manifest-v1names network, magic, inclusive height range, archive digest and size, and one packed entry per height (hash,offset,payload_length). Heights are contiguous. The packed records must consume the archive exactly. - Pinned corpus: after config load, the campaign copies archive and
manifest into a private campaign directory, re-hashes both, and
chmod's them
0o400. Every arm reads those pins. Before and after every child, the controller re-hashes both pins and checks device, inode, size, and mtime. A same-user child that chmod's and rewrites a pin fails closed. - Posture:
assume_validmust be false.txindex,blockfilterindex, andcoinstatsindexmust be off. Cache policy is the closed setprocess-cold/page-cache-unspecified. The sibling MuHash comparator requires hash-bound evidence before it will claim page-cache eviction; this harness does not enact eviction, so that policy string is refused rather than published as a posture the run did not take. - Certified state: height, best block, UTXO count, total amount in
satoshis, MuHash,
hash_serialized_3, body availability, and one-block disconnect readiness. Height and best block must equal the manifest tip. Body availability and disconnect readiness must be true. - Lifecycle:
freshtimes one process from spawn through durable clean exit.reopenthen runs an untimed reopen command against the same data directory and requires the same certified state. - Binaries: SHA-256 pinned.
command[0]is the{binary}placeholder. The controller copies the pinned program into a private arm directory, verifies the copy, strips owner-write (0o500), and re-hashes immediately before every spawn. Placeholders are{binary},{data_dir},{corpus_path},{manifest_path},{state_path}. The timed command must carry a known assume-valid-off token (-assumevalid=0,--assume-valid-height=0, or the two-token form with0) and must not carry a known index-on token (-txindex,-txindex=1,--txindex=true,-blockfilterindex,-coinstatsindex, and the same spellings with--and=1/=true). Reopen commands are not re-checked. The comparator does not parse the rest of either product's flag dialect.
Wall time is time.monotonic_ns from child creation through wait-for-exit.
It includes archive read, framing parse, validation, chainstate mutation,
persistence, flush, and shutdown. No stage is subtracted. CPU (utime+stime)
and peak RSS are sampled from /proc and reported separately; they are not
an alternate definition of speed.
The child must remain in the foreground and exit. Bitcoin Core commands
must not daemonize (-daemon=0) and should stop after import
(-stopafterblockimport=1, -assumevalid=0, -stopatheight=H). bitcoin-rs
commands must apply the same archive through height H with
--assume-valid-height 0, persist bodies and undo, publish the production
clean checkpoint, and exit nonzero if publication fails. After wait, the
process group must be empty and the comparator, running as a Linux child
subreaper, must own no leftover descendants. A fixture that forks a
sleeper and then exits 0 is refused; no result JSON is published.
_require_comparable runs before any statistics:
- Exactly 14 arms, two per pair, one Core and one bitcoin-rs.
- Alternation: even pairs Core-first, odd pairs bitcoin-rs-first.
- Archive and binary identities unchanged from the campaign pin.
- Each arm exited 0 (durable clean exit). Reopen arms additionally exited 0.
- Certified state equals the config expectation on both arms and the two arms agree with each other.
Any refusal raises ContractError, the process exits 2, and no result
JSON is emitted. Publication is atomic: bytes are written to an unnamed
O_TMPFILE inode, fsynced, and linked with linkat(AT_EMPTY_PATH). The
commit point is a successful link. Crash before the link leaves the
destination unchanged and is retriable; crash after the link (or a retry
against an existing name) is EEXIST and must not overwrite. Directory
fsync follows the link. This function does not retry.
offline-full-validation-result-v1 binds the config canonical hash, the
custody block (magic, network, height range, archive/manifest/binary
digests, posture, lifecycle), a correctness block (every gate true —
the document cannot exist otherwise), every arm's wall/CPU/RSS, exit code,
and certified state, per-role percentile summaries, and
candidate_over_core_p50_ratio. result_sha256 is the canonical hash of
the document without that field.
Raw argv is never published. Public command digests use the same category-only projection as the P2P loopback comparator.
python3 tools/benchmark-campaign/offline_full_validation.py \
--config <config.json> --output <result.json>
python3 -m unittest test_offline_full_validation # from tools/benchmark-campaign/
Tests use a two-block Core-framed archive and deterministic fixture nodes
that read the archive and write the certified state file. They prove the
harness, not live node performance. A live seven-pair campaign still needs
hash-pinned bitcoind and bitcoin-rs binaries plus an exported archive of
a corpus frozen by issue #42
(docs/contracts/campaign-corpora.md);
archive bytes are not stored in git.
Campaign ceilings: archive 1 TiB, manifest 512 MiB, 2 000 000 blocks. Those bounds admit the Cmodern corpus (genesis .. 709,635); they are not a promise to ingest the live full-tip chain (~760 GiB), which is not a product corpus under issue #42.
This is the processing-bound regime in CONCEPTS.md: blocks are local, wall is validation plus durable commit. It is not download-bound IBD. Historical 1.654× C150 numbers are not carried forward; a new ratio exists only after a gated result file is published against a frozen corpus.