Read-only resource estimates

vq estimate consumes an existing vibe-qc dry-run .system manifest. It does not import or execute a submitted Python file, call a remote host, change a calculation, or submit a job. Estimates set no resource limit or placement on their own; the opt-in flags below are the only way one becomes a limit.

vq estimate calculation.system --input calculation.py
vq estimate calculation.system --input calculation.py --json
vq estimate calculation.system --input calculation-dir --json
vq estimate calculation.system --features features.json --memory-mb 4096 --json

Obtain the manifest using vibe-qc’s existing dry-run/estimate workflow in an execution context where that calculation is authorized. Dry-run environment variables are not a sandbox. This command deliberately provides no interpreter or executable-preflight option. Each --input names a regular file or a directory. A directory stands for every regular file under it, in relative-path order, each opened without following a link. Links and special files under it are left out and counted in a warning, because a vq submit -d payload carries a link as a link. At most 64 files are fingerprinted after expansion, each at most 64 MiB, and a directory walk visits at most 10,000 entries. A directory with no regular file, or one that cannot be read completely, is an error rather than a smaller fingerprint. QVF members are not discovered: a .qvf input is fingerprinted as one file. The manifest is limited to 1 MiB and must have [outputs].status = "dry_run".

The versioned vq.resource-estimate/1 JSON receipt contains the program version, observed features, input/evidence SHA256 digests, memory semantics, an explicit override, unknown fields and limitations. An absent estimate stays unknown. Invalid values, contradictory evidence and non-dry-run manifests are errors. Existing submission/preflight and host-picking behavior is unchanged.

Quantity

Behavior

Memory bytes

Preserve [memory].estimate_bytes exactly, or take the complete analytical peak when the manifest has one (analytical allocations)

Safety margin

Already included by vibe-qc; never multiply by 1.2 again

Raw memory and margin factor

From analytical allocation evidence; unknown for a manifest without it

Memory scope

Unknown unless declared as job, node or rank, by the product or a features file

MPI memory

No inferred multiplication/division by rank count

Basis-function count

Only explicit evidence, resolved by the product or given in a features file; never guessed from atoms or a basis name

Runtime, scratch and recommended CPU layouts

Unknown until appropriate models are available

Explicit --memory-mb

Authoritative job limit in MiB, retained even below a job estimate

Calibration

Uncalibrated; no measured confidence or guaranteed upper bound

Optional features.json supplies missing operator evidence. It is attributed separately in the receipt and cannot contradict the method, basis or task type reported by the manifest. For example:

{
  "schema_version": 1,
  "nbf": 240,
  "basis_representation": "spherical",
  "algorithm": "direct",
  "mpi_ranks": 1,
  "threads_per_rank": 8,
  "memory_scope": "job"
}

These numbers illustrate the format, not a predicted calculation. Supplying an algorithm or Nbf does not create a new analytical model or modify the calculation. The program’s route-specific estimate remains authoritative. For a rank/node estimate, effective_job_memory_bytes stays unknown unless the user supplies a job limit. For a job estimate it uses the program value, unless overridden.

The input digest changes when the manifest, feature evidence or supplied input bytes change. A manifest without product evidence does not bind its estimate to the calculation’s inputs: the caller supplies that association. Do not use this receipt as an automatic admission decision or a validated cache hit. The receipt projects known scientific fields and hashes; it does not copy the manifest’s host/environment sections into the output.

Evidence the dry run records

A current vibe-qc dry run can add two tables to the manifest. Both are optional. A manifest without them is read exactly as before, and its receipt has the same shape as before.

Which inputs the dry run read

A [resource_evidence] table (vibeqc.resource-evidence/1) lists the SHA-256 of each input the dry run read through vibe-qc’s evidence reader, and the resource features vibe-qc resolved while setting up the calculation:

[resource_evidence]
schema = "vibeqc.resource-evidence/1"
input_coverage = "explicit_reads"
input_sha256 = ["<sha256 of water.py>"]
nbf = 24

With it, vq estimate checks the inputs you name:

  • When the --input files have exactly those digests, the receipt’s input_binding is product_observed_inputs: the estimate was made from these files.

  • A missing, extra or changed input is refused: product input digests disagree with the supplied input bytes. Make a new dry run from the files you will submit.

  • An empty digest list never strengthens the binding.

explicit_reads is a narrow claim. It covers the reads made through vibe-qc’s reader, not every file a Python input opens or imports, so the receipt lists unobserved_input_reads among its unknowns.

Each resolved feature uses the features.json names above, and feature_provenance records where each one came from: product_resolved, manifest_plan or operator_file. A features file may add what the product left out, but it cannot contradict a resolved value: feature nbf contradicts the program manifest. A dimension the product did not resolve stays unknown. A resolved occupied or virtual count can be zero. Every other count is positive.

Analytical allocations

An [analytical_memory] table (vibeqc.analytical-memory/1) breaks the memory estimate into vibe-qc’s own allocations. Each term names its route, its integer inputs, its scope (job, node or rank) and the domain of inputs it is valid for, and gives its size in bytes. The whole model has one scope, a headroom_factor, and either both peaks or neither:

  • Complete (complete = true): every allocation is modelled, so the model carries raw_peak_bytes and estimated_peak_bytes (the raw peak with vibe-qc’s headroom). The receipt’s memory.peak_bytes is the estimated peak, memory.raw_bytes the raw one, and memory.headroom_factor the product’s factor. vq applies no factor of its own on top.

  • Partial (complete = false): some allocations are not modelled and are listed as unknown. The model claims no peak, and the older [memory].estimate_bytes is not used in its place, so the receipt’s memory is unknown and vq estimate says so: Analytical allocations are incomplete; the legacy scalar is not used as a route peak. Each unmodelled allocation appears in the receipt’s unknowns as analytical_memory.<name>.

vq checks the evidence; it does not compute it. It refuses a term whose input lies outside its stated domain, a model whose scope contradicts the declared memory_scope, and a term input that contradicts a resolved feature. It never evaluates a formula, sums allocations across phases, or turns a per-rank figure into a job total. The terms are kept in the receipt’s analytical_memory, and they carry through vq submit --estimate and vq estimate-collect unchanged.

An older vq refuses a receipt that has these fields. Update the client that submits and collects before using such receipts, and do not strip the fields to get past the refusal. Version compatibility has the details.

Attaching an estimate to a job

vq submit --estimate RECEIPT keeps a vq estimate --json receipt with the job as its resource_estimate, so the estimate and what the job measurably used can be compared once it finishes:

vq estimate calculation.system --input calculation.py --json > calculation.estimate.json
vq submit localhost --estimate calculation.estimate.json -d . -- python calculation.py

Every input file the receipt fingerprinted (vq estimate --input) must be a file of the submission, by content: the submitted input file, a regular file anywhere under --dir, or a member of a --compressed archive. A link to it does not count. For a --dir submission, estimate with --input on the same directory. Otherwise the submission is refused before anything is written, because the estimate was made for other inputs. A receipt without --input fingerprints only its manifest, so nothing can be checked and it is attached as given. The search is bounded (20,000 files, 16 GiB) and a receipt is at most 256 KiB.

An attached estimate changes no resource, placement or admission: --mem-mb, --time and host picking behave as before. vq status shows an estimate: line, vq status --json the resource_estimate record, and vq resubmit or a reboot recovery keeps it. A remote host checks the receipt again against the payload it receives. A broker takes only a calibrated estimate, and only its runtime, to rank by (completion-time ordering). The submission’s idempotency intent includes the receipt only when one is attached.

vq estimate-collect OUT_DIR --hardware-class LABEL then builds the held-out set below from the local queue. Each finished job with an estimate becomes one case: estimates/<job>.json, the _vq/resource-usage.json its collector left as usage/<job>.json (no entry after a kill), its outcome from the job state, and its memory limit for an OOM kill or its wall limit for a time-limited run. An interrupted, starved or queue-aborted job has no evaluable outcome, and a scheduler array’s per-element receipts are not collected; both are listed as skipped. OUT_DIR must not exist or be empty, and is written whole or not at all. --set-id names the set (default: the directory’s name) and --json prints the summary.

vq estimate-collect held-out-2026-10 --hardware-class class-a
vq estimate-evaluate held-out-2026-10/set.json

Collect only after the set is frozen: jobs chosen by looking at their results are not held out.

Evaluating estimates on a held-out set

vq estimate-evaluate measures how estimates held up against what jobs actually used. Freeze the evaluation set before looking at the results. It pairs each estimate receipt with the job’s resource-usage.json receipt, its outcome and its hardware class:

vq estimate calculation.system --input calculation.py --json > case-01.estimate.json
# after the job ran: copy its _vq/resource-usage.json next to the set file
vq estimate-evaluate held-out.json
vq estimate-evaluate held-out.json --json
{
  "schema_name": "vq.estimate-evaluation-set/1",
  "set_id": "held-out-2026-10",
  "cases": [
    {"case_id": "case-01", "estimate": "case-01.estimate.json",
     "usage": "case-01.resource-usage.json", "outcome": "completed",
     "hardware_class": "class-a"},
    {"case_id": "case-02", "estimate": "case-02.estimate.json", "usage": null,
     "outcome": "oom_killed", "hardware_class": "class-a",
     "memory_limit_bytes": 8589934592, "memory_limit_scope": "node"}
  ]
}

Paths are relative to the set file. outcome is one of completed, failed, oom_killed, time_exceeded and killed. Give the job’s memory or wall-time limit when it was killed for exceeding it. Both direct (vq.direct-resource-usage.v1) and scheduler (vq.scheduler-resource-usage.v1) receipts are read.

memory_limit_scope declares whether memory_limit_bytes applies to the whole job, one node or one rank; use unspecified when unknown. Omitting it retains the existing total-job meaning. An OOM kill at a limit at or above an estimate refutes that estimate only when the limit covers the same or a narrower scope. For example, four ranks each using 600 MiB can exceed a 2 GiB job limit while staying below a 1 GiB per-rank estimate. That kill alone leaves the per-rank estimate censored. A rank limit can refute a rank, node or job estimate; a node limit can refute a node or job estimate. Unknown scopes remain censored unless measured peak RSS exceeds the estimate. No multiplication or division by ranks or nodes is inferred.

For a receipt with collector status: "ok", command_status must be succeeded or failed, and command_exit_code must be an integer. Success requires exit code zero; failure requires a nonzero code. A case marked completed cannot use a failed-command receipt: the evaluator reports the case and receipt error before producing a report. Correct the case’s outcome or supply its matching receipt instead of treating a failed run as successful calibration evidence. Nonzero exits remain valid observations for OOM, time-limit and kill outcomes, with the censored semantics below. A successful command can still have a failed case outcome when workload acceptance fails. Collector errors remain excluded.

Measurement fields must be finite, non-negative numbers (or null when unknown). NaN, infinities and values that overflow numeric conversion are receipt errors, never measurements or successful coverage. Runtime estimate intervals likewise require finite, non-negative bounds in increasing order; equal bounds, including zero, are allowed. Errors identify the case, receipt type and offending field before any evaluation report is produced.

A receipt’s peak RSS is the largest single process (ru_maxrss), not a sum over concurrent processes. Each case’s memory verdict follows from that:

Verdict

When

underpredicted

the measured peak exceeds the estimate; or an OOM kill at a same- or narrower-scope limit at or above the estimate

within

at or below the estimate, for a rank-scope estimate or a single-process run

not_refuted

at or below a job-, node- or unspecified-scope estimate, but the run had several processes or did not record how many

censored

an OOM kill, time limit or kill whose measurement does not decide it

unobserved

a completed job without a usage receipt

excluded

a failed run, or a collector error: not a measurement

no_prediction

the estimate has no memory figure

Runtime intervals are checked the same way against the measured wall time: covered, above (including a time-limited run whose limit exceeded the interval), below, censored, unobserved, excluded, no_prediction. Current vibe-qc manifests carry no runtime, so expect no_prediction there.

Per method and hardware class, and overall, the report gives the sample size, every verdict count, the measured/predicted ratio (median, maximum) and the underprediction frequency as a range. The lower bound counts decided underpredictions only. The upper bound adds every case that could still be one (not refuted, censored or unobserved). Runtime coverage is reported the same way. Nothing is tuned after seeing the data; thresholds, if any, are fixed beforehand (below). The report records the set’s digest and every receipt’s digest, so a published number names exactly the data behind it. It claims nothing beyond those estimators, programs, methods and hardware classes.

By route, against fixed thresholds

The JSON report also has route_groups, keyed by method, algorithm and hardware class, with the same summary as each group. Two routes of one method, such as density-fitted and direct SCF, need very different memory, so they are judged apart here.

To decide whether each route is good enough, write the thresholds down before the set is seen, and pass them with --thresholds:

{
  "schema_name": "vq.estimate-thresholds/1",
  "description": "agreed before held-out-2026-11 was collected",
  "max_underprediction": 0.05,
  "min_cases": 10,
  "basis": "raw"
}
vq estimate-evaluate held-out.json --thresholds thresholds.json

Each route group then gets an acceptance verdict from its underprediction bounds:

Verdict

When

insufficient

fewer than min_cases cases in the route group

fail

the lower bound exceeds max_underprediction

pass

the upper bound is at most max_underprediction

undecided

the bounds straddle the limit; undecided cases are never counted as correct

basis: "calibrated" judges the calibrated estimates instead and needs --calibration. The report records the thresholds and the document’s digest, and the text output lists one verdict per route. vq cannot prove when the thresholds were written: keep the document under version control, or publish its digest, before the set is collected.

Calibrating on a training set

A calibration is fitted on one set and judged on another that it never saw:

vq estimate-collect training-2026-10 --hardware-class class-a
vq estimate-calibrate training-2026-10/set.json -o calibration.json --min-cases 5
vq estimate-evaluate held-out-2026-11/set.json --calibration calibration.json

vq estimate-calibrate writes a vq.estimate-calibration/1 document. It groups cases by estimator, program, program version, method, algorithm, memory scope and hardware class, so a calibration only ever applies to the identity it was fitted on. Only decided evidence counts:

Evidence

When

exact ratio

observed/predicted peak where the measurement compares: a rank-scope estimate, or a single-process run (verdict within or a measured underpredicted)

lower bound

a multi-process run that measurably exceeded a job- or node-scope estimate (the job held at least its largest process), or an OOM kill at a comparable limit at or above the estimate (limit/predicted)

Not-refuted, censored, unobserved, excluded and no-prediction cases are counted and never used. A group’s multiplier is its worst ratio, never below 1, so a calibration never lowers a program’s estimate. When that worst case is only a lower bound, the document says so, and the true factor may be larger. A group with fewer decided cases than --min-cases (default 5) stays uncalibrated. Choose that number before fitting, never after looking at an evaluation. The document records the training set’s digest and every training case’s receipt digests. Its calibration_id is the digest of its own content, and a document whose content no longer matches it is refused. An existing output file is never overwritten.

Feature ranges

Each group, memory and runtime alike, also records feature_ranges: the smallest and largest value of every numeric feature its decided cases had, among nbf, naux, occupied, virtual, roots, kpoints, mpi_ranks and threads_per_rank. A multiplier fitted on 100 to 300 basis functions says nothing about 900, so an estimate is calibrated only when each feature with a range is present and inside it. One outside a range, or missing a feature its group has a range for, stays uncalibrated, and the reason names the feature, its value and the range. A feature no decided case had is not checked. A calibration fitted before ranges were recorded has none and checks nothing; refit it to gain them.

Runtime groups

The same document also calibrates runtime, in runtime_groups (method worst-observed-over-predicted-runtime-upper/1). These are grouped like the memory groups but without the memory scope, since wall time does not depend on it. The evidence is the measured wall time against the upper bound of the estimate’s runtime interval:

Evidence

When

exact ratio

a completed run with a measured wall time (wall/upper), whether it was covered, below or above the interval

lower bound

a run stopped at its time limit that ran past the upper bound: the larger of its wall time and wall_limit_seconds, over the upper bound; or a run stopped another way (killed, OOM) after it ran past the upper bound (wall/upper)

Failed, excluded and unobserved runs, runs stopped before reaching the upper bound, estimates without a runtime interval, and intervals whose upper bound is zero are counted and never used. As for memory, the multiplier is the worst ratio, never below 1, flagged when it is only a lower bound, and --min-cases applies. Only the upper bound is scaled; the lower bound is left as it is. A calibration fitted before runtime groups existed loads with none.

Judging on held-out cases

vq estimate-evaluate --calibration keeps every raw verdict and adds, per case:

  • calibrated_memory, the verdict for the estimate times its group’s multiplier;

  • calibrated_peak_bytes;

  • calibration_multiplier;

  • calibrated_runtime, the runtime verdict for the interval with its upper bound times the case’s runtime-group multiplier;

  • calibrated_runtime_upper_seconds;

  • runtime_calibration_multiplier;

  • calibration_out_of_range and runtime_calibration_out_of_range, the reason when the case lies outside its group’s feature ranges, or null.

A case whose group is uncalibrated, or absent, or whose features lie outside the group’s ranges, reads uncalibrated. Each summary gains memory.calibrated with its underprediction bounds over the calibrated cases and the number of uncalibrated ones, and runtime.calibrated with its coverage bounds in the same way. The report gains a calibration section naming the document. The text table adds CALIBRATED UNDER, UNCALIBRATED (memory) and CALIBRATED COVERED columns. The evaluation refuses the calibration’s training set itself, and any case whose receipts are also a training case, so a calibration is always judged on held-out cases. Without --calibration the report is unchanged.

A multiplier is the worst ratio seen in training, not a confidence bound. A calibration changes no receipt, admission or placement. Only --mem-from-estimate and --time-from-estimate (below) turn a calibrated figure into a limit, and only when asked to.

Applying a calibration

Once a calibration has held out well, apply it to new estimates:

vq estimate calculation.system --input calculation.py \
    --calibration calibration.json --hardware-class class-a --json > calibrated.json
vq submit localhost --estimate calibrated.json -d . -- python calculation.py

With --calibration (and the --hardware-class the job will run on), vq estimate --json prints a vq.calibrated-estimate/1 instead of the bare receipt. It holds:

  • the vq.resource-estimate/1 receipt, unchanged, and its digest;

  • the calibration’s id, method and training set, and the hardware class;

  • the matching group’s multiplier, and whether that is a lower bound;

  • calibrated_peak_bytes, the raw peak times the multiplier, rounded up;

  • the runtime part, from the matching runtime group: runtime_status, runtime_multiplier and whether it is a lower bound, and calibrated_runtime_upper_seconds, the raw upper bound times that multiplier. The raw interval in the receipt is unchanged.

When nothing applies it says status: uncalibrated with the reason: the estimate has no memory figure, no group matches it, its group had too few cases, or a feature lies outside the group’s observed range. The runtime part is likewise uncalibrated with a runtime_reason: no runtime interval, a zero upper bound, no matching runtime group, too few cases, or a feature outside its range. A document written before the runtime part existed still loads without it. The text output adds Calibrated memory: and Calibrated runtime lines.

vq submit --estimate accepts either document. For a calibrated one:

  • the document is checked: its receipt’s digest, the peak and multiplier arithmetic, and a status consistent with them;

  • the receipt is checked against the payload exactly as before and recorded as resource_estimate;

  • the rest is recorded as resource_estimate_calibration.

vq status adds the calibrated figure to the estimate: line (or why there is none). Resubmission and reboot recovery keep it, and the idempotency intent includes it. vq estimate-collect still writes the raw receipt, so a later calibration is fitted on raw estimates. On its own, a calibrated figure, like a raw one, sets no --mem-mb, admission or placement.

A memory limit from a calibrated estimate

--mem-from-estimate opts one submission in to taking its memory limit from its calibrated estimate:

vq submit localhost --estimate calibrated.json --mem-from-estimate -d . -- python calculation.py

The limit is calibrated_peak_bytes rounded up to MiB, exactly as if it had been given with --mem-mb. It is derived before the target is resolved, so vq submit auto places the job by it. It is refused unless:

  • the estimate is a vq.calibrated-estimate/1 whose status is calibrated;

  • its memory scope is job, because --mem-mb is a job total; under Slurm’s per-node --mem a job-scope limit is conservative;

  • no --mem-mb is given. An explicit limit stays authoritative, and giving both is a usage error.

The job records mem_mb_source: "calibrated-estimate". Where the job is created (on a remote host too), the limit is checked against the recorded calibrated peak. vq status shows a mem_mb: line naming the source. Resubmission keeps the source unless --mem-mb overrides the limit, and reboot recovery keeps it. Without --mem-from-estimate, no estimate changes a limit.

A wall-time limit from a calibrated estimate

--time-from-estimate does the same for the wall-time limit:

vq submit localhost --estimate calibrated.json --time-from-estimate -d . -- python calculation.py

The limit is calibrated_runtime_upper_seconds rounded up to whole minutes, exactly as if it had been given with --time-limit. It is refused unless:

  • the estimate is a vq.calibrated-estimate/1 whose runtime_status is calibrated;

  • neither --time-limit nor --wall-time-seconds is given. An explicit limit stays authoritative.

The job records wall_time_source: "calibrated-estimate", which is checked against the recorded calibrated bound where the job is created (on a remote host too). vq status shows a wall_time: line naming the source. Resubmission keeps the source unless --wall-time-seconds overrides the limit, and reboot recovery keeps it. The two flags are independent, and one estimate can set both limits. A calibrated upper bound is still the worst ratio seen in training, so a run can exceed it; the job is then stopped as TIME_EXCEEDED like any other limit.

Next increments

Still open: input coverage beyond vibe-qc’s explicit reads, the remaining resolved dimensions and analytical models for more routes, QVF member adapters, and letting a calibrated estimate inform placement beyond its memory limit. Runtime prediction must exclude queue delay. Basis count alone is insufficient to compare direct, dense, fitted and periodic algorithms. No predictive accuracy or optimization speedup is claimed.