Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cargo test -p apr-cli --test backend_refusal_case_table
52 changes: 51 additions & 1 deletion contracts/apr-backend-registry-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ equations:
- 'the printed selection names why (reason non-empty)'

proof_obligations:
- id: REG-OB-004
statement: "apr-cli turns a backend request into a Selection or a refusal by reading the registry, never cfg!; a forced accelerator that is not Ready refuses (exit 9 not-compiled, 14 not-Ready) and never resolves to cpu, and only the default may fall to cpu; the static guard keeps cfg!(feature=cuda|wgpu) backend reads at zero outside registry.rs; the served process reports the startup Selection as effective-config `resolved` (REG-12), and a forced accelerator whose RUNTIME attempt fell to cpu is refused before any output."
discharged_by: falsification_tests[2]
- id: REG-OB-001
statement: "cpu is always Ready and every kind of the fixed list is an entry; a non-Ready entry carries a non-empty reason and every entry a source (spec invariants i and iii)."
discharged_by: falsification_tests[0]
Expand Down Expand Up @@ -128,16 +131,63 @@ falsification_tests:
print the block without the `override:` lines -> fx11 and fx7 RED; accept a malformed
APR_RESERVE_BYTES as the default -> a_malformed_reserve_override_is_refused_by_name RED.

- id: REG-F-003
rule: resolution reads the registry, and a forced backend never downgrades (R-0b, #3002)
prediction: >-
apr_cli::registry::resolve_in over the R-0a fixtures — a_forced_accelerator_never_resolves_to_cpu
(one-cuda selects cuda), a_forced_accelerator_with_none_ready_refuses_and_never_downgrades
(cpu-only: --gpu/--backend cuda/--backend wgpu each Err with exit 9 or 14, never cpu),
cpu_and_no_gpu_and_default_resolve_to_cpu, the_default_takes_a_ready_accelerator_when_there_is_one,
every_backend_value_is_resolvable_on_every_fixture_and_forced_gpu_is_never_cpu: 5/5 PASS; and
scripts/check_backend_registry.sh --static finds zero cfg!(feature = "cuda"|"wgpu") backend reads
in crates/apr-cli/src outside registry.rs, over the call sites that now read the registry
(accel, dispatch, dispatch_analysis, bench, finetune, lib, serve); and GET /v1/effective-config
carries `resolved` {kind, device_index, device_uid, device_name, reason, discovered_at_unix,
basis, matches_loaded} — the startup Selection (REG-12) beside the residency-measured
compute_class, matches_loaded null on a model-less server
(effective_config_reports_the_startup_backend_resolution), and what the serve gate publishes
is read back field for field through realizar's own accessor
(the_startup_resolution_serve_publishes_is_the_one_effective_config_reports); and after a
generation, a forced accelerator whose runtime attempt fell to CPU is refused before any output
(BackendUnavailable, 14) while a default selection that fell to CPU prints a corrective
`selected: cpu (fallback …)` line — registry::after_generation, unit-tested, and CALLED by both
generating surfaces before the first output branch
(every_generating_surface_reconciles_what_ran_with_what_was_announced,
the_run_reconciliation_happens_before_the_first_output_branch).
test: 'cargo test -p apr-cli --test backend_refusal_case_table && bash scripts/check_backend_registry.sh --static && cargo test -p aprender-serve --lib effective_config_route_pp2 && cargo test -p apr-cli --lib registry:: serve::effective_config_publish_tests runtime_fallback_refusal_wiring'
if_fails: >-
a build that compiled no accelerator, or a host with none Ready, runs a forced --gpu on cpu
and reports success (aprender#2696) — the claim-1 defect
mutation: >-
drop the after_generation call from run_entry.rs print_run_output (the forced Err arm falls
through to cpu output, which is the 2026-09-07 defect) ->
every_generating_surface_reconciles_what_ran_with_what_was_announced RED naming `apr run`;
move that call below the --stream branch ->
the_run_reconciliation_happens_before_the_first_output_branch RED;
publish a constant `kind: "cpu"` instead of the resolved kind in serve::publish_backend_resolution ->
the_startup_resolution_serve_publishes_is_the_one_effective_config_reports RED;
make resolved_report() return None ->
effective_config_reports_the_startup_backend_resolution RED;
make the not-ready branch of resolve_in return cpu_resolved instead of an Err ->
a_forced_accelerator_with_none_ready_refuses_and_never_downgrades and
every_backend_value_is_resolvable_on_every_fixture_and_forced_gpu_is_never_cpu RED (observed);
put cfg!(any(feature = "cuda", feature = "wgpu")) back in accel.rs ->
check_backend_registry.sh --static RED naming the file (observed; it is row 10 of that
guard's own --self-test, run over a copy of the real crates/apr-cli/src).

non_goals:
- "Side-effect-free discovery is a PROCESS property (no files, no cache, no persistent allocation); reading free memory needs a driver context, so the CUDA factory creates and drops one CUDA context per device (cuCtxCreate + drop) during discovery — recorded here rather than hidden (review quorum 2026-09-06, lane 3)."
- "device_uid is vendor + normalised adapter name from BOTH APIs; if two APIs name one card differently the twins carry different uids and the reserve refusal does not propagate between them (lane 2). Every fixture and the four hosts name the card identically through cuda and wgpu; a host that does not is a dogfood finding, not a silent pass — distinct_devices() would count two."
- "Resolution: refusing --backend/--gpu/--device requests not in the Ready set, effective-config, zero cfg! reads in decisions — R-0b (#3002) adds REG-OB-004..006 and their tests to this file."
- "Resolution's remaining half — effective-config `resolved` == the Selection the launcher resolved (REG-12) — IS claimed here as of S3c (#3041), with the code and the read-back test that discharge it. What stays out: the PRE-generation refusal. apr-cli reconciles AFTER generation, so a forced accelerator that fell to cpu at runtime has already spent the cpu run when it is refused (realizar decides that fallback inside run_gguf_generate); refusing before the tokens are spent is #3042."
- "The non-hermetic fixtures FX-2/4/5/6/8/9 (real drivers, real cards, root): host-dogfood rows recorded as receipts on the named hosts, never CI claims (design quorum 2026-09-06)."
- "Elementwise / structured device kernels' capabilities (caps is a name list; cuBLAS-as-capability lands with R-0b's effective-config)."

binding_registry:
library: crates/aprender-compute/src/registry/mod.rs
case_table: crates/aprender-compute/tests/registry_case_table.rs
catalogue: crates/apr-cli/tests/registry_failure_catalogue.rs
resolution: crates/apr-cli/src/registry.rs
resolution_case_table: crates/apr-cli/tests/backend_refusal_case_table.rs
static_guard: scripts/check_backend_registry.sh # run bare + --self-test by scripts/guard_tree.sh (ci.yml guard-runner-labels)
ci_targets: "cargo test -p aprender-compute --test registry_case_table && cargo test -p apr-cli --test registry_failure_catalogue (ci.yml integration line)"
issue: "https://github.com/paiml/aprender/issues/2904"
152 changes: 130 additions & 22 deletions crates/apr-cli/src/accel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use crate::error::{CliError, Result};
/// True when this build carries a GPU backend that could honour a request.
#[must_use]
pub(crate) fn build_has_accelerator() -> bool {
cfg!(any(feature = "cuda", feature = "wgpu"))
// R-0b (#3002): the registry says what this build compiled; never `cfg!`.
crate::registry::build_has_accelerator()
}

/// Refuse an accelerator request this build cannot honour.
Expand All @@ -41,23 +42,61 @@ pub(crate) fn build_has_accelerator() -> bool {
///
/// # Errors
/// [`CliError::FeatureDisabled`] when `wants_accelerator` and the build has none.
/// R-0b "selected: always": resolve the request exactly as `apr run` / `apr
/// chat` honour it — GH-326 `--gpu` overrides `--no-gpu`, and `--gpu` also
/// overrides `--backend cpu` (that is what `effective_no_gpu` does downstream,
/// so the line must say the same) — announce the selection, and refuse a forced
/// accelerator this host cannot honour. Nothing can refuse a cpu request.
pub(crate) fn ensure_available_for(gpu: bool, no_gpu: bool, backend: Option<&str>) -> Result<()> {
let backend = if gpu {
backend.filter(|b| *b != "cpu")
} else {
backend
};
let no_gpu = no_gpu && !gpu;
let wants = gpu || matches!(backend, Some("cuda" | "wgpu" | "gpu"));
let asked = if wants {
asked_flag(gpu, backend)
} else if no_gpu {
"--no-gpu".to_string()
} else if backend == Some("cpu") {
"--backend cpu".to_string()
} else {
"default".to_string()
};
let req = crate::registry::Request {
gpu,
no_gpu,
backend,
layers_want_accelerator: false,
};
crate::registry::announce(&req, &asked).map(|_| ())
}

pub(crate) fn ensure_available(wants_accelerator: bool, asked: &str) -> Result<()> {
if !wants_accelerator || build_has_accelerator() {
if !wants_accelerator {
return Ok(());
}
Err(CliError::FeatureDisabled(format!(
"{asked} was requested, but this build has no GPU backend compiled in, \n\
so it would have run on CPU without telling you. On a 7B Q4_K_M \n\
model that is roughly a tenth of the decode rate and several seconds of \n\
extra latency to the first token (aprender#2696).\n\
\n\
Install a build that has one:\n\
\n\
\x20 cargo install aprender --features cuda # NVIDIA\n\
\x20 cargo install aprender --features wgpu # portable GPU backend\n\
\n\
Or pass --no-gpu to run on CPU deliberately."
)))
// R-0b: resolve the request the user typed against the registry. A forced
// kind that is not Ready refuses (FeatureDisabled when not compiled,
// BackendUnavailable when compiled but absent here); it never downgrades.
let req = request_from_asked(asked);
crate::registry::announce(&req, asked).map(|_| ())
}

/// The request behind the flag text a caller quotes back (`--gpu`,
/// `--gpu-layers`, `--backend <kind>`).
pub(crate) fn request_from_asked(asked: &str) -> crate::registry::Request<'_> {
match asked.strip_prefix("--backend ") {
Some(kind) => crate::registry::Request {
backend: Some(kind.trim()),
..Default::default()
},
None => crate::registry::Request {
gpu: true,
..Default::default()
},
}
}

/// Which flag the user actually typed, for quoting back.
Expand Down Expand Up @@ -112,23 +151,31 @@ mod tests {
/// this module passes with the call sites deleted.
#[test]
fn every_accelerator_surface_calls_the_refusal() {
let surfaces: [(&str, &str); 3] = [
("apr run (dispatch.rs)", include_str!("dispatch.rs")),
// S3b (#3041): run and chat call the REGISTRY-resolving entry point BY
// NAME. The old needle `accel::ensure_available` is deliberately not
// accepted: it is a substring of `ensure_available_for`, so a scan for
// it passed identically before and after this slice and witnessed
// nothing. serve keeps its own named wrapper.
let surfaces: [(&str, &str, &str); 3] = [
(
"apr run (dispatch.rs)",
include_str!("dispatch.rs"),
"accel::ensure_available_for(",
),
(
"apr chat (dispatch_analysis.rs)",
include_str!("dispatch_analysis.rs"),
"accel::ensure_available_for(",
),
(
"apr serve (commands/serve/mod.rs)",
include_str!("commands/serve/mod.rs"),
"ensure_accelerator_available(config)?",
),
];
let mut missing = Vec::new();
for (name, src) in surfaces {
// serve keeps its own named wrapper; run and chat call accel directly.
let guarded = src.contains("accel::ensure_available")
|| src.contains("ensure_accelerator_available(config)?");
if !guarded {
for (name, src, needle) in surfaces {
if !src.contains(needle) {
missing.push(name);
}
}
Expand All @@ -140,6 +187,67 @@ mod tests {
);
}

/// R-0b/S3b: NOTHING that asks for CPU may be refused, in any spelling.
/// `ensure_available_for` is now the one preflight `apr run` and `apr chat`
/// share, so a regression here is a refusal on a plain `apr run model.gguf`.
#[test]
fn a_cpu_or_default_request_is_never_refused_in_any_spelling() {
for (gpu, no_gpu, backend) in [
(false, false, None),
(false, true, None),
(false, false, Some("cpu")),
(false, true, Some("cpu")),
] {
assert!(
ensure_available_for(gpu, no_gpu, backend).is_ok(),
"a cpu/default request must never be refused: \
gpu={gpu} no_gpu={no_gpu} backend={backend:?}"
);
}
}

/// GH-326 (`--gpu` beats `--no-gpu`) and its twin (`--gpu` beats `--backend
/// cpu`), asserted at the resolution boundary rather than at the four call
/// sites that used to each re-derive it.
///
/// Both outcomes are asserted so the test says something on EVERY host: a
/// build/host with an accelerator resolves, one without refuses. The one
/// thing it may never do is quietly become a cpu run — that is #2696.
#[test]
fn a_forced_gpu_request_is_honoured_or_refused_never_quietly_made_cpu() {
for backend in [None, Some("cpu"), Some("gpu")] {
match ensure_available_for(true, true, backend) {
Ok(()) => assert!(
build_has_accelerator(),
"--gpu resolved on a build the registry says has no accelerator"
),
Err(e) => {
let m = e.to_string();
assert!(
m.contains("--gpu"),
"the refusal quotes the flag typed: {m}"
);
assert!(
m.contains("--no-gpu"),
"and offers the deliberate CPU path: {m}"
);
}
}
}
}

/// `ensure_available` keeps taking the flag TEXT a caller quotes back, so
/// the text has to map onto the same request the registry resolves.
#[test]
fn the_flag_text_maps_back_onto_the_request_it_came_from() {
assert!(request_from_asked("--gpu").gpu);
assert_eq!(request_from_asked("--gpu").backend, None);
assert!(request_from_asked("--gpu-layers all").gpu);
assert_eq!(request_from_asked("--backend cuda").backend, Some("cuda"));
assert!(!request_from_asked("--backend cuda").gpu);
assert_eq!(request_from_asked("--backend wgpu ").backend, Some("wgpu"));
}

#[test]
fn the_flag_quoted_back_is_the_one_the_user_typed() {
assert_eq!(asked_flag(true, None), "--gpu");
Expand Down
28 changes: 7 additions & 21 deletions crates/apr-cli/src/commands/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,24 +281,9 @@ pub(crate) fn run(
/// Feature gates are read FIRST and are decisive when absent: a build without
/// the feature cannot take that path, whatever `nvidia-smi` says.
fn compute_class() -> &'static str {
if cfg!(feature = "cuda") {
// Built for CUDA. It still only counts as `cuda` if the runtime is
// actually there; otherwise this build silently fell back and the
// receipt must say so rather than claim the fast path.
let runtime = std::process::Command::new("nvidia-smi")
.arg("-L")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if runtime {
return "cuda";
}
return "cpu";
}
if cfg!(feature = "wgpu") {
return "wgpu";
}
"cpu"
// R-0b (#3002): the registry's default selection is the class this build
// runs on (first Ready accelerator, else cpu) — a runtime fact, never `cfg!`.
crate::registry::compute_class()
}

/// PARITY-001 — sha256 of a file's contents, for model identity.
Expand Down Expand Up @@ -330,10 +315,11 @@ fn provenance_json() -> serde_json::Value {
if cfg!(feature = "inference") {
features.push("inference");
}
if cfg!(feature = "cuda") {
// R-0b: compiled backends come from the registry, never `cfg!`.
if crate::registry::compiled("cuda") {
features.push("cuda");
}
if cfg!(feature = "wgpu") {
if crate::registry::compiled("wgpu") {
features.push("wgpu");
}
if cfg!(feature = "training") {
Expand Down Expand Up @@ -850,7 +836,7 @@ mod parity_001_receipt_tests {
#[test]
fn compute_class_is_cpu_without_a_gpu_feature() {
let class = compute_class();
if cfg!(feature = "cuda") || cfg!(feature = "wgpu") {
if crate::registry::build_has_accelerator() {
// Built with a GPU feature: the class may legitimately be a GPU
// path, or `cpu` if the runtime turned out to be absent.
assert!(
Expand Down
Loading
Loading