Status: Spec for review — 2026-06-01. The Phase-C sub-stage that turns Rosalind's
already-real memory receipt into a contract on the bounded whole-genome variants path:
declare → predict → honor-or-refuse → verify. Builds directly on the Phase-B4 caller
(rosalind variants --index, merged — PR #19). Under the contract thesis in
docs/OPEN_PROBLEMS.md and the strategy reframe
(the contract is the shippable breakthrough; √t is the future Phase-D knob).
You declare a RAM budget;
rosalind plantells you before you commit a byte whether your whole-genome germline call will fit; the run honors that budget — fitting cleanly or refusing cleanly, never silently OOM-killed mid-job; androsalind verifyre-checks a deterministic, BLAKE3-stamped receipt proving the realized peak landed inside your budget and the VCF came from exactly these inputs.
Today (post-B4) the engine already measures and records peak memory — peak_rss_bytes() is a real
getrusage signal (util/rss.rs), and the manifest carries peak_rss_bytes + max_working_set_bytes
as BLAKE3-stamped canonical JSON (provenance/mod.rs, written at main.rs:1083-1094). But the budget
check is record-only (main.rs:1109-1120 computes budget.admits(peak_rss) then prints
"record-only, run completed"), there is no pre-run prediction for the streaming path, the
stdout path writes no manifest (main.rs:1097-1101), and the reported max_working_set_bytes is a
real under-count of true peak. Phase C closes exactly those four gaps. No incumbent ships this
four-property contract; it is unique without √t (which extends the same contract down to index
construction in Phase D).
For the bounded whole-genome variants drive (call/whole_genome.rs), true peak working set is the sum
of four terms. Phase C makes each one either exactly known or hard-bounded, so a prediction can be a
true upper bound and the realized high-water can be measured exactly.
| Term | Bytes | Knowable a priori? | How Phase C bounds it |
|---|---|---|---|
| Reference (current contig) | largest_contig_len |
✅ from index header | decode-then-move into the Arc — eliminates the buf+Arc double-copy at whole_genome.rs:63-64 (2× → 1×) |
| Active read set | D × max_read_len × per_base_cost |
hard cap at --max-depth D (deterministic); read length is the one residual assumption |
|
| Resident VCF rows | O(1) (writer buffer) | ✅ constant | stream each call to the writer as produced — no genome-wide row Vec; the only resident "rows" memory is the BufWriter's fixed buffer |
| Fixed overhead | const | ✅ | constant |
Two numbers fall out:
- Predicted (
rosalind plan, pre-run, from index header + declaredD+ assumed max-read-lenL): a true upper bound modulo the read-length assumption. - Realized (the receipt's high-water): exact — sampled from a corrected accountant that includes all four terms.
The honest guarantee. Depth is hard-capped (guaranteed), rows are streamed (guaranteed small),
reference is exactly the largest contig (1× after the move fix). The only residual assumption is max
read length — and --enforce fails loud post-run if realized > budget (a clean non-zero exit, never
a silent overrun). So the brand is "fits-or-tells-you up front, and proves the realized peak with a
receipt" — explicitly not "never refuses" (graceful degrade/spill needs the Phase-D ladder and is
out of scope here; without it --enforce can only refuse cleanly).
In (the variants --index path only):
- C1 — soundness fix. Make
max_working_set_bytesa true conservative upper bound; deterministic--max-depthcap; per-call streaming VCF flush; decode-then-move reference (2×→1×). Output-preserving by default (cap defaults off in C1). - C2 —
rosalind plan+--enforce. A pre-run streaming estimator; theplansubcommand; honor-or-refuse enforcement (refuse-up-front-on-predicted / fail-loud-post-run-on-realized). The--max-depthdefault (1000) lands here. - C3 —
rosalind verify+ receipt-on-stdout + CI contract gate. A parser for the canonical manifest; theverifysubcommand; always-persist-a-receipt; the CI contract suite.
Out (deferred, by design):
- Index-build enforcement. The build is O(reference);
rosalind index --memory-budget-mb/plan --referencestay advisory/record-only (enforcement waits for Phase-D sublinear construction). - Somatic.
call/pipeline.rscollects both column streams intoVecs — region-bounded, not whole-genome-bounded. A region-bound refactor is out of scope; the bounded contract is scoped to the germline--indexpath and the docs say so. - Graceful degrade / external-memory spill. The Phase-D √t/spill ladder.
--enforcehere refuses cleanly; it does not degrade. - On-demand
ref_base(shrinking the reference term to O(pileup window)) → Phase D/E. - Deterministic multithreading / thread-invariance. The engine is single-threaded; we do not claim thread-invariance (it would be vacuously true) until a parallel path + gate exist (Phase E).
Mirrors the B3/B4 pattern. Each sub-stage lands green (cargo test, cargo fmt --check, 0 warnings)
with its own plan + PR.
Phase C — memory as a verifiable contract (variants path)
├─ C1 soundness fix [the hard correctness core]
├─ C2 rosalind plan + --enforce
└─ C3 rosalind verify + stdout receipt + CI contract gate
The prerequisite for everything: until the modeled number is a true upper bound, any plan lies and the
brand inverts on first contact. C1 is output-preserving by default (the cap defaults to None); it
changes only the accounting, the reference copy count, and the row-flush plumbing.
PileupEngine::current_working_set() today sums only ref_to_read.len()*16 + 64 per active read + 256.
It omits (a) the engine's own self.reference bytes and (b) each active read's seq/qual bytes. Fix it
to count what is actually resident:
self.reference.len()(the decoded contig the engine holds),- per active read:
ref_to_read.len()*16(map) +seq.len()+qual.len()(the byte buffers) + a small per-read constant, - fixed engine overhead.
This is the single load-bearing correctness change. (It only ever grows the reported number — it cannot newly pass a budget it failed before.)
Add PileupParams.max_depth: Option<u32> (default None in C1 — no behavior change; C2 sets the CLI
default). When Some(D) and the active set already holds D reads, drop the incoming read and count
it under a new SkipCounts.over_max_depth. Because the source is (contig,pos)-sorted (and SliceSource
sorts on construction), "the first D reads to overlap a position" is deterministic ⇒ capped output is
deterministic and order-independent. Output changes only at sites deeper than D (the artifact
pileups) — standard caller behavior. Applied in advance_to/ingest.
Split the monolithic write_germline_vcf(writer, contigs, sample, &rows) into:
write_germline_header(writer, contigs, sample)— header +##contiglines (once),write_germline_row(writer, &row)— one record.
write_germline_vcf is retained as a thin wrapper (header + loop) for back-compat / the single-contig
path. Rows are produced in (contig,pos) order, so streaming them is byte-identical to the batch
write — the golden_vcf + determinism gates stay green.
Change the drive from returning (Vec<rows>, WorkingSet) to driving a row sink and returning the
true high-water working set:
pub fn call_germline_whole_genome<S: ReadSource>(
source: S,
ref_view: &ReferenceView,
contigs: &ContigSet,
pileup_params: PileupParams,
germline_params: &GermlineParams,
on_row: &mut dyn FnMut((Locus, u8, GermlineCall)) -> Result<(), CoreError>,
) -> Result<WorkingSet, CoreError>- decode-then-move:
decode_window(.., &mut buf)thenArc::<[u8]>::from(std::mem::take(&mut buf))(moves the allocation; no second copy). Reference term 2× → 1×. - each emitted
(Locus, ref_base, call)is passed straight toon_row(no genome-widerowsVec). - the returned
WorkingSetismax over contigs of (engine peak working set + resident-row bytes for the in-flight write batch)— a sound upper bound on realized peak.
API-shape decision: a sink callback, not an Iterator. It is bounded, trivial to retrofit, and a
clean substrate primitive in its own right (a consumer passes any sink: stream-to-VCF, collect,
featurize-into-tensors). An Iterator is more idiomatic but fights the borrow checker across the
per-contig reference-decode / contig-switch state machine; a .calls() iterator adapter over this sink is
an easy future addition for the front-door cookbook.
main.rs::run_variants_index (main.rs:1044-1101) calls write_germline_header once, then passes a sink
that write_germline_rows into the (file or stdout) writer; the returned WorkingSet feeds the receipt.
- Real-RSS soundness gate (
tests/): on a generated growing input with an explicit--max-depth D, assert realizedpeak_rssis flat as the BAM grows and the realized working-set ≤ the modeled bound. - Depth-cap determinism: capped output byte-identical across shuffled input order; sites >
Dare the only ones changed;over_max_depthcounted. - Accountant completeness:
current_working_setincludes reference + seq/qual (unit test on a known active set). - VCF byte-identity: header-split + streamed rows == the old batch write (extend
golden_vcf).
A sibling to genomics/index/report.rs::estimate_build_working_set, for the call path:
pub fn estimate_variants_working_set(largest_contig_len: u64, max_depth: u32, max_read_len: u32) -> WorkingSetpredicted = largest_contig_len (ref 1×) + D·L·per_base_cost (active) + writer_buf_const + fixed, using the
same per-base/per-read constants as the corrected C1 accountant (single source of truth — a shared
const/helper so the estimate and the realized accountant cannot drift). Needs only the index header
(contigs.iter().map(|c| c.length).max()) plus declared D/L — it never opens the BAM. Plus a pure
renderer render_variants_plan(estimate_breakdown, budget) -> String producing the multi-line
[FITS]/[REFUSE] breakdown.
plan --index <idx> [--max-depth D] [--max-read-len L] [--budget-mb B]→ predicts the variants peak (the flagship): reference / active / rows / fixed → predicted peak / budget →[FITS]/[REFUSE].plan --reference <fa> [--budget-mb B]→ reusesestimate_build_working_setfor the build (advisory; build is O(reference) and Phase-D enforces).--indexXOR--reference.- Output is pure-rendered + deterministic (testable, like
render_plan_line); exit 0 always (planning is advisory; refusal isvariants --enforce).
Replaces the record-only verdict (main.rs:1109-1120) when set; both checks require --memory-budget-mb:
- Pre-run:
estimate_variants_working_set(largest_contig, D, L)> budget → refuse, exit 3, actionable stderr ("declared B MiB; predicted peak P MiB @ max-depth D / max-read-len L; raise --memory-budget-mb, lower --max-depth, or drop --enforce"). No work performed. - Post-run: realized
peak_rss> budget → write the VCF and the manifest (recording the violation), then fail loud, exit 4. (The artifact + the proof-of-overrun are preserved; the exit code signals the violated contract.) - Without
--enforce: today's record-only behavior, exit 0 (back-compat).
A small ContractStatus / exit-code helper centralizes the codes (0 ok, 3 predicted-over-refused,
4 realized-over-completed) and the honest messages.
C2 sets the CLI --max-depth default to 1000 (always applied ⇒ the engine is bounded by default);
--max-depth 0 = uncapped opt-out (and then plan cannot promise a hard bound — say so). --max-read-len
defaults to a short-read value (e.g. 250) with a flag to raise it for long reads. This is the one
deliberate default-output change in Phase C; it is documented (changes calls only at >1000× artifact
sites) and motivated by the contract surface.
estimate_variants_working_set monotonic + no-overflow; the breakdown renderer [FITS]/[REFUSE];
plan --index / plan --reference subprocess tests; --enforce refuses up front (exit 3) on a tiny
budget with --max-depth; --enforce fails post-run (exit 4) on a budget below realized peak;
record-only path unchanged without --enforce.
Today the stdout branch writes no manifest, so "every run emits a receipt" is false on the default path.
Fix: persist a receipt whenever there is a destination — an explicit --manifest <path> (exact path) or a
<vcf>.manifest.json sidecar next to a -o VCF. REVISED during C3 implementation (2026-06-01): a
stdout run without --manifest writes no file but prints a notice on how to persist one — the
originally-specced "write a rosalind.variants.manifest.json sidecar in the cwd" was dropped because
silently writing an unrequested file into a pipe user's cwd pollutes their directory and races on a fixed
filename across concurrent stdout runs. This still kills the silent-no-receipt footgun (the notice is loud)
without the pollution; any run that wants a receipt uses -o or --manifest.
Extend the params written at main.rs:1083-1094 with memory_budget_mb (if declared), contract_verdict
(within/over/unset), enforced (bool), max_depth, max_read_len. A receipt then carries
everything verify needs without the user re-supplying flags.
Add RunManifest::from_canonical_json(&str) -> Result<RunManifest, ManifestError> — a small parser for the
fixed canonical shape we emit (not a general JSON parser), keeping deps lean (no serde_json in a
public surface) and matching the hand-rolled writer. Guarded by a serialize → parse → serialize == identity
property test over varied manifests.
verify --manifest <path> [--budget-mb B] — read-and-assert, no re-run:
- parse the manifest; re-hash each listed input/output file (BLAKE3, streamed via
blake3_file) and compare to the recorded digest → detects drift / proves "this VCF came from exactly these inputs"; - re-check recorded
peak_rss_bytes≤ recordedmemory_budget_mb(or the supplied--budget-mb); - exit 0 if all hold; non-zero with a per-check report otherwise. Missing files / hash mismatch / over budget each produce a clear, distinct message.
Extend tests/rss_budget.rs into a subprocess contract suite (style of tests/index_cli.rs):
- predicted envelope ≥ realized peak on a generated growing input;
- realized working-set flat as the BAM grows (bounded-by-coverage);
--enforcerefuses up front (exit 3);--enforcefails post-run on a tiny budget (exit 4);verifyround-trips a real run's manifest and catches a tampered output hash.
Scope = add these to the existing suite (which CI already runs). A full CI-matrix overhaul (macOS runners, clippy gate) stays Phase-E; clippy remains a Phase-E call under MSRV 1.72.
rosalind plan (--index <idx> | --reference <fa>) [--max-depth D] [--max-read-len L] [--budget-mb B]
rosalind variants --index <idx> --alignments <sorted.bam>
[--mapq-threshold N] [--quality-threshold Q]
[--max-depth D] [--max-read-len L]
[--memory-budget-mb M] [--enforce] [--manifest <path>] [-o out.vcf]
rosalind verify --manifest <path> [--budget-mb B]
variants --reference (single-contig) is unchanged; --enforce/--max-depth/--max-read-len/--manifest
are additive and back-compatible (absent ⇒ today's behavior, except the --max-depth default of 1000 from
C2).
- Byte-identical output preserved. The writer split + per-call streaming must not change VCF bytes
(gated by
golden_vcf+determinism). The depth cap is deterministic; capped output is reproducible and order-independent. - The estimate and the realized accountant share one set of constants (§6.1) so a passing
planand the realized receipt cannot silently diverge. - Branding is honest in every message and doc: "fits-or-tells-you / never silently OOM-kills," never
"never refuses." The README/issue-3/
lib.rspositioning fixes are tracked separately (front-door move), not in this spec.
Each of C1/C2/C3: cargo test green (full suite), cargo fmt --all -- --check, cargo build 0 warnings
(debug and release), no htslib types in any new public signature, and the sub-stage's own gates
(C1 §5.5, C2 §6.5, C3 §7.5). Final whole-branch review per the established workflow.
Index-build enforcement; somatic whole-genome bounding; graceful degrade / spill; on-demand ref_base;
deterministic multithreading / thread-invariance claims; out-accuracy-ing GATK/DeepVariant (the demo is a
memory/reproducibility contract win on SNVs, never an accuracy head-to-head).
- Working-set model: declared depth ceiling + deterministic cap (realized ≤ predicted by construction for the coverage term; read length is the residual assumption with the realized post-run check as the backstop).
- Scope: full contract on the variants path, decomposed C1/C2/C3; index build stays record-only; somatic out of scope.
- Row flush: stream each call to the writer (resident rows = O(batch)), via a sink callback on
call_germline_whole_genome(not a per-contig batch, not an iterator). - Exit codes:
3= predicted over budget (refused, no work);4= realized over budget (completed, contract violated). --max-depthdefault = 1000 (lands in C2;0= uncapped);--max-read-lendefault short-read (250).- Stdout runs write a cwd manifest sidecar by default;
--manifest <path>redirects. - Manifest parsing for
verify= a small hand-parser for the fixed canonical shape (noserde_json), round-trip property-tested.