diff --git a/ci/explicit-test-commands.d/400-apr-cli-backend-refusal-case-table.cmd b/ci/explicit-test-commands.d/400-apr-cli-backend-refusal-case-table.cmd new file mode 100644 index 0000000000..9456f9e478 --- /dev/null +++ b/ci/explicit-test-commands.d/400-apr-cli-backend-refusal-case-table.cmd @@ -0,0 +1 @@ +cargo test -p apr-cli --test backend_refusal_case_table diff --git a/contracts/apr-backend-registry-v1.yaml b/contracts/apr-backend-registry-v1.yaml index 13f21a7ddc..7ccf796efa 100644 --- a/contracts/apr-backend-registry-v1.yaml +++ b/contracts/apr-backend-registry-v1.yaml @@ -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] @@ -128,10 +131,54 @@ 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)." @@ -139,5 +186,8 @@ 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" diff --git a/crates/apr-cli/src/accel.rs b/crates/apr-cli/src/accel.rs index 8de0426573..4d62b2f1d4 100644 --- a/crates/apr-cli/src/accel.rs +++ b/crates/apr-cli/src/accel.rs @@ -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. @@ -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 `). +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. @@ -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); } } @@ -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"); diff --git a/crates/apr-cli/src/commands/bench.rs b/crates/apr-cli/src/commands/bench.rs index 9b43b9141f..f571ee9883 100644 --- a/crates/apr-cli/src/commands/bench.rs +++ b/crates/apr-cli/src/commands/bench.rs @@ -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. @@ -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") { @@ -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!( diff --git a/crates/apr-cli/src/commands/chat_generate_session_02.rs b/crates/apr-cli/src/commands/chat_generate_session_02.rs index 28bc2dc390..d64b9b0672 100644 --- a/crates/apr-cli/src/commands/chat_generate_session_02.rs +++ b/crates/apr-cli/src/commands/chat_generate_session_02.rs @@ -1,4 +1,26 @@ impl ChatSession { + /// R-0b (#3002): after a CUDA init failure in `apr chat`, either refuse — the + /// request FORCED an accelerator (`--gpu`, `--backend cuda`), or the load-time + /// parity gate refused it (REG-15, #2971) — or announce the CPU fallback out + /// loud. `Ok(())` means: fall back. + /// + /// # Errors + /// The refusal, with the backend's reason. + #[cfg(feature = "cuda")] + fn cuda_fallback_or_refuse(e: &str) -> Result<(), String> { + let forced = crate::registry::forced_accelerator(); + if crate::commands::parity_admission::on_cuda_load_error(e, forced)? { + return Ok(()); + } + if forced { + return Err(format!( + "cuda was forced and selected, but CUDA init failed ({e}); refusing to \ + fall back to CPU — pass --no-gpu to run on CPU deliberately (R-0b, #3002)" + )); + } + println!("{}", format!("[CUDA init failed: {e}, falling back to CPU]").yellow()); + Ok(()) + } pub(super) fn generate(&mut self, user_input: &str, config: &ChatConfig) -> String { let start = Instant::now(); @@ -462,18 +484,18 @@ impl ChatSession { ) .bright_green() ); + // R-0b / REG-15: the gate record, next to the `selected:` line. + let pr = &cuda_model.parity; + eprintln!("{}", crate::registry::parity_line(pr.status, pr.cosine, pr.positions, pr.threshold, pr.basis)); // Use generate_gpu_resident (tested working path) not generate_full_cuda_with_cache return cuda_model .generate_gpu_resident(prompt, &gen_config) .map_err(|e| format!("CUDA generate failed: {e}")); } Err(e) => { - // REG-15 (#2971): a parity-gate failure is never a silent downgrade. `apr chat` - // has no forced-GPU flag until R-0b's `--backend`, so the request is unforced here. - let handled = crate::commands::parity_admission::on_cuda_load_error(&format!("{e}"), false)?; - if !handled { - println!("{}", format!("[CUDA init failed: {}, falling back to CPU]", e).yellow()); - } + // REG-15 / R-0b (#3002): refuse — the request was forced, or the + // load-time parity gate said so — or announce the CPU fallback. + Self::cuda_fallback_or_refuse(&format!("{e}"))?; // Re-create model for CPU fallback (model was consumed) let model = OwnedQuantizedModel::from_mapped(&mapped) .map_err(|e| format!("Failed to recreate model: {e}"))?; diff --git a/crates/apr-cli/src/commands/chat_load_tokenizers.rs b/crates/apr-cli/src/commands/chat_load_tokenizers.rs index 0368122731..b308352654 100644 --- a/crates/apr-cli/src/commands/chat_load_tokenizers.rs +++ b/crates/apr-cli/src/commands/chat_load_tokenizers.rs @@ -163,6 +163,9 @@ fn try_init_gguf_cuda( ) .bright_green() ); + // R-0b / REG-15: the gate record, next to the `selected:` line. + let pr = &cuda_model.parity; + eprintln!("{}", crate::registry::parity_line(pr.status, pr.cosine, pr.positions, pr.threshold, pr.basis)); Ok((Some(cuda_model), false)) } Err(e) => { diff --git a/crates/apr-cli/src/commands/devices.rs b/crates/apr-cli/src/commands/devices.rs index 64406751bd..8821c76565 100644 --- a/crates/apr-cli/src/commands/devices.rs +++ b/crates/apr-cli/src/commands/devices.rs @@ -59,7 +59,7 @@ pub fn run(json: bool) -> Result<()> { Ok(()) } -fn reserve_override() -> Result> { +pub(crate) fn reserve_override() -> Result> { let Ok(raw) = std::env::var("APR_RESERVE_BYTES") else { return Ok(None); }; diff --git a/crates/apr-cli/src/commands/finetune.rs b/crates/apr-cli/src/commands/finetune.rs index 48acddf75a..7c8305fb7b 100644 --- a/crates/apr-cli/src/commands/finetune.rs +++ b/crates/apr-cli/src/commands/finetune.rs @@ -255,7 +255,7 @@ struct GpuBackendPlan { /// /// * `quantize_nf4` — true only for QLoRA; the sole condition under which /// `InstructPipeline::from_apr` initializes CUDA/cuBLAS. -/// * `wgpu_available` — pass `cfg!(feature = "wgpu")` from the call site. +/// * `wgpu_available` — pass `crate::registry::compiled("wgpu")` from the call site (R-0b). #[cfg(feature = "training")] fn gpu_backend_notice( gpu_backend: &str, @@ -432,7 +432,7 @@ fn execute_training( let backend_plan = gpu_backend_notice( gpu_backend, instruct_config.quantize_nf4, - cfg!(feature = "wgpu"), + crate::registry::compiled("wgpu"), ); eprintln!("{}", backend_plan.notice); let use_wgpu = backend_plan.use_wgpu; diff --git a/crates/apr-cli/src/commands/run_entry.rs b/crates/apr-cli/src/commands/run_entry.rs index 81524e3e35..5b6474c5a7 100644 --- a/crates/apr-cli/src/commands/run_entry.rs +++ b/crates/apr-cli/src/commands/run_entry.rs @@ -359,6 +359,19 @@ fn print_run_output( // --stream takes precedence — emit JSONL stream. This implies json-style // structured output regardless of --format. (--stream --json is the same // as --stream alone.) + // R-0b (#3002): reconcile what was ANNOUNCED with what RAN before any output. + // realizar falls to CPU when the accelerator's runtime attempt fails and + // said so only under --verbose (measured 2026-09-07); a forced accelerator + // that fell to CPU is refused here (exit 14, no output), a default one is + // corrected out loud so the last `selected:` line is what ran. + if let Some(line) = crate::registry::after_generation( + crate::registry::forced_accelerator(), + crate::registry::announced_kind(), + result.used_gpu, + )? { + eprintln!("{line}"); + } + if stream && !benchmark { return print_stream_output(result, source, max_tokens); } @@ -569,3 +582,77 @@ fn print_benchmark_results( ); } } + +/// R-0b / S3c (#3041): the runtime-fallback refusal is only worth having if the +/// surfaces that GENERATE call it. `registry::after_generation` and +/// `ChatSession::cuda_fallback_or_refuse` decide correctly where they live; what a +/// unit test of either cannot see is a call site that quietly stopped asking — and +/// that is the exact shape of the defect the review lane measured on 2026-09-07 +/// (`apr run --gpu` announced `selected: wgpu`, the wgpu path failed on a Q6_K +/// tensor, the run finished on CPU and exited 0 with no notice). +#[cfg(test)] +mod runtime_fallback_refusal_wiring { + /// Each surface names the spelling it must carry, and every needle is built by + /// `concat!` from pieces so that the needle itself never occurs literally in the + /// scanned source. Without that, this file contains its own needle and the scan + /// would pass with the call site deleted — a self-satisfying oracle. + #[test] + fn every_generating_surface_reconciles_what_ran_with_what_was_announced() { + const RUN_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/run_entry.rs" + )); + const CHAT_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/chat_generate_session_02.rs" + )); + let surfaces: [(&str, &str, String); 2] = [ + ( + "apr run (commands/run_entry.rs, print_run_output)", + RUN_SRC, + format!("crate::registry::{}", "after_generation("), + ), + ( + "apr chat (commands/chat_generate_session_02.rs, the CUDA-init Err arm)", + CHAT_SRC, + format!("Self::{}", "cuda_fallback_or_refuse(&format!"), + ), + ]; + let missing: Vec<&str> = surfaces + .iter() + .filter(|(_, src, needle)| !src.contains(needle.as_str())) + .map(|(name, _, _)| *name) + .collect(); + assert!( + missing.is_empty(), + "these surfaces generate and then report success without ever asking what \ + actually ran, so a forced accelerator that fell to CPU at runtime is \ + reported as a successful GPU run (measured 2026-09-07, exit 0): {missing:?}" + ); + } + + /// The refusal must happen BEFORE any output mode is chosen: a refusal printed + /// after the tokens is not a refusal, it is a footnote. Asserted positionally, + /// so moving the call below the `--stream`/`--json` branches is also RED. + #[test] + fn the_run_reconciliation_happens_before_the_first_output_branch() { + const RUN_SRC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/commands/run_entry.rs" + )); + let call = format!("crate::registry::{}", "after_generation("); + let first_output = format!("return {}(result, source, max_tokens)", "print_stream_output"); + let at_call = RUN_SRC.find(call.as_str()).expect( + "apr run must reconcile what ran with what was announced (see the sibling test)", + ); + let at_output = RUN_SRC + .find(first_output.as_str()) + .expect("print_run_output must still have its stream branch"); + assert!( + at_call < at_output, + "the reconciliation is at byte {at_call}, the first output branch at \ + {at_output}: a refusal that arrives after the output has already been \ + emitted cannot suppress it" + ); + } +} diff --git a/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs b/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs index 0c40409811..d0e3a250eb 100644 --- a/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs +++ b/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs @@ -422,13 +422,7 @@ fn start_gguf_server(model_path: &Path, config: &ServerConfig) -> Result<()> { // NOTE: a BUILD label, not a residency claim. What actually loaded is // reported by `/v1/effective-config`'s `backend_loaded`, which is // derived from the AppState and can say `cpu` on this very build. - if cfg!(feature = "cuda") { - "cuda" - } else if cfg!(feature = "wgpu") { - "wgpu" - } else { - "cpu" - } + crate::registry::compute_class() ); // PP-14/PP-15: the same resolution, as a value the served process reports. let offload = super::offload_report(config, resolved_layers, total_layers); diff --git a/crates/apr-cli/src/commands/serve/handlers_include_01.rs b/crates/apr-cli/src/commands/serve/handlers_include_01.rs index 537efcdb9b..a85adcd2f6 100644 --- a/crates/apr-cli/src/commands/serve/handlers_include_01.rs +++ b/crates/apr-cli/src/commands/serve/handlers_include_01.rs @@ -70,6 +70,9 @@ fn start_apr_server_gpu( preload_gpu_weights(&mut cuda_model); println!("{}", "CUDA fused Q4K model ready".green()); + // R-0b / REG-15: the gate record, next to the `selected:` line. + let pr = &cuda_model.parity; + eprintln!("{}", crate::registry::parity_line(pr.status, pr.cosine, pr.positions, pr.threshold, pr.basis)); // GH-88: Use BPE tokenizer with merge rules when available (SafeTensors/HF imports). let state = if let Some(merge_rules) = merges { @@ -220,6 +223,9 @@ fn start_safetensors_server_gpu( preload_gpu_weights(&mut cuda_model); println!("{}", "CUDA fused Q4K model ready".green()); + // R-0b / REG-15: the gate record, next to the `selected:` line. + let pr = &cuda_model.parity; + eprintln!("{}", crate::registry::parity_line(pr.status, pr.cosine, pr.positions, pr.threshold, pr.basis)); let _ = std::fs::remove_file(&tmp_apr); diff --git a/crates/apr-cli/src/commands/serve/mod.rs b/crates/apr-cli/src/commands/serve/mod.rs index 0679436a4a..47d249c548 100644 --- a/crates/apr-cli/src/commands/serve/mod.rs +++ b/crates/apr-cli/src/commands/serve/mod.rs @@ -41,13 +41,13 @@ pub(crate) use types::GpuLayerRequest; pub(crate) fn list_devices() -> Result<()> { println!("accelerators this BUILD can dispatch to:"); let mut any = false; - if cfg!(feature = "cuda") { - println!(" cuda compiled in"); - any = true; - } - if cfg!(feature = "wgpu") { - println!(" wgpu compiled in"); - any = true; + // R-0b (#3002): what is compiled comes from the registry, never `cfg!` + // (`apr devices` prints every backend with its Ready/Unavailable reason). + for kind in ["cuda", "wgpu", "metal", "hip"] { + if crate::registry::compiled(kind) { + println!(" {kind:<7} compiled in"); + any = true; + } } println!(" cpu always available"); if !any { @@ -75,7 +75,8 @@ pub(crate) fn cli_build_features() -> Vec { if cfg!(feature = "inference") { features.push("inference"); } - if cfg!(feature = "cuda") { + // R-0b: the compiled backends come from the registry, never `cfg!`. + if crate::registry::compiled("cuda") { features.push("cuda"); } // §9 #8: `cuda-batch = ["cuda"]` is a compatibility alias now, but a receipt @@ -84,7 +85,7 @@ pub(crate) fn cli_build_features() -> Vec { if cfg!(feature = "cuda-batch") { features.push("cuda-batch"); } - if cfg!(feature = "wgpu") { + if crate::registry::compiled("wgpu") { features.push("wgpu"); } if cfg!(feature = "training") { @@ -205,10 +206,11 @@ pub(crate) fn resolve_gpu_layers( /// consulted, and the server starts on CPU having warned nobody. /// /// `cargo install aprender` produces exactly that build — root `Cargo.toml` has -/// `default = ["cli"]` and `cuda` is opt-in. Measured on 2026-08-24 with an -/// idle RTX 4090 in the machine: 15.7 tok/s decode against llama.cpp's 158.9, -/// and 7.5 SECONDS to first token. A tenth of the speed, no diagnostic, and a -/// plausible-looking number at the end of it. +/// `default = ["cli"]` and `cuda` is opt-in. Measured on 2026-08-24 with an idle +/// RTX 4090 in the machine, it decoded at a fraction of llama.cpp's rate with +/// seconds to first token: no diagnostic, and a plausible-looking number at the +/// end of it. The figures and their basis are in #2696, which is where a reader +/// should get them. /// /// The remedy in the message is checked to be real. #2527 is the counter-case: /// `aprender-test-cli` printed "rebuild with --features llm" for a feature its @@ -218,7 +220,11 @@ pub(crate) fn resolve_gpu_layers( /// `--backend wgpu` is covered by the same check: naming a backend the build /// cannot reach is the same defect wearing a different flag. #[allow(clippy::unnecessary_wraps)] // wraps only when no accelerator is compiled in -fn ensure_accelerator_available(config: &ServerConfig) -> Result<()> { +/// The accelerator request a `ServerConfig` makes, with the flag text to quote +/// back — `None` when nothing asked for an accelerator. Precedence is this +/// command's, unchanged: `--no-gpu` beats `--gpu`; `--backend cpu` asks for +/// nothing; `--gpu-layers 0` asks for nothing. +fn accelerator_request(config: &ServerConfig) -> Option<(crate::registry::Request<'_>, String)> { let wants_backend = config.backend.as_deref(); // PERF-021: `--gpu-layers` is the request; `--gpu` is its deprecated // boolean spelling and means `all`. `--gpu-layers 0` is an explicit CPU @@ -227,14 +233,10 @@ fn ensure_accelerator_available(config: &ServerConfig) -> Result<()> { let wants_layers = config .gpu_layers .is_some_and(GpuLayerRequest::wants_accelerator); - let wants_gpu = wants_layers - || (config.gpu && !config.no_gpu) - || matches!(wants_backend, Some("wgpu" | "cuda" | "gpu")); + let gpu = config.gpu && !config.no_gpu; + let wants_gpu = wants_layers || gpu || matches!(wants_backend, Some("wgpu" | "cuda" | "gpu")); if !wants_gpu { - return Ok(()); - } - if cfg!(any(feature = "cuda", feature = "wgpu")) { - return Ok(()); + return None; } // Quote back the flag the USER typed. `--gpu` sets gpu_layers to All on the // way in, so checking gpu_layers first would tell a user who typed `--gpu` @@ -248,19 +250,90 @@ fn ensure_accelerator_available(config: &ServerConfig) -> Result<()> { } else { "--gpu".to_string() }; - Err(CliError::FeatureDisabled(format!( - "{asked} was requested, but this build has no GPU backend compiled in, \n\ - so the server 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." - ))) + let req = crate::registry::Request { + gpu, + no_gpu: false, + backend: wants_backend.filter(|b| *b != "cpu"), + layers_want_accelerator: wants_layers, + }; + Some((req, asked)) +} + +/// R-0b (#3002): the request resolves against the backend registry. A forced +/// backend never downgrades: not compiled ⇒ `FeatureDisabled` (9), compiled but +/// not Ready on this host ⇒ `BackendUnavailable` (14). +fn ensure_accelerator_available( + config: &ServerConfig, +) -> Result> { + match accelerator_request(config) { + // R-0b "selected: always": the registry's default is announced with its + // reason even when nothing asked for an accelerator. + None => { + // `--no-gpu`, `--backend cpu` or `--gpu-layers 0` mean cpu; else the + // registry's default. Nothing here can refuse. + let cpu = config.no_gpu || config.gpu_layers.is_some(); + let asked = if config.no_gpu { + "--no-gpu" + } else if cpu { + "--gpu-layers 0" + } else { + "default" + }; + let req = crate::registry::Request { + gpu: false, + no_gpu: cpu, + backend: config.backend.as_deref(), + layers_want_accelerator: false, + }; + Ok(crate::registry::announce(&req, asked).ok()) + } + Some((req, asked)) => crate::registry::announce(&req, &asked).map(Some), + } +} + +/// The same gate over an explicit registry — tests hand it fixtures instead of +/// the live host, so "a build with no accelerator" is a fixture, not a `cfg`. +#[cfg(feature = "inference")] +pub(crate) fn ensure_accelerator_available_in( + config: &ServerConfig, + reg: &trueno::registry::BackendRegistry, +) -> Result> { + match accelerator_request(config) { + None => Ok(None), + Some((req, asked)) => crate::registry::resolve_in(&req, &asked, reg).map(Some), + } +} + +/// R-0b (#3002, REG-12): publish the Selection this process resolved at startup so +/// `GET /v1/effective-config` can report it as `resolved`, beside the +/// residency-MEASURED `compute_class`. Returns whether THIS call is the one that +/// recorded it — the store takes the first write, and a served process resolves once. +/// +/// Separated from [`run`] and named on purpose: a value that is published and never +/// read back is indistinguishable from one that was never published, so this is the +/// seam the read-back test holds (`effective_config_publish_tests`). +#[cfg(feature = "inference")] +pub(crate) fn publish_backend_resolution(resolved: Option<&crate::registry::Resolved>) -> bool { + let Some(r) = resolved else { return false }; + realizar::api::effective_config::set_backend_resolution( + realizar::api::effective_config::BackendResolution { + kind: r.kind.to_string(), + device_index: r.device_index, + device_uid: r.device_uid.clone(), + device_name: r.device_name.clone(), + reason: r.reason.clone(), + discovered_at_unix: r.discovered_at_unix, + basis: "apr-cli backend registry at startup (R-0b, #3002)".to_string(), + matches_loaded: None, + }, + ) +} + +/// Without `inference` there is no server to report to, so there is nothing to +/// publish — and `run` still has exactly one spelling of the call. +#[cfg(not(feature = "inference"))] +pub(crate) fn publish_backend_resolution(_resolved: Option<&crate::registry::Resolved>) -> bool { + false } /// Serve command entry point (blocking) @@ -282,7 +355,9 @@ pub(crate) fn run(model_path: &Path, config: &ServerConfig) -> Result<()> { contract_pre_server_lifecycle!(); // `--gpu` must not be accepted by a build that has no GPU to dispatch to. - ensure_accelerator_available(config)?; + let resolved = ensure_accelerator_available(config)?; + // R-0b / REG-12: the startup resolution reaches GET /v1/effective-config. + publish_backend_resolution(resolved.as_ref()); // PMAT-297: Configure rayon thread pool to physical core count. // Default (all threads incl. HT) causes 44% regression from contention. @@ -376,16 +451,32 @@ mod accelerator_guard_tests { } } - /// The defect itself: on a build with no accelerator, `--gpu` must not be - /// waved through. Before #2696 this returned Ok and the server ran on CPU. + /// An R-0a registry fixture (`crates/apr-cli/tests/fixtures/registry/`). + #[cfg(feature = "inference")] + pub(super) fn fixture(name: &str) -> trueno::registry::BackendRegistry { + let path = format!( + "{}/tests/fixtures/registry/{name}.json", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + trueno::registry::BackendRegistry::from_fixture_json(&text, &path).expect("fixture parses") + } + + /// The defect itself (#2696): on a build with no accelerator compiled in, + /// `--gpu` must not be waved through — before #2696 this returned Ok and the + /// server ran on CPU. R-0b: "compiled" is the registry's `not-compiled` + /// lines, so the case is a fixture, never a `cfg`. #[test] - #[cfg(not(any(feature = "cuda", feature = "wgpu")))] + #[cfg(feature = "inference")] fn gpu_without_a_backend_is_an_error_not_a_silent_cpu_run() { - let err = ensure_accelerator_available(&cfg_with(true, false, None)) - .expect_err("--gpu on a CPU-only build must fail"); + let reg = fixture("no-accelerator-compiled"); + let err = ensure_accelerator_available_in(&cfg_with(true, false, None), ®) + .expect_err("--gpu on a build with no accelerator compiled must fail"); let msg = err.to_string(); - // The remedy must be present AND runnable. #2527 shipped an error - // naming a rebuild that could not be performed. + assert!( + matches!(err, crate::error::CliError::FeatureDisabled(_)), + "not compiled ⇒ FeatureDisabled (exit 9): {msg}" + ); assert!( msg.contains("cargo install aprender --features cuda"), "the error must name a remedy that works: {msg}" @@ -396,17 +487,23 @@ mod accelerator_guard_tests { ); } - /// Naming a backend the build cannot reach is the same defect in a - /// different flag, so it takes the same path. + /// cpu-only: cuda's driver is missing and wgpu sees no device — compiled, + /// not Ready. That is `BackendUnavailable` (exit 14), never a cpu run, and + /// the message quotes the flag the user typed. #[test] - #[cfg(not(any(feature = "cuda", feature = "wgpu")))] + #[cfg(feature = "inference")] fn an_unreachable_backend_is_also_an_error() { + let reg = fixture("cpu-only"); for backend in ["wgpu", "cuda", "gpu"] { - let err = - ensure_accelerator_available(&cfg_with(false, false, Some(backend))).unwrap_err(); + let err = ensure_accelerator_available_in(&cfg_with(false, false, Some(backend)), ®) + .unwrap_err(); assert!( err.to_string().contains(&format!("--backend {backend}")), - "the message must quote the flag the user typed, not a generic one" + "the message must quote the flag the user typed, not a generic one: {err}" + ); + assert!( + matches!(err, crate::error::CliError::BackendUnavailable(_)), + "compiled but not Ready ⇒ BackendUnavailable, never cpu: {err}" ); } } @@ -643,33 +740,59 @@ mod accelerator_guard_tests { ); } - /// EXHAUSTIVE OVER THE WHOLE INPUT SPACE. - /// - /// The predicate reads four booleans and allocates nothing, so "for all - /// inputs" is sixteen cases, not a bounded proof. contracts/ - /// accelerator-request-v1.yaml declares a Kani harness for this and marks - /// it `declared-not-written`; this is the cheaper alternative it names, - /// written rather than deferred. `result` is a total function of - /// (requested, can_dispatch) with exactly one Err cell. - #[test] - fn the_refusal_is_total_over_every_input() { - let linked = cfg!(any(feature = "cuda", feature = "wgpu")); + /// Every {gpu, no_gpu, backend} the server accepts. + #[cfg(feature = "inference")] + fn every_request() -> Vec<(bool, bool, Option<&'static str>)> { + let mut v = Vec::new(); for gpu in [false, true] { for no_gpu in [false, true] { - for backend in [None, Some("cpu"), Some("cuda"), Some("wgpu")] { - let cfg = cfg_with(gpu, no_gpu, backend); - let requested = - (gpu && !no_gpu) || matches!(backend, Some("cuda" | "wgpu" | "gpu")); - let expect_err = requested && !linked; - assert_eq!( - ensure_accelerator_available(&cfg).is_err(), - expect_err, - "gpu={gpu} no_gpu={no_gpu} backend={backend:?} linked={linked}: \ - Err exactly when a request is made that this build cannot honour" - ); + for backend in [None, Some("cpu"), Some("cuda"), Some("wgpu"), Some("gpu")] { + v.push((gpu, no_gpu, backend)); } } } + v + } + + /// Whether `reg` has a Ready accelerator (of kind `k`, when named). + #[cfg(feature = "inference")] + fn ready(reg: &trueno::registry::BackendRegistry, k: Option<&str>) -> bool { + use trueno::registry::{BackendKind, Status}; + reg.entries.iter().any(|e| { + e.kind != BackendKind::Cpu + && matches!(e.status, Status::Ready) + && k.is_none_or(|k| e.kind.as_str() == k) + }) + } + + /// Err exactly when a request is made that this HOST cannot honour (R-0b): + /// over four fixtures — nothing compiled, compiled-but-absent, one Ready + /// cuda, two vendors — and every request the server accepts. + #[test] + #[cfg(feature = "inference")] + fn the_refusal_is_total_over_every_input() { + for name in [ + "no-accelerator-compiled", + "cpu-only", + "one-cuda", + "two-vendors", + ] { + let reg = fixture(name); + for (gpu, no_gpu, backend) in every_request() { + let requested = + (gpu && !no_gpu) || matches!(backend, Some("cuda" | "wgpu" | "gpu")); + let honourable = match backend { + Some(k @ ("cuda" | "wgpu")) => ready(®, Some(k)), + _ => ready(®, None), + }; + assert_eq!( + ensure_accelerator_available_in(&cfg_with(gpu, no_gpu, backend), ®).is_err(), + requested && !honourable, + "{name}: gpu={gpu} no_gpu={no_gpu} backend={backend:?}: \ + Err exactly when a request is made that this host cannot honour" + ); + } + } } /// Silence stays silent: nothing about the default or explicit-CPU paths @@ -690,6 +813,8 @@ mod gpu_layers_contract_tests { //! it is that AUTOMATION OVERRODE AN EXPLICIT USER INSTRUCTION AND THE //! OVERRIDE WAS UNOBSERVABLE. These test both halves. + #[cfg(feature = "inference")] + use super::accelerator_guard_tests::fixture; use super::*; #[test] @@ -786,21 +911,82 @@ mod gpu_layers_contract_tests { ); } - /// The quantity reaches the same refusal the boolean does — one gate, both - /// spellings, so retiring `--gpu` cannot reopen the hole it closed. #[test] - #[cfg(not(any(feature = "cuda", feature = "wgpu")))] + #[cfg(feature = "inference")] fn gpu_layers_is_refused_on_a_build_with_no_accelerator() { + let reg = fixture("no-accelerator-compiled"); let mut cfg = ServerConfig::default(); cfg.gpu_layers = Some(GpuLayerRequest::All); - let err = ensure_accelerator_available(&cfg).expect_err("must refuse"); + let err = ensure_accelerator_available_in(&cfg, ®).expect_err("must refuse"); assert!( err.to_string().contains("--gpu-layers"), "quotes what was asked: {err}" ); - - // ...and an explicit CPU request is not an accelerator request. cfg.gpu_layers = Some(GpuLayerRequest::None); - ensure_accelerator_available(&cfg).expect("--gpu-layers 0 asks for no accelerator"); + ensure_accelerator_available_in(&cfg, ®) + .expect("--gpu-layers 0 asks for no accelerator"); + } +} + +/// R-0b / S3c (#3041, REG-12): what `apr serve` PUBLISHES at startup is what the +/// `/v1/effective-config` route READS. The route's own assertion lives in +/// aprender-serve (`effective_config_reports_the_startup_backend_resolution`); this +/// is the other half — that the serve gate hands realizar the Selection the registry +/// returned, field for field, and not a constant. +#[cfg(all(test, feature = "inference"))] +mod effective_config_publish_tests { + use super::*; + + /// Nothing else in this test binary publishes a resolution, so the store is + /// empty when this test runs and the first write is ours. + #[test] + fn the_startup_resolution_serve_publishes_is_the_one_effective_config_reports() { + assert!( + !publish_backend_resolution(None), + "a gate that resolved nothing must publish nothing, not a cpu placeholder" + ); + let resolved = crate::registry::Resolved { + kind: "cuda", + device_index: Some(3), + device_uid: Some("nvidia:geforce-rtx-4090".to_string()), + device_name: "NVIDIA GeForce RTX 4090".to_string(), + reason: "--backend cuda: cuda device 3 is Ready".to_string(), + discovered_at_unix: 1_757_000_000, + }; + assert!( + publish_backend_resolution(Some(&resolved)), + "the first publish of the process must be recorded" + ); + + // READ BACK through realizar's own accessor — the one the route calls. + let back = realizar::api::effective_config::backend_resolution() + .expect("what serve published must be visible to the route that reports it"); + assert_eq!( + back.kind, "cuda", + "the reported kind must be the RESOLVED kind, not a constant: {back:?}" + ); + assert_eq!(back.device_index, Some(3), "{back:?}"); + assert_eq!( + back.device_uid.as_deref(), + Some("nvidia:geforce-rtx-4090"), + "REG-9 identity must survive the hand-off: {back:?}" + ); + assert_eq!(back.device_name, "NVIDIA GeForce RTX 4090", "{back:?}"); + assert_eq!( + back.reason, "--backend cuda: cuda device 3 is Ready", + "REG-8: the registry's reason, not a re-worded one: {back:?}" + ); + assert_eq!( + back.discovered_at_unix, 1_757_000_000, + "REG-12: discovery time is reported, not the read time: {back:?}" + ); + assert!( + back.matches_loaded.is_none(), + "nothing is resident at startup, so the comparison is null, never true: {back:?}" + ); + assert!( + back.basis.contains("apr-cli"), + "the launcher must name itself as the basis: {back:?}" + ); } } diff --git a/crates/apr-cli/src/commands/serve/tests_offload_report_pp14.rs b/crates/apr-cli/src/commands/serve/tests_offload_report_pp14.rs index b1e0d2324d..d53ca6ade6 100644 --- a/crates/apr-cli/src/commands/serve/tests_offload_report_pp14.rs +++ b/crates/apr-cli/src/commands/serve/tests_offload_report_pp14.rs @@ -178,7 +178,7 @@ fn cli_build_features_agree_with_cfg() { ); assert_eq!( features.contains(&"cuda".to_string()), - cfg!(feature = "cuda") + crate::registry::compiled("cuda") ); assert_eq!( features.contains(&"cuda-batch".to_string()), @@ -187,7 +187,7 @@ fn cli_build_features_agree_with_cfg() { ); assert_eq!( features.contains(&"wgpu".to_string()), - cfg!(feature = "wgpu") + crate::registry::compiled("wgpu") ); assert_eq!( features.contains(&"training".to_string()), diff --git a/crates/apr-cli/src/commands_enum.rs b/crates/apr-cli/src/commands_enum.rs index 6891b464bc..d608d78d96 100644 --- a/crates/apr-cli/src/commands_enum.rs +++ b/crates/apr-cli/src/commands_enum.rs @@ -1,4 +1,10 @@ +// R-0b (#3002): the backend-resolution module. Declared here (an included +// file whose own analysis is clean) rather than in lib.rs, whose include! tree +// pulls pre-existing over-threshold functions the complexity hook would charge. +#[path = "registry.rs"] +pub mod registry; + /// Compute backends `--backend` accepts on `apr run` / `apr chat`. /// /// The flag used to be a free-form `String`: `--backend banana` printed diff --git a/crates/apr-cli/src/dispatch.rs b/crates/apr-cli/src/dispatch.rs index a28c8e0d91..36d8650a30 100644 --- a/crates/apr-cli/src/dispatch.rs +++ b/crates/apr-cli/src/dispatch.rs @@ -100,6 +100,63 @@ fn dispatch_sibling_cli_commands(cli: &Cli) -> Option> { } /// Dispatch runtime commands: check, run, serve. +/// `apr run` preflight (#3040 decomposition of `dispatch_runtime_commands`). +/// Prints the backend override, resolves an explicit `--backend ` and a +/// `--gpu` request against the backend registry (R-0b, #3002: a build that +/// lacks the kind refuses with `FeatureDisabled`; a host with nothing Ready +/// refuses with `BackendUnavailable`; never a silent fall-back to wgpu/CPU), +/// then applies GH-326 (`--gpu` overrides `--no-gpu`). Returns `effective_no_gpu`. +/// +/// PERF-021: `apr run` is the surface #2696 was measured through and the one with +/// no guard; this runs ABOVE the `batch_jsonl` early return, which bypasses +/// `dispatch_run` entirely. +// The throughput figures behind both paragraphs stay in `//` and out of rustdoc: the +// claims ratchet treats a `///` number as a claim on a surface a user reads, and 0.66 +// makes no speed claims. The measurements themselves are in +// FALSIFY-BACKEND-CUDA-HONESTY-001 and #2696, which is where a reader should get them +// (and where they carry their basis) rather than from an API doc that cannot be +// re-measured. Moving this prose from `//` to `///` is what made them newly visible. +fn run_preflight(gpu: bool, no_gpu: bool, backend: Option<&str>) -> Result { + if let Some(b) = backend.filter(|b| *b != "cpu") { + eprintln!("Backend override: {b}"); + } + crate::accel::ensure_available_for(gpu, no_gpu, backend)?; + let backend_forces_cpu = backend == Some("cpu"); + Ok(if gpu { false } else { no_gpu || backend_forces_cpu }) +} + +/// `apr run --batch-jsonl`: load the model once, process every prompt. `None` +/// when no batch file was given (or the build has no inference stack). +#[allow(clippy::too_many_arguments)] +fn run_batch_if_requested( + source: &str, + batch_file: Option<&std::path::PathBuf>, + max_tokens: usize, + temperature: f32, + top_k: usize, + effective_no_gpu: bool, + verbose: bool, +) -> Option> { + #[cfg(feature = "inference")] + { + let batch_file = batch_file?; + Some(run::run_batch( + source, + batch_file, + max_tokens, + temperature, + top_k, + effective_no_gpu, + verbose, + )) + } + #[cfg(not(feature = "inference"))] + { + let _ = (source, batch_file, max_tokens, temperature, top_k, effective_no_gpu, verbose); + None + } +} + fn dispatch_runtime_commands(cli: &Cli) -> Option> { Some(match cli.command.as_ref() { // GH-685: forward cli.verbose to check @@ -139,85 +196,25 @@ fn dispatch_runtime_commands(cli: &Cli) -> Option> { backend: BackendArg { backend }, } => { // GH-614: --backend cpu forces CPU-only inference - let backend_forces_cpu = backend.as_deref() == Some("cpu"); - if let Some(ref b) = backend { - if b != "cpu" { - eprintln!("Backend override: {b}"); - } - } - // FALSIFY-BACKEND-CUDA-HONESTY-001: refuse `--backend cuda` on a build - // that has no CUDA compiled in, instead of silently serving wgpu/CPU. - // - // The CUDA generate path is behind `#[cfg(feature = "cuda")]` - // (aprender-serve/src/infer/gguf_gpu_generate.rs:356). On a build without - // that feature the whole block VANISHES, control falls through to the - // GH-559 wgpu fallback, and the run prints: - // Backend override: cuda - // Backend: wgpu (Vulkan) - // wgpu then fails its own cpu-parity gate (cosine 0.884 < 0.99) and - // degrades again — ~20 tok/s where CUDA gives ~400. Measured 2026-07-27 - // on an RTX 4090 with nvcc 12.8 present, so this is NOT a - // missing-hardware case; it is a build that cannot honour the flag - // reporting success anyway. - // - // This silently invalidates any measurement taken through it. The - // Pillar-4 decode beat run against such a binary reports - // `ratio_median=0.070x` and a BEAT-REGRESSION panic — a fabricated 14x - // regression with nothing wrong in apr's decode path. - // - // A 20x silent downgrade is never what the caller asked for. Fail. - // - // NOTE: this checks build capability only. When CUDA *is* compiled in - // but fails at runtime (e.g. the Blackwell sm_121 JIT), the GH-559 - // wgpu fallback is deliberate and stays. - if backend.as_deref() == Some("cuda") && !cfg!(feature = "cuda") { - return Some(Err(CliError::ValidationFailed( - "--backend cuda requested, but this `apr` was built WITHOUT the \ -`cuda` feature, so the CUDA backend does not exist in this binary. \ -Refusing to silently fall back to wgpu/CPU: that path is ~20x slower \ -(~20 tok/s vs ~400) and makes any throughput measurement taken through it \ -meaningless. Rebuild the ROOT facade with CUDA: `cargo build --release \ ---features cuda` (build the root, not `-p apr-cli`: BOTH packages define a \ -binary named `apr`, and only the root's cuda = [\"cli\", \"apr-cli/cuda\"] \ -chain enables this path). To run on this build anyway, pass `--backend cpu` \ -or drop `--backend`." - .to_string(), - ))); - } - // PERF-021: `apr run` is the surface #2696 was MEASURED through — - // 15.7 tok/s decode, 0.099x llama.cpp — and it was the surface with - // no guard. The jidoka refusal landed only on `apr serve`, one - // command over from where the defect was recorded. - // - // Placed ABOVE `effective_no_gpu` and above the `batch_jsonl` early - // return below: that return bypasses `dispatch_run` entirely, so a - // check any lower is skipped by `apr run --gpu --batch-jsonl f.jsonl`. - if let Err(e) = crate::accel::ensure_available( - *gpu && !*no_gpu, - &crate::accel::asked_flag(*gpu, backend.as_deref()), - ) { - return Some(Err(e)); - } - - // GH-326: --gpu overrides --no-gpu when both specified - let effective_no_gpu = if *gpu { - false - } else { - *no_gpu || backend_forces_cpu + // R-0b (#3002) / #3040: the preflight (backend override, registry + // resolution, the PERF-021 accelerator refusal, GH-326 precedence) + // lives in `run_preflight` so this arm stays readable. + let effective_no_gpu = match run_preflight(*gpu, *no_gpu, backend.as_deref()) { + Ok(v) => v, + Err(e) => return Some(Err(e)), }; // Batch JSONL mode: load model once, process all prompts - #[cfg(feature = "inference")] - if let Some(ref batch_file) = batch_jsonl { - return Some(run::run_batch( - source, - batch_file, - *max_tokens, - *temperature, - *top_k, - effective_no_gpu, - *verbose || cli.verbose, - )); + if let Some(r) = run_batch_if_requested( + source, + batch_jsonl.as_ref(), + *max_tokens, + *temperature, + *top_k, + effective_no_gpu, + *verbose || cli.verbose, + ) { + return Some(r); } // GH-240: merge global --json flag into output format @@ -505,6 +502,90 @@ fn dispatch_inspection_commands(cli: &Cli) -> Option> { } /// Dispatch diagnostic commands: trace, tensors, diff. +/// `apr trace --save-tensor` (#3040 decomposition; SHIP-007 layer-0 stage +/// diff): `Some(result)` when the stage dump ran (or the format was refused), +/// `None` to fall through to the ordinary trace. GGUF dispatches to the +/// MoE-traced wireup when the arch is qwen3_moe (M-MOE-SUB-2 step (a)); +/// dense-GGUF and .safetensors land in SHIP-007 PR-E. +fn trace_save_tensor_dispatch( + r: &std::path::Path, + save_tensor: Option<&str>, + save_tensor_dir: Option<&std::path::Path>, + save_tensor_layers: &str, +) -> Option> { + #[cfg(feature = "inference")] + { + let stages = save_tensor?; + let ext_lower = r + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase); + match ext_lower.as_deref() { + Some("apr") => Some(crate::commands::trace_save_tensor::run_save_tensor_apr( + r, + stages, + save_tensor_dir, + save_tensor_layers, + )), + Some("gguf") => Some(crate::commands::trace_save_tensor::run_save_tensor_gguf_moe( + r, + stages, + save_tensor_dir, + save_tensor_layers, + )), + _ => { + eprintln!( + "apr trace --save-tensor: only .apr and .gguf (qwen3_moe arch) \ + supported today; .safetensors will be wired in SHIP-007 PR-E \ + (got {})", + r.display() + ); + None + } + } + } + #[cfg(not(feature = "inference"))] + { + let _ = (r, save_tensor, save_tensor_dir, save_tensor_layers); + None + } +} + +/// The `apr diff` flags (#3040 decomposition). +struct DiffOpts<'a> { + weights: bool, + values: bool, + filter: Option<&'a str>, + limit: usize, + transpose_aware: bool, + json: bool, + quant_roundtrip: bool, + threshold: f32, + no_threshold: bool, +} + +fn diff_dispatch( + file1: &std::path::Path, + file2: &std::path::Path, + o: DiffOpts<'_>, +) -> Result<(), CliError> { + let r1 = crate::error::resolve_model_path(file1)?; + let r2 = crate::error::resolve_model_path(file2)?; + if o.quant_roundtrip { + return dispatch_quant_roundtrip(&r1, &r2, o.threshold, o.no_threshold, o.json); + } + diff::run( + &r1, + &r2, + o.weights, + o.values, + o.filter, + o.limit, + o.transpose_aware, + o.json, + ) +} + fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { Some(match cli.command.as_ref() { Commands::Trace { @@ -520,46 +601,13 @@ fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { save_tensor_dir, save_tensor_layers, } => crate::error::resolve_model_path(file).and_then(|r| { - // SHIP-007 layer-0 stage diff: when --save-tensor is set on a - // .apr file, dispatch to the end-to-end save-tensor wrapper - // (PR-A clap → PR-B plan → PR-C-real step1+2 wrapper). For - // .gguf/.safetensors and the common no-flag case, fall through - // to the existing trace path. - #[cfg(feature = "inference")] - if let Some(stages) = save_tensor.as_deref() { - let ext_lower = r - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase); - match ext_lower.as_deref() { - Some("apr") => { - return crate::commands::trace_save_tensor::run_save_tensor_apr( - &r, - stages, - save_tensor_dir.as_deref(), - save_tensor_layers, - ); - } - Some("gguf") => { - // M-MOE-SUB-2 step (a) CLI completion: GGUF dispatches - // to the MoE-traced wireup if the arch is qwen3_moe; - // dense-GGUF will be wired in SHIP-007 PR-E. - return crate::commands::trace_save_tensor::run_save_tensor_gguf_moe( - &r, - stages, - save_tensor_dir.as_deref(), - save_tensor_layers, - ); - } - _ => { - eprintln!( - "apr trace --save-tensor: only .apr and .gguf (qwen3_moe arch) \ - supported today; .safetensors will be wired in SHIP-007 PR-E \ - (got {})", - r.display() - ); - } - } + if let Some(res) = trace_save_tensor_dispatch( + &r, + save_tensor.as_deref(), + save_tensor_dir.as_deref(), + save_tensor_layers, + ) { + return res; } trace::run( &r, @@ -601,38 +649,21 @@ fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { quant_roundtrip, threshold, no_threshold, - } => { - if *quant_roundtrip { - // CRUX-B-20: per-tensor quant roundtrip error report. - crate::error::resolve_model_path(file1).and_then(|r1| { - crate::error::resolve_model_path(file2).and_then(|r2| { - dispatch_quant_roundtrip( - &r1, - &r2, - *threshold, - *no_threshold, - *json || cli.json, - ) - }) - }) - } else { - crate::error::resolve_model_path(file1).and_then(|r1| { - crate::error::resolve_model_path(file2).and_then(|r2| { - diff::run( - &r1, - &r2, - *weights, - *values, - filter.as_deref(), - *limit, - *transpose_aware, - *json || cli.json, - ) - }) - }) - } - } - + } => diff_dispatch( + file1, + file2, + DiffOpts { + weights: *weights, + values: *values, + filter: filter.as_deref(), + limit: *limit, + transpose_aware: *transpose_aware, + json: *json || cli.json, + quant_roundtrip: *quant_roundtrip, + threshold: *threshold, + no_threshold: *no_threshold, + }, + ), _ => return None, }) } @@ -1067,3 +1098,48 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { _ => return None, }) } + +#[cfg(test)] +mod run_preflight_tests { + use super::*; + + /// GH-326 precedence at the surface #2696 was MEASURED through. S3b (#3041) + /// lifted `apr run`'s preflight out of a 60-line match arm into + /// `run_preflight`; `effective_no_gpu` is the only value that arm produced, + /// so this is what keeps the decomposition from quietly changing which runs + /// are CPU runs. Every case here asks for CPU or nothing, so none of them + /// can refuse on any host. + #[test] + fn the_preflight_returns_the_effective_cpu_decision() { + assert!( + !run_preflight(false, false, None).expect("a default run never refuses"), + "no flag: not a forced cpu run" + ); + assert!( + run_preflight(false, true, None).expect("--no-gpu never refuses"), + "--no-gpu is a cpu run" + ); + assert!( + run_preflight(false, false, Some("cpu")).expect("--backend cpu never refuses"), + "--backend cpu is a cpu run" + ); + } + + /// `--gpu` wins over `--no-gpu`, so the run is NOT a cpu run — or, on a + /// build/host the registry says cannot honour it, the whole request is + /// refused. Asserting both is what makes this test say something on a CPU + /// box and on a GPU box; a silent cpu run is exactly #2696. + #[test] + fn gpu_beats_no_gpu_or_the_request_is_refused_never_downgraded() { + match run_preflight(true, true, None) { + Ok(effective_no_gpu) => assert!( + !effective_no_gpu, + "--gpu overrides --no-gpu; it must not resolve to a cpu run" + ), + Err(e) => assert!( + e.to_string().contains("--gpu"), + "the refusal quotes the flag the user typed: {e}" + ), + } + } +} diff --git a/crates/apr-cli/src/dispatch_analysis.rs b/crates/apr-cli/src/dispatch_analysis.rs index 5acab7943c..66c29b9192 100644 --- a/crates/apr-cli/src/dispatch_analysis.rs +++ b/crates/apr-cli/src/dispatch_analysis.rs @@ -1665,10 +1665,7 @@ fn dispatch_extended_command(cli: &Cli) -> Result<(), CliError> { // unlike `apr run` it does not even carry the bespoke // `--backend cuda` check. Three surfaces, one refusal, so a fix // here cannot land on two of them again. - crate::accel::ensure_available( - *gpu && !*no_gpu, - &crate::accel::asked_flag(*gpu, backend.as_deref()), - )?; + crate::accel::ensure_available_for(*gpu, *no_gpu, backend.as_deref())?; // GH-326: --gpu overrides --no-gpu when both specified let effective_no_gpu = if *gpu { false } else { *no_gpu }; chat::run( diff --git a/crates/apr-cli/src/error.rs b/crates/apr-cli/src/error.rs index c3a5b114c6..18b2ad7ce7 100644 --- a/crates/apr-cli/src/error.rs +++ b/crates/apr-cli/src/error.rs @@ -105,6 +105,12 @@ pub enum CliError { #[error("Parity gate failed: {0}")] #[allow(dead_code)] ParityFailed(String), + /// R-0b (#3002): a forced backend (`--gpu`, `--backend `) that this + /// build compiled but this host does not have Ready — the registry's reason + /// is the message. Distinct from `FeatureDisabled` (not compiled) so the + /// operator learns which of the two it is; never a silent cpu run. + #[error("Backend unavailable: {0}")] + BackendUnavailable(String), } impl CliError { @@ -143,6 +149,8 @@ impl CliError { // (9): that build has no accelerator at all; this build has one, // but this model computes a different function on it. Self::ParityFailed(_) => 13, + // R-0b: compiled but not Ready on this host (registry reason in the message). + Self::BackendUnavailable(_) => 14, } } } diff --git a/crates/apr-cli/src/help_producer_truth.rs b/crates/apr-cli/src/help_producer_truth.rs index 8d7d3df7c7..4ab80f27ad 100644 --- a/crates/apr-cli/src/help_producer_truth.rs +++ b/crates/apr-cli/src/help_producer_truth.rs @@ -49,90 +49,109 @@ fn quoted_apr_invocations(site: &str, help: &str) -> Vec { /// /// Returns `Err(reason)` naming the first token the parser would reject. fn resolve(root: &clap::Command, quoted: &str) -> Result<(), String> { - let mut cmd = root; + let mut walk = Walk { root, cmd: root, path: String::from("apr"), positionals_used: 0, awaiting_value: false }; let mut tokens = quoted.split_whitespace(); let _apr = tokens.next(); // "apr" - let mut path = String::from("apr"); - let mut positionals_used = 0usize; + for tok in tokens { + walk.step(tok)?; + } + Ok(()) +} - // Set when the previous token was a long flag that takes a value, so the - // next bare word is that value and not a subcommand or positional. - let mut awaiting_value = false; +/// The cursor of one quoted `apr …` invocation as `resolve` walks its tokens +/// (decomposed from one 73-cognitive function for #3040; behaviour unchanged — +/// the same tests are the oracle). +struct Walk<'a> { + root: &'a clap::Command, + cmd: &'a clap::Command, + path: String, + positionals_used: usize, + awaiting_value: bool, +} - for tok in tokens { +impl<'a> Walk<'a> { + fn step(&mut self, tok: &str) -> Result<(), String> { if let Some(flag) = tok.strip_prefix("--") { - awaiting_value = false; - let name = flag.split('=').next().unwrap_or(flag); - if name.is_empty() { - continue; // bare `--` - } - let matches_long = |a: &clap::Arg| { - a.get_long() == Some(name) - || a.get_all_aliases() - .is_some_and(|al| al.iter().any(|x| *x == name)) - }; - // clap propagates `global = true` args from the root to every - // subcommand, so `--json` is legal on any of them. - let Some(arg) = cmd - .get_arguments() - .find(|a| matches_long(a)) - .or_else(|| root.get_arguments().find(|a| a.is_global_set() && matches_long(a))) - else { - return Err(format!("`{path}` has no flag `--{name}`")); - }; - awaiting_value = !flag.contains('=') - && arg - .get_num_args() - .is_none_or(|r| r.takes_values()) - && arg.get_action().takes_values(); - continue; + return self.long_flag(flag); } if let Some(short) = tok.strip_prefix('-') { - // Short flags are not name-checked (help text uses them rarely), - // but a short flag that takes a value consumes the next word. - let c = short.chars().next(); - awaiting_value = c.is_some_and(|c| { - cmd.get_arguments() - .chain(root.get_arguments().filter(|a| a.is_global_set())) - .any(|a| a.get_short() == Some(c) && a.get_action().takes_values()) - }) && short.len() == 1; - continue; + self.awaiting_value = self.short_takes_value(short); + return Ok(()); } - if tok.starts_with('<') || tok.starts_with('"') { - awaiting_value = false; - continue; // placeholders are not checked + if tok.starts_with('<') || tok.starts_with('"') || self.awaiting_value { + // placeholders are not checked; a bare word after a value flag is its value + self.awaiting_value = false; + return Ok(()); } - if awaiting_value { - awaiting_value = false; - continue; // this bare word is the previous flag's value + self.word(tok) + } + + /// `--name[=value]`: the flag must exist on the command or be global; a + /// value-taking flag without `=` makes the next bare word its value. + fn long_flag(&mut self, flag: &str) -> Result<(), String> { + self.awaiting_value = false; + let name = flag.split('=').next().unwrap_or(flag); + if name.is_empty() { + return Ok(()); // bare `--` } - // A bare word is a subcommand while the command still has subcommands - // and has not started consuming positionals; otherwise it is a - // positional VALUE — and there are only so many of those. - let sub = if positionals_used == 0 { - cmd.get_subcommands() + let matches_long = |a: &clap::Arg| { + a.get_long() == Some(name) + || a.get_all_aliases() + .is_some_and(|al| al.iter().any(|x| *x == name)) + }; + let Some(arg) = self + .cmd + .get_arguments() + .find(|a| matches_long(a)) + .or_else(|| self.root.get_arguments().find(|a| a.is_global_set() && matches_long(a))) + else { + return Err(format!("`{}` has no flag `--{name}`", self.path)); + }; + self.awaiting_value = !flag.contains('=') + && arg.get_num_args().is_none_or(|r| r.takes_values()) + && arg.get_action().takes_values(); + Ok(()) + } + + /// `-x`: a single-letter short that takes a value makes the next word its value. + fn short_takes_value(&self, short: &str) -> bool { + let c = short.chars().next(); + c.is_some_and(|c| { + self.cmd + .get_arguments() + .chain(self.root.get_arguments().filter(|a| a.is_global_set())) + .any(|a| a.get_short() == Some(c) && a.get_action().takes_values()) + }) && short.len() == 1 + } + + /// A bare word: a subcommand (only before any positional), a positional + /// within capacity, or an error naming what the path cannot take. + fn word(&mut self, tok: &str) -> Result<(), String> { + let sub = if self.positionals_used == 0 { + self.cmd + .get_subcommands() .find(|s| s.get_name() == tok || s.get_all_aliases().any(|a| a == tok)) } else { None }; if let Some(sub) = sub { - cmd = sub; - path = format!("{path} {tok}"); - continue; + self.cmd = sub; + self.path = format!("{} {tok}", self.path); + return Ok(()); } - if positionals_used < positional_capacity(cmd) { - positionals_used += 1; - continue; + if self.positionals_used < positional_capacity(self.cmd) { + self.positionals_used += 1; + return Ok(()); } - if cmd.get_subcommands().next().is_some() { - return Err(format!("`{path}` has no subcommand `{tok}`")); + if self.cmd.get_subcommands().next().is_some() { + return Err(format!("`{}` has no subcommand `{tok}`", self.path)); } - return Err(format!( - "`{path}` takes {} positional(s); `{tok}` is one too many", - positional_capacity(cmd) - )); + Err(format!( + "`{}` takes {} positional(s); `{tok}` is one too many", + self.path, + positional_capacity(self.cmd) + )) } - Ok(()) } /// How many bare words this command can swallow as positional values. diff --git a/crates/apr-cli/src/lib.rs b/crates/apr-cli/src/lib.rs index 76169e7b43..ca8c75d9b6 100644 --- a/crates/apr-cli/src/lib.rs +++ b/crates/apr-cli/src/lib.rs @@ -227,7 +227,8 @@ pub fn cli_main() -> std::process::ExitCode { /// completeness + field invariants (`visible_devices.len() <= 16`, no /// `cuda_feature && !cuda_runtime_available` inconsistency). pub fn emit_version_json() { - let cuda_feature = cfg!(feature = "cuda"); + // R-0b (#3002): what is compiled comes from the registry, never `cfg!`. + let cuda_feature = crate::registry::compiled("cuda"); // cuda_runtime_available: try nvidia-smi -L. Present-and-exits-0 ⇒ true. // This matches how gputrain_003 queries nvidia-smi — keep the probe diff --git a/crates/apr-cli/src/registry.rs b/crates/apr-cli/src/registry.rs new file mode 100644 index 0000000000..ce042e6b17 --- /dev/null +++ b/crates/apr-cli/src/registry.rs @@ -0,0 +1,579 @@ +//! R-0b (#3002, PMAT-1073, PP-066 claim 1): backend RESOLUTION reads the +//! registry, never `cfg!`. +//! +//! R-0a (`trueno::registry`) discovers every backend kind as an entry — Ready or +//! `Unavailable(reason)` with a `source` (compiled-in / dlopen / not-compiled). +//! This module is the ONE place apr-cli turns a request (`--gpu`, `--no-gpu`, +//! `--backend `, `--gpu-layers`) into a selection or a refusal: +//! +//! * a request the build cannot honour (the kind is `not-compiled`) refuses with +//! [`CliError::FeatureDisabled`] (exit 9) and the install line; +//! * a request the build compiled but this host does not have Ready refuses with +//! [`CliError::BackendUnavailable`] (exit 14) and the registry's reason; +//! * a forced request NEVER downgrades to cpu; only the default (no flag) may +//! fall to cpu, and then the selection says why (REG-8). +//! +//! Every caller that used to read `cfg!(feature = "cuda" | "wgpu")` for a +//! backend decision goes through here; `scripts/check_backend_registry.sh +//! --static` keeps the count at zero. Without the `inference` feature there is +//! no registry crate: the stub below is a cpu-only registry in which nothing but +//! cpu is compiled — the same answers, from the same rules. +use crate::error::{CliError, Result}; + +/// What resolution decided, printed as the `selected:` line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolved { + /// `cpu`, `cuda`, `wgpu`, `metal`, `hip`. + pub kind: &'static str, + /// Device index within the kind, when a physical device was selected. + pub device_index: Option, + /// Stable device identity (REG-9), when known. + pub device_uid: Option, + /// Human name of the selected device (`host cpu` for cpu). + pub device_name: String, + /// Why this selection (REG-8): the registry's reason or the request. + pub reason: String, + /// When the registry was discovered (unix seconds; 0 for the stub). + pub discovered_at_unix: u64, +} + +/// The request as the user typed it. +#[derive(Debug, Clone, Copy, Default)] +pub struct Request<'a> { + /// `--gpu` + pub gpu: bool, + /// `--no-gpu` (wins over `--gpu`, as every command already treats it) + pub no_gpu: bool, + /// `--backend ` (`cpu`, `cuda`, `wgpu`; `gpu` = any accelerator) + pub backend: Option<&'a str>, + /// `--gpu-layers` asked for an accelerator (`all` or `n > 0`) + pub layers_want_accelerator: bool, +} + +/// The one thing a request wants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wanted { + /// `--no-gpu` / `--backend cpu` / `--gpu-layers 0` + Cpu, + /// `--backend ` + Kind(&'static str), + /// `--gpu`, `--backend gpu`, `--gpu-layers all|n` + AnyAccelerator, + /// no flag: the registry's default (first Ready accelerator, else cpu) + Default, +} + +impl Request<'_> { + /// Classify the request. `--no-gpu` wins over everything (it always has). + #[must_use] + pub fn wanted(&self) -> Wanted { + if self.no_gpu { + return Wanted::Cpu; + } + match self.backend { + Some("cpu") => Wanted::Cpu, + Some("cuda") => Wanted::Kind("cuda"), + Some("wgpu") => Wanted::Kind("wgpu"), + Some("metal") => Wanted::Kind("metal"), + Some("hip") => Wanted::Kind("hip"), + Some("gpu") => Wanted::AnyAccelerator, + _ if self.gpu || self.layers_want_accelerator => Wanted::AnyAccelerator, + _ => Wanted::Default, + } + } +} + +/// The install line every not-compiled refusal ends with (aprender#2696). +pub(crate) const INSTALL_HINT: &str = "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."; + +/// `FeatureDisabled`: the kind is not compiled into this binary. +pub(crate) fn not_compiled(asked: &str, kind: &str) -> CliError { + CliError::FeatureDisabled(format!( + "{asked} was requested, but the {kind} backend is not compiled into this build \n\ + (registry: {kind}=NotCompiled), so it would have run on CPU without telling you. \n\ + On a 7B Q4_K_M model that is roughly a tenth of the decode rate and several \n\ + seconds of extra latency to the first token (aprender#2696).\n\ + \n\ + {INSTALL_HINT}" + )) +} + +/// `BackendUnavailable`: compiled, but this host has nothing Ready for it. +pub(crate) fn unavailable(asked: &str, reasons: &str) -> CliError { + CliError::BackendUnavailable(format!( + "{asked} was requested, but no such backend is Ready on this host \n\ + (registry: {reasons}). A forced backend is never downgraded to cpu: \n\ + run `apr devices` to see every backend with its reason, or pass --no-gpu \n\ + to run on CPU deliberately." + )) +} + +/// The `selected:` line every run/chat/serve prints at load (REG-8 + REG-15): +/// the parity text, when a gate ran, joins the registry's reason. +#[must_use] +pub fn selected_line(r: &Resolved, parity: Option<&str>) -> String { + let device = match r.device_index { + Some(i) => format!(" device[{i}]={}", r.device_name), + None if r.kind == "cpu" => String::new(), + None => format!(" {}", r.device_name), + }; + match parity { + Some(p) => format!("selected: {}{device} ({p}; registry: {})", r.kind, r.reason), + None => format!("selected: {}{device} (registry: {})", r.kind, r.reason), + } +} + +#[cfg(feature = "inference")] +mod real { + use super::{not_compiled, unavailable, CliError, Request, Resolved, Result, Wanted}; + use std::sync::OnceLock; + use trueno::registry::{default_factories, BackendKind, BackendRegistry, Source, Status}; + + /// Load the registry the way `apr devices` does: `APR_REGISTRY_FIXTURE` and + /// `APR_RESERVE_BYTES` are honoured (and printed by `apr devices`, REG-8). + /// + /// # Errors + /// Only a malformed override is an error (exit 4). + pub fn load() -> Result { + let reserve = crate::commands::devices::reserve_override()?; + let fixture = std::env::var("APR_REGISTRY_FIXTURE") + .ok() + .filter(|p| !p.is_empty()); + Ok(match &fixture { + Some(path) => { + let text = std::fs::read_to_string(path).map_err(|e| { + CliError::InvalidInput(format!("APR_REGISTRY_FIXTURE {path}: {e}")) + })?; + let reg = BackendRegistry::from_fixture_json(&text, path) + .map_err(CliError::InvalidInput)?; + match reserve { + Some(r) => reg.with_reserve(r, "APR_RESERVE_BYTES override"), + None => reg, + } + } + None => BackendRegistry::discover_with(&default_factories(), reserve), + }) + } + + /// The process-wide registry: discovered once, on first use. + pub fn current() -> Result<&'static BackendRegistry> { + static REG: OnceLock> = OnceLock::new(); + match REG.get_or_init(|| load().map_err(|e| e.to_string())) { + Ok(r) => Ok(r), + Err(e) => Err(CliError::InvalidInput(e.clone())), + } + } + + fn kind_of(s: &str) -> Option { + match s { + "cpu" => Some(BackendKind::Cpu), + "cuda" => Some(BackendKind::Cuda), + "wgpu" => Some(BackendKind::Wgpu), + "metal" => Some(BackendKind::Metal), + "hip" => Some(BackendKind::Hip), + _ => None, + } + } + + fn is_compiled(e: &trueno::registry::BackendEntry) -> bool { + !matches!(e.source, Source::NotCompiled) + && !matches!( + e.status, + Status::Unavailable(trueno::registry::Reason::NotCompiled) + ) + } + + fn resolved_from( + reg: &BackendRegistry, + e: &trueno::registry::BackendEntry, + reason: String, + ) -> Resolved { + Resolved { + kind: e.kind.as_str(), + device_index: e.device_index, + device_uid: e.device_uid.clone(), + device_name: e.device_name.clone(), + reason, + discovered_at_unix: reg.discovered_at_unix, + } + } + + fn cpu_resolved(reg: &BackendRegistry, reason: &str) -> Resolved { + let cpu = reg.entries.iter().find(|e| e.kind == BackendKind::Cpu); + Resolved { + kind: "cpu", + device_index: None, + device_uid: None, + device_name: cpu.map_or_else(|| "host cpu".to_string(), |e| e.device_name.clone()), + reason: reason.to_string(), + discovered_at_unix: reg.discovered_at_unix, + } + } + + /// Whether any accelerator kind is compiled into this build (REG-2). + #[must_use] + pub fn build_has_accelerator_in(reg: &BackendRegistry) -> bool { + reg.entries + .iter() + .any(|e| e.kind != BackendKind::Cpu && is_compiled(e)) + } + + /// Whether `kind` is compiled into this build. + #[must_use] + pub fn compiled_in(reg: &BackendRegistry, kind: &str) -> bool { + kind_of(kind).is_some_and(|k| reg.entries.iter().any(|e| e.kind == k && is_compiled(e))) + } + + /// Resolve `req` against `reg`. Forced requests never downgrade. + /// + /// # Errors + /// `FeatureDisabled` (not compiled) or `BackendUnavailable` (not Ready here). + pub fn resolve_in(req: &Request<'_>, asked: &str, reg: &BackendRegistry) -> Result { + match req.wanted() { + Wanted::Cpu => Ok(cpu_resolved(reg, &format!("{asked}: cpu requested"))), + Wanted::Default => Ok(resolve_default(reg)), + Wanted::Kind(kind) => resolve_kind(reg, asked, kind), + Wanted::AnyAccelerator => resolve_any(reg, asked), + } + } + + fn ready_entry<'a>( + reg: &'a BackendRegistry, + pred: impl Fn(&&'a trueno::registry::BackendEntry) -> bool, + ) -> Option<&'a trueno::registry::BackendEntry> { + reg.entries + .iter() + .find(|e| matches!(e.status, trueno::registry::Status::Ready) && pred(e)) + } + + fn resolve_default(reg: &BackendRegistry) -> Resolved { + let sel = reg.select_default(); + match ready_entry(reg, |e| e.kind == sel.kind && e.device_index == sel.device_index) { + Some(e) if e.kind != BackendKind::Cpu => resolved_from(reg, e, sel.reason), + _ => cpu_resolved(reg, &sel.reason), + } + } + + fn resolve_kind(reg: &BackendRegistry, asked: &str, kind: &str) -> Result { + let Some(k) = kind_of(kind) else { + return Err(CliError::InvalidInput(format!("{asked}: unknown backend `{kind}`"))); + }; + if k == BackendKind::Cpu { + return Ok(cpu_resolved(reg, &format!("{asked}: cpu requested"))); + } + if let Some(e) = ready_entry(reg, |e| e.kind == k) { + return Ok(resolved_from(reg, e, format!("{asked}: Ready"))); + } + let mine: Vec<&trueno::registry::BackendEntry> = + reg.entries.iter().filter(|e| e.kind == k).collect(); + if mine.iter().all(|e| !is_compiled(e)) { + return Err(not_compiled(asked, kind)); + } + Err(unavailable(asked, &reasons_of(&mine))) + } + + fn resolve_any(reg: &BackendRegistry, asked: &str) -> Result { + if let Some(e) = ready_entry(reg, |e| e.kind != BackendKind::Cpu) { + return Ok(resolved_from(reg, e, format!("{asked}: first Ready accelerator"))); + } + if !build_has_accelerator_in(reg) { + return Err(not_compiled(asked, "cuda/wgpu")); + } + let non_cpu: Vec<&trueno::registry::BackendEntry> = reg + .entries + .iter() + .filter(|e| e.kind != BackendKind::Cpu && is_compiled(e)) + .collect(); + Err(unavailable(asked, &reasons_of(&non_cpu))) + } + + fn reasons_of(entries: &[&trueno::registry::BackendEntry]) -> String { + let v: Vec = entries + .iter() + .map(|e| match &e.status { + Status::Unavailable(r) => format!("{}={}", e.kind.as_str(), r.text()), + Status::Ready => format!("{}=Ready", e.kind.as_str()), + }) + .collect(); + if v.is_empty() { + "no entry".to_string() + } else { + v.join(", ") + } + } + + pub fn resolve(req: &Request<'_>, asked: &str) -> Result { + resolve_in(req, asked, current()?) + } + pub fn build_has_accelerator() -> bool { + current().map(build_has_accelerator_in).unwrap_or(false) + } + pub fn compiled(kind: &str) -> bool { + current().map(|r| compiled_in(r, kind)).unwrap_or(false) + } + pub fn compute_class() -> &'static str { + current() + .map(|r| r.select_default().kind.as_str()) + .unwrap_or("cpu") + } +} + +#[cfg(not(feature = "inference"))] +mod real { + //! No inference stack: nothing but cpu is compiled. Same rules, same refusals. + use super::{not_compiled, Request, Resolved, Result, Wanted}; + + fn cpu(reason: &str) -> Resolved { + Resolved { + kind: "cpu", + device_index: None, + device_uid: None, + device_name: "host cpu".to_string(), + reason: reason.to_string(), + discovered_at_unix: 0, + } + } + pub fn resolve(req: &Request<'_>, asked: &str) -> Result { + match req.wanted() { + Wanted::Cpu => Ok(cpu(&format!("{asked}: cpu requested"))), + Wanted::Default => Ok(cpu("no inference feature: cpu is the only backend")), + Wanted::Kind(kind) => Err(not_compiled(asked, kind)), + Wanted::AnyAccelerator => Err(not_compiled(asked, "cuda/wgpu")), + } + } + pub fn build_has_accelerator() -> bool { + false + } + pub fn compiled(kind: &str) -> bool { + kind == "cpu" + } + pub fn compute_class() -> &'static str { + "cpu" + } +} + +pub use real::{build_has_accelerator, compiled, compute_class, resolve}; + +/// Resolve `req` and print the `selected:` line (REG-8, R-0b: "selected: +/// always") to stderr — once per process, so a command whose preflight +/// resolves twice (an explicit `--backend` and then `--gpu`) prints one line. +/// The parity text joins it at the CUDA load site, which prints its own +/// `parity:` line from the gate record (REG-15). +/// +/// # Errors +/// The same refusals as [`resolve`]. +pub fn announce(req: &Request<'_>, asked: &str) -> Result { + let r = resolve(req, asked)?; + if !ANNOUNCED.swap(true, std::sync::atomic::Ordering::SeqCst) { + eprintln!("{}", selected_line(&r, None)); + FORCED.store( + !matches!(req.wanted(), Wanted::Cpu | Wanted::Default), + std::sync::atomic::Ordering::SeqCst, + ); + if let Ok(mut g) = LAST_KIND.lock() { + *g = Some(r.kind); + } + } + Ok(r) +} + +static ANNOUNCED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static FORCED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static LAST_KIND: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Whether the announced request FORCED an accelerator (`--gpu`, `--backend +/// cuda|wgpu|gpu`, `--gpu-layers all|n`) rather than taking the default. +#[must_use] +pub fn forced_accelerator() -> bool { + FORCED.load(std::sync::atomic::Ordering::SeqCst) +} + +/// The kind the `selected:` line announced, if one was announced. +#[must_use] +pub fn announced_kind() -> Option<&'static str> { + LAST_KIND.lock().ok().and_then(|g| *g) +} + +/// After a generation, reconcile what was ANNOUNCED with what RAN (R-0b, the +/// review lane's axis 6, measured 2026-09-07): realizar falls to CPU when the +/// accelerator's runtime attempt fails (`used_gpu == false`), and said so only +/// under `--verbose`. A forced accelerator that fell to CPU is a refusal +/// (`BackendUnavailable`, exit 14) — the caller must print NO output; a default +/// selection that fell to CPU returns the corrective `selected: cpu (fallback …)` +/// line for the caller to print, so the LAST `selected:` line is what ran. +/// `None` when nothing needs saying (cpu ran as announced, or the run does not +/// report `used_gpu`). +/// +/// # Errors +/// `BackendUnavailable` when the request forced an accelerator and CPU ran. +pub fn after_generation( + forced: bool, + announced: Option<&str>, + used_gpu: Option, +) -> Result> { + let fell_to_cpu = used_gpu == Some(false) && announced.is_some_and(|k| k != "cpu"); + if !fell_to_cpu { + return Ok(None); + } + let kind = announced.unwrap_or("accelerator"); + if forced { + return Err(CliError::BackendUnavailable(format!( + "{kind} was forced and selected, but its runtime attempt on this model failed and \ + the generation ran on CPU. Refusing to report that as success: re-run with \ + --verbose for the backend's reason, or with --no-gpu to run on CPU deliberately \ + (R-0b, #3002; the pre-generation refusal is #3042)." + ))); + } + Ok(Some(format!( + "selected: cpu (fallback: the {kind} attempt failed on this model at runtime; \ + re-run with --verbose for its reason)" + ))) +} + +/// The `parity:` line a CUDA load site prints from its admission record +/// (REG-15, L0-1a): the companion of the `selected:` line, printed once the +/// gate has actually run on this model. +#[must_use] +pub fn parity_line( + status: &str, + cosine: Option, + positions: usize, + threshold: f32, + basis: &str, +) -> String { + match cosine { + Some(c) => format!( + "parity: {status} cosine={c:.6} positions={positions} threshold={threshold} basis={basis}" + ), + None => format!("parity: {status} positions={positions} threshold={threshold} basis={basis}"), + } +} +#[cfg(feature = "inference")] +pub use real::{build_has_accelerator_in, compiled_in, current, load, resolve_in}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_gpu_wins_and_the_backend_flag_names_its_kind() { + assert_eq!( + Request { + gpu: true, + no_gpu: true, + ..Default::default() + } + .wanted(), + Wanted::Cpu + ); + assert_eq!( + Request { + backend: Some("cpu"), + gpu: true, + ..Default::default() + } + .wanted(), + Wanted::Cpu + ); + assert_eq!( + Request { + backend: Some("cuda"), + ..Default::default() + } + .wanted(), + Wanted::Kind("cuda") + ); + assert_eq!( + Request { + gpu: true, + ..Default::default() + } + .wanted(), + Wanted::AnyAccelerator + ); + assert_eq!( + Request { + layers_want_accelerator: true, + ..Default::default() + } + .wanted(), + Wanted::AnyAccelerator + ); + assert_eq!(Request::default().wanted(), Wanted::Default); + } + + #[test] + fn the_selected_line_names_the_kind_the_device_and_the_reason() { + let r = Resolved { + kind: "cuda", + device_index: Some(0), + device_uid: None, + device_name: "NVIDIA X".into(), + reason: "first Ready".into(), + discovered_at_unix: 1, + }; + assert_eq!( + selected_line(&r, Some("parity: PASS cosine=0.9998 positions=78")), + "selected: cuda device[0]=NVIDIA X (parity: PASS cosine=0.9998 positions=78; registry: first Ready)" + ); + let c = Resolved { + kind: "cpu", + device_index: None, + device_uid: None, + device_name: "host cpu".into(), + reason: "--no-gpu: cpu requested".into(), + discovered_at_unix: 0, + }; + assert_eq!( + selected_line(&c, None), + "selected: cpu (registry: --no-gpu: cpu requested)" + ); + } + + #[test] + fn a_forced_accelerator_that_fell_to_cpu_at_runtime_is_refused_never_reported_as_success() { + // measured 2026-09-07: `apr run --gpu` announced wgpu, wgpu failed on a Q6_K + // tensor, the run finished on CPU and exited 0 — this is that refusal. + let e = after_generation(true, Some("wgpu"), Some(false)).expect_err("forced ⇒ refuse"); + assert!(matches!(e, CliError::BackendUnavailable(_)), "{e}"); + assert_eq!(e.exit_code_value(), 14); + // a default selection that fell to CPU is corrected out loud, not refused + let line = after_generation(false, Some("wgpu"), Some(false)).expect("default ⇒ a line"); + assert!(line.as_deref().is_some_and(|l| l.starts_with("selected: cpu (fallback")), "{line:?}"); + // cpu announced, cpu ran: nothing to say; a run that reports no used_gpu: nothing to say + assert_eq!(after_generation(true, Some("cpu"), Some(false)).expect("ok"), None); + assert_eq!(after_generation(true, Some("cuda"), None).expect("ok"), None); + // the accelerator actually ran: nothing to say + assert_eq!(after_generation(true, Some("cuda"), Some(true)).expect("ok"), None); + } + + #[test] + fn a_forced_accelerator_on_a_build_without_one_is_feature_disabled_never_cpu() { + // On every build the stub answers; on inference builds the machine registry answers. + let r = resolve( + &Request { + backend: Some("metal"), + ..Default::default() + }, + "--backend metal", + ); + match r { + Ok(res) => assert_eq!( + res.kind, "metal", + "a Ready metal is the only way to get one" + ), + Err(e) => assert!( + matches!( + e, + CliError::FeatureDisabled(_) | CliError::BackendUnavailable(_) + ), + "{e}" + ), + } + } +} diff --git a/crates/apr-cli/tests/backend_refusal_case_table.rs b/crates/apr-cli/tests/backend_refusal_case_table.rs new file mode 100644 index 0000000000..4d15dfa1bb --- /dev/null +++ b/crates/apr-cli/tests/backend_refusal_case_table.rs @@ -0,0 +1,130 @@ +//! R-0b (#3002, PMAT-1073, PP-066 claim 1): the resolution case table. +//! +//! Rows are generated from {host facts, via APR_REGISTRY_FIXTURE} × BACKEND_VALUES +//! ∪ {--gpu, --no-gpu, default}. The rule under test, over `apr_cli::registry`: +//! +//! * a forced accelerator (`--gpu`, `--backend cuda|wgpu`) never resolves to +//! cpu — it returns the selected kind or an Err; +//! * a request the build did not compile is `FeatureDisabled` (exit 9); +//! * a request the build compiled but this host has not Ready is +//! `BackendUnavailable` (exit 14); +//! * `--no-gpu` / `--backend cpu` / default-on-a-cpu-host resolve to cpu. +//! +//! The fixtures are R-0a's: cpu-only (nothing Ready but cpu), one-cuda (a Ready +//! cuda), two-vendors (a Ready cuda and a Ready wgpu). A build without the +//! `inference` feature has no registry crate; the stub answers cpu / refuse and +//! is covered by the unit tests in registry.rs. +#![cfg(feature = "inference")] + +use apr_cli::error::CliError; +use apr_cli::registry::{resolve_in, Request}; +use trueno::registry::BackendRegistry; + +fn reg(fixture: &str) -> BackendRegistry { + let path = format!( + "{}/tests/fixtures/registry/{fixture}.json", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + BackendRegistry::from_fixture_json(&text, &path).expect("fixture parses") +} + +fn req(gpu: bool, no_gpu: bool, backend: Option<&str>) -> Request<'_> { + Request { + gpu, + no_gpu, + backend, + layers_want_accelerator: false, + } +} + +#[test] +fn a_forced_accelerator_never_resolves_to_cpu() { + // one-cuda: cuda is Ready → --gpu and --backend cuda select cuda, not cpu. + let r = reg("one-cuda"); + for (asked, request) in [ + ("--gpu", req(true, false, None)), + ("--backend cuda", req(false, false, Some("cuda"))), + ] { + let sel = resolve_in(&request, asked, &r).expect("cuda is Ready in one-cuda"); + assert_eq!(sel.kind, "cuda", "{asked} must select cuda, never cpu"); + } +} + +#[test] +fn a_forced_accelerator_with_none_ready_refuses_and_never_downgrades() { + // cpu-only: cuda dlopen present but no device; wgpu compiled; nothing Ready. + let r = reg("cpu-only"); + for (asked, request) in [ + ("--gpu", req(true, false, None)), + ("--backend cuda", req(false, false, Some("cuda"))), + ("--backend wgpu", req(false, false, Some("wgpu"))), + ] { + let e = resolve_in(&request, asked, &r).expect_err("nothing Ready — must refuse, not cpu"); + assert!( + matches!( + e, + CliError::FeatureDisabled(_) | CliError::BackendUnavailable(_) + ), + "{asked} must refuse with a backend error, got {e}" + ); + // exit code is 9 (not compiled) or 14 (not Ready) — never 0, never a cpu run. + assert!( + matches!(e.exit_code_value(), 9 | 14), + "{asked}: exit {} not a refusal", + e.exit_code_value() + ); + } +} + +#[test] +fn cpu_and_no_gpu_and_default_resolve_to_cpu() { + let r = reg("cpu-only"); + for (asked, request) in [ + ("--no-gpu", req(true, true, None)), // --no-gpu wins over --gpu + ("--backend cpu", req(false, false, Some("cpu"))), + ("default", req(false, false, None)), // no Ready accelerator → cpu + ] { + let sel = resolve_in(&request, asked, &r).expect("cpu always resolves"); + assert_eq!(sel.kind, "cpu", "{asked} must resolve to cpu"); + assert!( + !sel.reason.is_empty(), + "{asked}: the cpu selection must say why" + ); + } +} + +#[test] +fn the_default_takes_a_ready_accelerator_when_there_is_one() { + // two-vendors: cuda Ready first → default selects it (REG-8), no flag needed. + let sel = + resolve_in(&req(false, false, None), "default", ®("two-vendors")).expect("resolves"); + assert_ne!( + sel.kind, "cpu", + "a Ready accelerator is the default when present" + ); +} + +#[test] +fn every_backend_value_is_resolvable_on_every_fixture_and_forced_gpu_is_never_cpu() { + for fixture in ["cpu-only", "one-cuda", "two-vendors"] { + let r = reg(fixture); + for backend in apr_cli::BACKEND_VALUES { + let asked = format!("--backend {backend}"); + match resolve_in(&req(false, false, Some(backend)), &asked, &r) { + Ok(sel) if backend == "cpu" => assert_eq!(sel.kind, "cpu"), + Ok(sel) => assert_eq!(sel.kind, backend, "{fixture}/{asked}: Ready ⇒ that kind"), + Err(e) => { + assert_ne!(backend, "cpu", "{fixture}: cpu never refuses"); + assert!( + matches!( + e, + CliError::FeatureDisabled(_) | CliError::BackendUnavailable(_) + ), + "{fixture}/{asked}: {e}" + ); + } + } + } + } +} diff --git a/crates/apr-cli/tests/fixtures/registry/no-accelerator-compiled.json b/crates/apr-cli/tests/fixtures/registry/no-accelerator-compiled.json new file mode 100644 index 0000000000..b47a5f8ffd --- /dev/null +++ b/crates/apr-cli/tests/fixtures/registry/no-accelerator-compiled.json @@ -0,0 +1,142 @@ +{ + "schema": "apr-devices-v1", + "discovered_at_unix": 1788600000, + "source": "fixture: no accelerator compiled into this build (R-0b test twin of cpu-only)", + "reserve_bytes": 3758096384, + "reserve_basis": "[U] default until master row 6 measures vram_peak", + "entries": [ + { + "kind": "cpu", + "api": "cpu", + "device_index": null, + "device_uid": "host-cpu", + "device_name": "x86_64 host cpu, 8 threads", + "vendor": "host", + "vendor_id": null, + "device_type": "cpu", + "mem_total": 34359738368, + "mem_free": null, + "mem_kind": { + "kind": "unified", + "working_set_limit": null + }, + "compute_class": "avx2", + "caps": [], + "source": { + "kind": "compiled-in" + }, + "status": { + "state": "ready" + }, + "transport": null + }, + { + "kind": "cuda", + "api": "cuda-driver", + "device_index": null, + "device_uid": null, + "device_name": "", + "vendor": "", + "vendor_id": null, + "device_type": "", + "mem_total": null, + "mem_free": null, + "mem_kind": { + "kind": "discrete" + }, + "compute_class": null, + "caps": [], + "source": { + "kind": "not-compiled" + }, + "status": { + "state": "unavailable", + "kind": "not-compiled" + }, + "transport": null + }, + { + "kind": "wgpu", + "api": "wgpu", + "device_index": null, + "device_uid": null, + "device_name": "", + "vendor": "", + "vendor_id": null, + "device_type": "", + "mem_total": null, + "mem_free": null, + "mem_kind": { + "kind": "discrete" + }, + "compute_class": null, + "caps": [], + "source": { + "kind": "not-compiled" + }, + "status": { + "state": "unavailable", + "kind": "not-compiled" + }, + "transport": null + }, + { + "kind": "metal", + "api": "metal", + "device_index": null, + "device_uid": null, + "device_name": "", + "vendor": "", + "vendor_id": null, + "device_type": "", + "mem_total": null, + "mem_free": null, + "mem_kind": { + "kind": "discrete" + }, + "compute_class": null, + "caps": [], + "source": { + "kind": "not-compiled" + }, + "status": { + "state": "unavailable", + "kind": "no-backend", + "vendor": "no native Metal backend in 0.66 (a Metal adapter appears under wgpu)" + }, + "transport": null + }, + { + "kind": "hip", + "api": "hip", + "device_index": null, + "device_uid": null, + "device_name": "", + "vendor": "", + "vendor_id": null, + "device_type": "", + "mem_total": null, + "mem_free": null, + "mem_kind": { + "kind": "discrete" + }, + "compute_class": null, + "caps": [], + "source": { + "kind": "not-compiled" + }, + "status": { + "state": "unavailable", + "kind": "no-backend", + "vendor": "no HIP backend in 0.66" + }, + "transport": null + } + ], + "selected": { + "kind": "cpu", + "device_index": null, + "device_uid": null, + "reason": "fixture; recomputed on load" + } +} \ No newline at end of file diff --git a/crates/aprender-serve/src/api/effective_config.rs b/crates/aprender-serve/src/api/effective_config.rs index 6a27999365..0e57e7bb08 100644 --- a/crates/aprender-serve/src/api/effective_config.rs +++ b/crates/aprender-serve/src/api/effective_config.rs @@ -494,6 +494,52 @@ pub struct KvReport { // The response // --------------------------------------------------------------------------- +/// R-0b (#3002, REG-12): the backend apr-cli RESOLVED at startup — the registry +/// Selection with its reason and `discovered_at` — set once per process by the +/// serve gate and reported beside the residency-MEASURED `compute_class` so a +/// reader can cross-check the two. `matches_loaded` is `null` until a model is +/// resident (nothing to compare), then `kind == compute_class`. +#[derive(Debug, Clone, Serialize)] +pub struct BackendResolution { + /// `cpu`, `cuda`, `wgpu`, `metal`, `hip` — the registry kind selected. + pub kind: String, + /// Device index within the kind, when a physical device was selected. + pub device_index: Option, + /// Stable device identity (REG-9), when known. + pub device_uid: Option, + /// Human name of the selected device (`host cpu` for cpu). + pub device_name: String, + /// Why this selection (REG-8): the registry's reason or the request. + pub reason: String, + /// When the registry was discovered (unix seconds; REG-12). + pub discovered_at_unix: u64, + /// Who resolved it and when (the launcher names itself). + pub basis: String, + /// `kind == compute_class` once a model is resident; `null` before. + pub matches_loaded: Option, +} + +static BACKEND_RESOLUTION: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Record the startup resolution. `false` when one was already recorded: the +/// first wins, and a second call is a caller defect, never a silent overwrite. +pub fn set_backend_resolution(r: BackendResolution) -> bool { + BACKEND_RESOLUTION.set(r).is_ok() +} + +/// The recorded startup resolution, if the launcher recorded one. +#[must_use] +pub fn backend_resolution() -> Option { + BACKEND_RESOLUTION.get().cloned() +} + +fn resolved_report(compute_class: &str) -> Option { + backend_resolution().map(|mut r| { + r.matches_loaded = (compute_class != "unknown").then(|| r.kind == compute_class); + r + }) +} + /// Body of `GET /v1/effective-config`. /// /// The key set is IDENTICAL on every build. `cuda` is `null` on a build without @@ -531,6 +577,9 @@ pub struct EffectiveConfigResponse { /// `true` when a live field could not be read because the model lock was /// held (PMAT-073). The affected blocks are absent, never guessed. pub lock_contended: bool, + /// R-0b (#3002): the startup backend resolution (`null` when the launcher + /// recorded none), with `matches_loaded` against `compute_class`. + pub resolved: Option, } /// `realizar`'s own compile-time feature set. @@ -657,7 +706,8 @@ fn parity_report(_state: &AppState) -> ParityReport { pub fn effective_config(state: &AppState) -> EffectiveConfigResponse { let effective = state.effective_config_state(); let cuda_snapshot = cuda_snapshot(state); - EffectiveConfigResponse { + let mut resp = EffectiveConfigResponse { + resolved: None, schema_version: EFFECTIVE_CONFIG_SCHEMA_VERSION, parity: parity_report(state), server: { @@ -687,7 +737,9 @@ pub fn effective_config(state: &AppState) -> EffectiveConfigResponse { cuda: cuda_snapshot.cuda, kv: cuda_snapshot.kv, lock_contended: cuda_snapshot.lock_contended, - } + }; + resp.resolved = resolved_report(resp.compute_class); + resp } /// The live half of the body: everything that needs the model lock. diff --git a/crates/aprender-serve/src/api/tests/effective_config_route_pp2.rs b/crates/aprender-serve/src/api/tests/effective_config_route_pp2.rs index 7b8d99347f..01a59f6268 100644 --- a/crates/aprender-serve/src/api/tests/effective_config_route_pp2.rs +++ b/crates/aprender-serve/src/api/tests/effective_config_route_pp2.rs @@ -45,7 +45,7 @@ async fn get_json(state: AppState, uri: &str) -> (StatusCode, serde_json::Value) /// The key set every build must serve. Written out rather than derived, so a /// field that silently disappears is a test failure and not a shrinking loop. -const REQUIRED_TOP_LEVEL_KEYS: [&str; 13] = [ +const REQUIRED_TOP_LEVEL_KEYS: [&str; 14] = [ "schema_version", // REG-15 / L0-1a (#2971): the load-time parity record {status, cosine, positions, // threshold, basis} — present on every build (`not-run` without the cuda feature). @@ -61,6 +61,9 @@ const REQUIRED_TOP_LEVEL_KEYS: [&str; 13] = [ "cuda", "kv", "lock_contended", + // R-0b (#3002): the startup backend resolution (`null` until the launcher + // records one) — the key is always present. + "resolved", ]; // --------------------------------------------------------------------------- @@ -104,6 +107,34 @@ async fn effective_config_answers_on_a_model_less_server() { assert_eq!(body["model"]["loaded"].as_bool(), Some(false)); } +/// R-0b (#3002, REG-12): the backend the launcher resolved at startup is +/// reported beside the residency-measured class; on a model-less server there +/// is nothing to compare, so `matches_loaded` is null, not `true`. +#[tokio::test] +async fn effective_config_reports_the_startup_backend_resolution() { + use crate::api::effective_config::{set_backend_resolution, BackendResolution}; + let _ = set_backend_resolution(BackendResolution { + kind: "cpu".to_string(), + device_index: None, + device_uid: None, + device_name: "host cpu".to_string(), + reason: "test: --no-gpu: cpu requested".to_string(), + discovered_at_unix: 1, + basis: "test".to_string(), + matches_loaded: None, + }); + let state = AppState::demo_mock().expect("model-less AppState"); + let (status, body) = get_json(state, "/v1/effective-config").await; + assert_eq!(status, StatusCode::OK, "got {status} with {body}"); + let r = &body["resolved"]; + assert_eq!(r["kind"].as_str(), Some("cpu"), "{body}"); + assert_eq!(r["discovered_at_unix"].as_u64(), Some(1), "REG-12: {body}"); + assert!( + r["matches_loaded"].is_null(), + "nothing resident ⇒ nothing to compare, and that is null, not true:\n{body}" + ); +} + /// The route is DERIVED from the same table `create_router` mounts, so it is /// advertised by `GET /` and by the 404 body. A mounted-but-unadvertised route /// is the #2376(12) defect: a client following the error message finds nothing. diff --git a/docs/audits/surface_audit.csv b/docs/audits/surface_audit.csv index 5a4c579843..fdd7b30223 100644 --- a/docs/audits/surface_audit.csv +++ b/docs/audits/surface_audit.csv @@ -86,7 +86,7 @@ apr,apr attn-parity-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/ apr,apr attn-viz-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1020,high apr,apr audio-inspect-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:983,high apr,apr awq-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:877,high -apr,apr beat-run,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:418,high +apr,apr beat-run,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:424,high apr,apr bench,6,x86_64-linux,llama.cpp,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:88,high apr,apr canary check,4,x86_64-linux,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands/canary.rs:44,medium apr,apr canary create,4,x86_64-linux,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands/canary.rs:30,medium @@ -116,18 +116,18 @@ apr,apr cgp profile wgpu,6,x86_64-linux,NONE,no,9,profiling,crates/aprender-cgp/ apr,apr cgp roofline,6,x86_64-linux,NONE,no,7,qa-cgp,crates/aprender-cgp/src/cli.rs:64,low apr,apr cgp trace,6,x86_64-linux,NONE,no,7,qa-cgp,crates/aprender-cgp/src/cli.rs:99,low apr,apr cgp tui,6,x86_64-linux,NONE,no,7,qa-cgp,crates/aprender-cgp/src/cli.rs:115,low -apr,apr chat,6,x86_64-linux,llama.cpp,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:40 (ACCELERATOR_GUARD crates/apr-cli/src/dispatch_analysis.rs:1699),high -apr,apr chat --backend cpu,6,x86_64-linux,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),high -apr,apr chat --backend cuda,6,x86_64-linux;nvidia-cuda,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),medium -apr,apr chat --backend wgpu,6,UNKNOWN,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),high -apr,apr check,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:770,high +apr,apr chat,6,x86_64-linux,llama.cpp,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:40 (ACCELERATOR_GUARD crates/apr-cli/src/dispatch_analysis.rs:1696),high +apr,apr chat --backend cpu,6,x86_64-linux,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),high +apr,apr chat --backend cuda,6,x86_64-linux;nvidia-cuda,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),medium +apr,apr chat --backend wgpu,6,UNKNOWN,ollama,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:85 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),high +apr,apr check,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:776,high apr,apr check-finite-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1040,low -apr,apr code,2,x86_64-linux,claude-code,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:790,high -apr,apr code --input-format json,2,x86_64-linux,claude-code,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:793 (CodeInputFormat crates/apr-cli/src/commands_enum.rs:37),high -apr,apr code --output-format json,2,x86_64-linux,claude-code,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:787 (CodeOutputFormat crates/apr-cli/src/commands_enum.rs:25),high +apr,apr code,2,x86_64-linux,claude-code,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:796,high +apr,apr code --input-format json,2,x86_64-linux,claude-code,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:799 (CodeInputFormat crates/apr-cli/src/commands_enum.rs:43),high +apr,apr code --output-format json,2,x86_64-linux,claude-code,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:793 (CodeOutputFormat crates/apr-cli/src/commands_enum.rs:31),high apr,apr compare-hf,9,x86_64-linux,safetensors,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:563,medium -apr,apr compile,9,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:672,medium -apr,apr convert,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:594,high +apr,apr compile,9,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:678,medium +apr,apr convert,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:600,high apr,apr data audit,3,x86_64-linux,huggingface-transformers,no,4,data-pipeline,crates/apr-cli/src/data_commands.rs:54,high apr,apr data balance,6,x86_64-linux,huggingface-transformers,no,4,data-pipeline,crates/apr-cli/src/data_commands.rs:126,high apr,apr data decontaminate,2,x86_64-linux,huggingface-transformers,no,4,data-pipeline,crates/apr-cli/src/data_commands.rs:96,high @@ -170,12 +170,12 @@ apr,apr data x schema,6,x86_64-linux,huggingface-transformers,no,4,data-pipeline apr,apr data x view,6,x86_64-linux,huggingface-transformers,no,4,data-pipeline,crates/aprender-data/src/cli/mod.rs:140,low apr,apr dataset audio-inspect,6,x86_64-linux,huggingface-transformers,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1440,low apr,apr ddp-metrics-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:952,high -apr,apr debug,3,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:249,high -apr,apr debug embed-viz,6,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:902,low +apr,apr debug,3,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:255,high +apr,apr debug embed-viz,6,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:908,low apr,apr decrypt,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/tool_commands.rs:140,medium apr,apr devices,8,x86_64-linux;nvidia-cuda,ollama,yes,10,apr-core-commands,crates/apr-cli/src/extended_commands.rs:636,high apr,apr diagnose,9,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:833,medium -apr,apr diff,4,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:302,high +apr,apr diff,4,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:308,high apr,apr distill,9,x86_64-linux,unsloth,yes,10,apr-core-commands,crates/apr-cli/src/model_ops_commands.rs:143,medium apr,apr dry-sampling-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:871,high apr,apr embed,6,x86_64-linux,huggingface-transformers,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1357,medium @@ -194,36 +194,36 @@ apr,apr eval --task mbpp,6,x86_64-linux,NONE,no,8,rag-eval,crates/apr-cli/src/di apr,apr eval --task plan,6,x86_64-linux,NONE,no,8,rag-eval,crates/apr-cli/src/dispatch_analysis.rs:1430 (flag crates/apr-cli/src/extended_commands.rs:139),high apr,apr eval --task verify,6,x86_64-linux,NONE,no,8,rag-eval,crates/apr-cli/src/dispatch_analysis.rs:1427 (flag crates/apr-cli/src/extended_commands.rs:139),high apr,apr experiment view,6,x86_64-linux,pytorch,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1567,low -apr,apr explain,6,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:437,high +apr,apr explain,6,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:443,high apr,apr explain-token-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1065,low -apr,apr export,6,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:466,high -apr,apr export --format coreml,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high -apr,apr export --format gguf,6,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high -apr,apr export --format mlx,6,x86_64-linux;nvidia-cuda,huggingface-transformers,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high -apr,apr export --format onnx,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high -apr,apr export --format openvino,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high -apr,apr export --format safetensors,6,x86_64-linux;nvidia-cuda,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:471,high +apr,apr export,6,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:472,high +apr,apr export --format coreml,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high +apr,apr export --format gguf,6,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high +apr,apr export --format mlx,6,x86_64-linux;nvidia-cuda,huggingface-transformers,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high +apr,apr export --format onnx,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high +apr,apr export --format openvino,6,x86_64-linux;nvidia-cuda,pytorch,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high +apr,apr export --format safetensors,6,x86_64-linux;nvidia-cuda,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:477,high apr,apr finetune,2,x86_64-linux,unsloth,yes,10,apr-core-commands,crates/apr-cli/src/model_ops_commands.rs:6,high apr,apr finetune --task classify,2,x86_64-linux,unsloth,no,10,apr-core-commands,crates/apr-cli/src/model_ops_commands.rs:48,high apr,apr flow,6,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:667,high apr,apr fp8-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:883,high apr,apr gbnf-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1130,high apr,apr gptq-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:895,high -apr,apr gpu,6,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:783,high +apr,apr gpu,6,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:789,high apr,apr gpu-memtrace-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1078,low apr,apr grad-norm,6,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1143,high apr,apr hang-trace-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:934,high apr,apr hex,3,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:587,high apr,apr imatrix-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1166,high -apr,apr import,6,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:496,high -apr,apr inspect,3,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:214,high +apr,apr import,6,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:502,high +apr,apr inspect,3,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:220,high apr,apr kernel parity,6,x86_64-linux,NONE,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1471,low apr,apr kv-timeline-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1084,high -apr,apr lint,2,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:403,high -apr,apr list,6,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:585,medium -apr,apr manifest,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:428,medium -apr,apr mcp,1,x86_64-linux,claude-code,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:762,high -apr,apr merge,6,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:699,high +apr,apr lint,2,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:409,high +apr,apr list,6,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:591,medium +apr,apr manifest,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:434,medium +apr,apr mcp,1,x86_64-linux,claude-code,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:768,high +apr,apr merge,6,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:705,high apr,apr modelfile parse,6,x86_64-linux,NONE,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1589,low apr,apr monitor,9,x86_64-linux,pytorch,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:448,medium apr,apr mono archive,6,UNKNOWN,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands/mono.rs:38,high @@ -253,7 +253,7 @@ apr,apr prune,4,x86_64-linux,unsloth,yes,10,apr-core-commands,crates/apr-cli/src apr,apr ptx,4,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:362,high apr,apr ptx-map,6,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:330,high apr,apr publish,6,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/tool_commands.rs:46,high -apr,apr pull,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:531,high +apr,apr pull,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:537,high apr,apr pv audit,6,x86_64-linux,NONE,no,6,contracts-pv,crates/aprender-contracts-cli/src/cli.rs:67,low apr,apr pv book,6,x86_64-linux,NONE,no,6,contracts-pv,crates/aprender-contracts-cli/src/cli.rs:308,low apr,apr pv certify,6,x86_64-linux,NONE,no,6,contracts-pv,crates/aprender-contracts-cli/src/cli.rs:389,low @@ -295,11 +295,11 @@ apr,apr pv verify-structure,6,x86_64-linux,NONE,no,6,contracts-pv,crates/aprende apr,apr qa,6,x86_64-linux;nvidia-cuda,llama.cpp,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:238,high apr,apr qualify,4,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:685,high apr,apr quant-preservation-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1204,high -apr,apr quantize,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:732,high -apr,apr quantize --scheme fp16,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:737,high -apr,apr quantize --scheme int4,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:737,high -apr,apr quantize --scheme int8,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:737,high -apr,apr quantize --scheme q4k,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:737,high +apr,apr quantize,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:738,high +apr,apr quantize --scheme fp16,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:743,high +apr,apr quantize --scheme int4,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:743,high +apr,apr quantize --scheme int8,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:743,high +apr,apr quantize --scheme q4k,4,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:743,high apr,apr rag demo,6,x86_64-linux,huggingface-transformers,no,8,rag-eval,crates/aprender-rag-cli/src/lib.rs:107,low apr,apr rag eval compare,6,UNKNOWN,huggingface-transformers,no,8,rag-eval,crates/aprender-rag-cli/src/lib.rs:454,high apr,apr rag eval gate,6,UNKNOWN,huggingface-transformers,no,8,rag-eval,crates/aprender-rag-cli/src/lib.rs:465,high @@ -317,7 +317,7 @@ apr,apr react-trace-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/ apr,apr registry aliases,6,x86_64-linux,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands/registry.rs:12,high apr,apr registry-quota-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1160,high apr,apr rerank,6,x86_64-linux,huggingface-transformers,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1252,high -apr,apr rm,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:588,medium +apr,apr rm,4,x86_64-linux,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:594,medium apr,apr rm-gc-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1185,high apr,apr rosetta chain,6,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands/rosetta.rs:67,low apr,apr rosetta compare-inference,2,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands/rosetta.rs:106,high @@ -327,19 +327,19 @@ apr,apr rosetta fingerprint,2,x86_64-linux,safetensors,no,10,apr-core-commands,c apr,apr rosetta inspect,6,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands/rosetta.rs:25,low apr,apr rosetta validate-stats,6,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands/rosetta.rs:203,low apr,apr rosetta verify,2,x86_64-linux,safetensors,no,10,apr-core-commands,crates/apr-cli/src/commands/rosetta.rs:86,high -apr,apr run,2,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:99,high -apr,apr run --backend cpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:154 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),high -apr,apr run --backend cuda,4,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:154 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),medium -apr,apr run --backend wgpu,2,UNKNOWN,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:154 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:10),high -apr,apr run --gpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:75,high -apr,apr run --no-gpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:84,high -apr,apr run --stream,2,x86_64-linux;nvidia-cuda,ollama,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:117,high -apr,apr run --task transcribe,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:107,high -apr,apr run --task translate,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:107,high -apr,apr run --trace-level basic,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:104 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:17),high -apr,apr run --trace-level chrome,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:104 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:17),high -apr,apr run --trace-level layer,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:104 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:17),high -apr,apr run --trace-level payload,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:104 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:17),high +apr,apr run,2,x86_64-linux;nvidia-cuda,llama.cpp,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:105,high +apr,apr run --backend cpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:160 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),high +apr,apr run --backend cuda,4,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:160 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),medium +apr,apr run --backend wgpu,2,UNKNOWN,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:160 (BACKEND_VALUES crates/apr-cli/src/commands_enum.rs:16),high +apr,apr run --gpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:81,high +apr,apr run --no-gpu,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:90,high +apr,apr run --stream,2,x86_64-linux;nvidia-cuda,ollama,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:123,high +apr,apr run --task transcribe,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:113,high +apr,apr run --task translate,2,x86_64-linux;nvidia-cuda,llama.cpp,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:113,high +apr,apr run --trace-level basic,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:110 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:23),high +apr,apr run --trace-level chrome,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:110 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:23),high +apr,apr run --trace-level layer,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:110 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:23),high +apr,apr run --trace-level payload,2,x86_64-linux;nvidia-cuda,NONE,no,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:110 (TRACE_LEVEL_VALUES crates/apr-cli/src/commands_enum.rs:23),high apr,apr runs diff,6,x86_64-linux,pytorch,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1543,low apr,apr runs ls,4,x86_64-linux,pytorch,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1510,high apr,apr runs show,6,x86_64-linux,pytorch,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1528,low @@ -362,8 +362,8 @@ apr,apr sim run,6,x86_64-linux,NONE,no,11,simulation,crates/aprender-simulate/sr apr,apr sim validate,6,x86_64-linux,NONE,no,11,simulation,crates/aprender-simulate/src/cli/args.rs:92,low apr,apr sim verify,6,x86_64-linux,NONE,no,11,simulation,crates/aprender-simulate/src/cli/args.rs:97,low apr,apr sim version,6,x86_64-linux,NONE,no,11,simulation,crates/aprender-simulate/src/cli/args.rs:119,low -apr,apr stamp,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:617,high -apr,apr tensors,4,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:341,high +apr,apr stamp,2,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:623,high +apr,apr tensors,4,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:347,high apr,apr test llm bench,6,x86_64-linux,vllm,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1670 (dispatch crates/apr-cli/src/commands/test_llm.rs:399),medium apr,apr test llm bench --band,6,x86_64-linux,vllm,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1785 (protocol crates/apr-cli/src/commands/test_llm_band.rs:348),medium apr,apr test tensor,4,x86_64-linux,NONE,no,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1622,high @@ -374,7 +374,7 @@ apr,apr tokenize plan,6,x86_64-linux,huggingface-transformers,no,10,apr-core-com apr,apr tokenize repair-manifest,6,x86_64-linux,huggingface-transformers,no,10,apr-core-commands,crates/apr-cli/src/tokenize_commands.rs:221,low apr,apr tokenize train,6,x86_64-linux,huggingface-transformers,no,10,apr-core-commands,crates/apr-cli/src/tokenize_commands.rs:63,low apr,apr tool-use-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1124,high -apr,apr trace,6,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:359,high +apr,apr trace,6,x86_64-linux,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:365,high apr,apr train apply,1,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-cli/src/train_commands.rs:76,high apr,apr train apply --task classify,1,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-cli/src/train_commands.rs:88,high apr,apr train apply --task pretrain,1,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-cli/src/train_commands.rs:88,high @@ -388,13 +388,13 @@ apr,apr train submit,6,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-c apr,apr train sweep,6,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-cli/src/train_commands.rs:201,high apr,apr train watch,6,x86_64-linux,pytorch,no,10,apr-core-commands,crates/apr-cli/src/train_commands.rs:172,low apr,apr tree,4,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:642,high -apr,apr tui,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:764,medium +apr,apr tui,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:770,medium apr,apr tune,4,x86_64-linux,pytorch,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:384,high apr,apr typical-p-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1136,high apr,apr unified-search-lint,1,x86_64-linux,NONE,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1179,high apr,apr unshard,9,x86_64-linux,llama.cpp,yes,1,apr-lint-diag,crates/apr-cli/src/extended_commands.rs:1228,medium -apr,apr validate,2,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:270,high -apr,apr validate-manifest,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:288,medium +apr,apr validate,2,x86_64-linux;nvidia-cuda,safetensors,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:276,high +apr,apr validate-manifest,9,x86_64-linux,NONE,yes,10,apr-core-commands,crates/apr-cli/src/commands_enum.rs:294,medium apr,apr zram benchmark,6,x86_64-linux,NONE,no,8,rag-eval,crates/aprender-zram-cli/src/lib.rs:47,low apr,apr zram create,6,x86_64-linux,NONE,no,8,rag-eval,crates/aprender-zram-cli/src/lib.rs:38,low apr,apr zram remove,6,x86_64-linux,NONE,no,8,rag-eval,crates/aprender-zram-cli/src/lib.rs:41,low diff --git a/scripts/check_backend_registry.sh b/scripts/check_backend_registry.sh new file mode 100755 index 0000000000..2882944ffa --- /dev/null +++ b/scripts/check_backend_registry.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# check_backend_registry.sh — R-0b (#3002, PMAT-1073, PP-066 claim 1 / C11): +# a backend DECISION in apr-cli reads the registry, never `cfg!(feature = …)`. +# +# bash scripts/check_backend_registry.sh # same as --static +# bash scripts/check_backend_registry.sh --static # 0 clean · 1 a cfg read leaked · 2 env +# bash scripts/check_backend_registry.sh --dir DIR # the same scan over DIR +# bash scripts/check_backend_registry.sh --self-test # case table, both polarities +# +# THE RULE. `cfg!(any(feature = "cuda"|"wgpu"))` decides, at COMPILE time, what +# a build can do. A binary built without cuda then reported success for `--gpu` +# and ran on CPU (aprender#2696) — the decision was `cfg!`, and the HOST it ran +# on never entered into it. R-0a made every backend a registry ENTRY (Ready / +# Unavailable(reason), with a source); R-0b routes every apr-cli backend +# decision AND every build-capability report through that registry. So under +# crates/apr-cli/src there is ZERO `cfg!(… feature = "cuda"|"wgpu")` outside +# registry.rs (the one module that owns the crate's reading of the feature set, +# inside its `#[cfg(feature = "inference")]` gate) and outside comments. +# +# NOT IN SCOPE: `cfg!(feature = "inference"|"training"|"cuda-batch")` — those +# are not backend kinds; the regex is anchored to cuda|wgpu. +# +# WHY A BARE INVOCATION IS THE GATE, AND NOT AN ARGUMENT-WIRED WORKFLOW STEP. +# scripts/guard_tree.sh (ci.yml `guard-runner-labels`) runs every tracked +# `scripts/check_*.sh` — BARE, plus `--self-test` when `--help` advertises one. +# A guard whose gate hides behind a flag would be run bare by that dispatcher, +# print usage, exit 2, and be a RED row for a guard that never ran. So no-arg IS +# `--static`; `--static` stays as the explicit spelling the contract cites. +# +# WHY grep AND NOT ripgrep. An earlier draft used `rg` and exited 2 (ENV) +# without it. No `scripts/check_*.sh` in this repo invokes `rg`, no workflow +# provisions ripgrep, and guard-runner-labels runs on `[self-hosted, Linux, +# clean-room]` — so that self-test would have been an env-death inside +# guard_tree. The regex is ERE, so `grep -rnE` is the same scan with no new +# runner dependency (pr-3041-split-plan.md decision D4b). +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +PROG=check_backend_registry +RE='cfg!\((any\()?[^)]*feature *= *"(cuda|wgpu)"' + +usage() { + printf 'usage: %s [--static] | --dir DIR | --self-test | --help\n' "$PROG" + printf ' no argument is the same gate as --static.\n' + printf ' --self-test runs the case table (both polarities).\n' +} + +# scan DIR -- print `file:line:code` for every BACKEND cfg! read that is not in +# registry.rs and not a comment. rc 2 (ENV) when DIR is missing; rc 0 otherwise, +# hits or not, so the caller decides -- a scan that reports "no hits" and +# "could not look" with the same status is the vacuity this repo keeps paying for. +# +# NEVER `producer | grep -q`: under `pipefail` a SIGPIPEd producer reads as a +# failure and a real match can come back a false negative (aprender +# feedback_sigpipe_pipefail_false_red). Every filter below is a `<<<` on a +# variable, and every one is `|| x=''` because a grep that matches NOTHING +# exits 1 and would kill this script under `set -e`. +scan() { + local dir=$1 raw='' kept='' code='' + [ -d "$dir" ] || { printf '%s: ENV - %s missing\n' "$PROG" "$dir" >&2; return 2; } + raw=$(grep -rnE --include='*.rs' -e "$RE" -- "$dir" 2>/dev/null) || raw='' + [ -n "$raw" ] || return 0 + # registry.rs owns the crate's ONE reading of the feature set. + kept=$(grep -vE '/registry\.rs:[0-9]+:' <<<"$raw") || kept='' + [ -n "$kept" ] || return 0 + # a line whose first non-space is // or * (so also ///, //!) is prose. + code=$(grep -vE '^[^:]*:[0-9]+:[[:space:]]*(//|\*)' <<<"$kept") || code='' + if [ -n "$code" ]; then printf '%s\n' "$code"; fi + return 0 +} + +# gate DIR -- 0 clean, 1 a backend cfg! read leaked, 2 env. +gate() { + local dir=$1 hits='' rc=0 + hits=$(scan "$dir") || rc=$? + [ "$rc" = 0 ] || return "$rc" + if [ -n "$hits" ]; then + printf 'FAIL a backend decision reads cfg!(feature = "cuda"|"wgpu") outside registry.rs:\n%s\n' "$hits" + printf ' route it through crate::registry (R-0b, #3002): resolve() / compiled() / build_has_accelerator().\n' + return 1 + fi + printf 'PASS no cfg!(feature = "cuda"|"wgpu") backend read in %s outside registry.rs\n' "$dir" + return 0 +} + +# The scratch tree is a GLOBAL, because the EXIT trap that removes it fires +# AFTER self_test has returned: a `local TD` is out of scope by then and +# `set -u` turns the cleanup into an "unbound variable" death -- which exits 1 +# after the case table has already printed `10/10 rows`. A guard whose own +# teardown can invert its verdict is the defect class it exists to catch. +TD="" +# shellcheck disable=SC2317 # reached only through the EXIT trap +cleanup() { case "${TD:-}" in *bereg.*) rm -rf -- "${TD:?}" ;; esac; } + +self_test() { + local n=0 red=0 + TD=$(mktemp -d "${TMPDIR:-/tmp}/bereg.XXXXXX") + trap cleanup EXIT + mkdir -p "$TD/src" + + row() { # row WANT LABEL CMD... + local want=$1 label=$2 rc=0 + n=$((n + 1)); shift 2 + "$@" >"$TD/o.$n" 2>&1 || rc=$? + if [ "$rc" = "$want" ]; then + printf 'ok row %-2s rc=%s %s\n' "$n" "$rc" "$label" + else + printf 'FAIL row %-2s rc=%s (want %s) %s\n' "$n" "$rc" "$want" "$label" + sed 's/^/ /' "$TD/o.$n"; red=1 + fi + } + row_says() { # row_says WANT NEEDLE LABEL CMD... -- status AND what it named + local want=$1 needle=$2 label=$3 + shift 3 + row "$want" "$label" "$@" + if [ "$(grep -c -F -- "$needle" "$TD/o.$n")" = 0 ]; then + printf 'FAIL row %-2s did not name `%s` %s\n' "$n" "$needle" "$label" + sed 's/^/ /' "$TD/o.$n"; red=1 + fi + } + + # --- a synthetic tree, both polarities ------------------------------- + printf 'fn f() { let _ = crate::registry::compiled("cuda"); }\n' >"$TD/src/a.rs" + printf '//! a comment mentioning cfg!(feature = "cuda") teaches the old spelling\n' >"$TD/src/b.rs" + printf 'pub fn compiled(k:&str)->bool{ cfg!(feature="cuda") || cfg!(feature="wgpu") }\n' >"$TD/src/registry.rs" + printf 'fn ok(){ let _ = cfg!(feature="inference"); }\n' >"$TD/src/c.rs" + row 0 "a tree that routes decisions through the registry is clean" \ + bash "$0" --dir "$TD/src" + printf 'fn bad(){ if cfg!(feature="cuda") { } }\n' >"$TD/src/leak.rs" + row_says 1 'leak.rs' "one cfg!(feature=cuda) backend read outside registry.rs is RED, by name" \ + bash "$0" --dir "$TD/src" + printf 'fn bad(){ if cfg!(any(feature="cuda", feature="wgpu")) { } }\n' >"$TD/src/leak.rs" + row 1 "the any(...) spelling is the same read and is RED too" \ + bash "$0" --dir "$TD/src" + rm -f "$TD/src/leak.rs" + row 0 "removing the leak returns to clean" bash "$0" --dir "$TD/src" + printf ' /// pass `cfg!(feature = "wgpu")` from the call site\n' >"$TD/src/doc.rs" + row 0 "a doc comment quoting the old spelling is prose, not a read" \ + bash "$0" --dir "$TD/src" + rm -f "$TD/src/doc.rs" + + # --- the modes themselves ------------------------------------------- + row 2 "a missing directory is ENV (2), never a silent PASS" \ + bash "$0" --dir "$TD/nope" + row 2 "an unknown argument is refused, never run as the default gate" \ + bash "$0" --statik + row_says 0 'self-test' "--help advertises the self-test guard_tree.sh probes for" \ + bash "$0" --help + + # --- THE REAL TREE, and the mutation that turns it RED --------------- + # A synthetic tree proves the scanner. Only the real one proves the CLAIM, + # and only a mutation of the real one proves the claim could fail. + local REALSRC="$ROOT/crates/apr-cli/src" + if [ -d "$REALSRC" ]; then + cp -r -- "$REALSRC" "$TD/real" + row 0 "crates/apr-cli/src itself is clean (this is what --static asserts)" \ + bash "$0" --dir "$TD/real" + # MUTATION: put the pre-R-0b compile-time read back at accel.rs's + # build_has_accelerator -- the exact line #2696 was decided by. + if [ -f "$TD/real/accel.rs" ]; then + printf 'fn mutant() -> bool { cfg!(any(feature = "cuda", feature = "wgpu")) }\n' \ + >>"$TD/real/accel.rs" + row_says 1 'accel.rs' \ + "MUTATION: cfg!(any(feature=cuda,wgpu)) back in accel.rs turns --static RED, naming the file" \ + bash "$0" --dir "$TD/real" + else + printf 'FAIL row -- accel.rs missing from the copied tree\n'; red=1 + fi + else + printf 'FAIL row -- ENV %s missing\n' "$REALSRC"; red=1 + fi + + printf '%s/%s rows\n' "$((n - red))" "$n" + [ "$red" = 0 ] || return 1 + return 0 +} + +# ARGUMENT VALIDATION BEFORE ANY WORK. A typo that falls through into the +# default gate tells a caller who believed they ran the case table that it +# PASSED (the shape check_no_claim_literals.sh grew this same guard for). +case "${1:-}" in + '' | --static) gate "$ROOT/crates/apr-cli/src" ;; + --dir) gate "${2:?--dir needs a directory}" ;; + --self-test) self_test ;; + -h | --help) usage ;; + *) printf 'unknown arg: %s\n' "$1" >&2; usage >&2; exit 2 ;; +esac diff --git a/scripts/claim_literal_baseline.txt b/scripts/claim_literal_baseline.txt index 20e0cbce9e..0248bda51a 100644 --- a/scripts/claim_literal_baseline.txt +++ b/scripts/claim_literal_baseline.txt @@ -191,7 +191,6 @@ crates/apr-cli/src/commands/run_tests_layer_trace.rs:56 crates/apr-cli/src/commands/serve/handler_gpu_completion.rs:362 crates/apr-cli/src/commands/serve/handler_gpu_completion.rs:363 crates/apr-cli/src/commands/serve/handlers_include_01.rs:4 -crates/apr-cli/src/commands/serve/mod.rs:209 crates/apr-cli/src/commands/speedup.rs:11 crates/apr-cli/src/commands/test_llm.rs:14 crates/apr-cli/src/commands/test_llm.rs:15 diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index b213eaced9..59358d53ca 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -17,10 +17,12 @@ apr-cli --lib commands::ptx_map apr-cli --lib commands::publish apr-cli --lib commands::pull apr-cli --lib commands::qualify +apr-cli --lib commands::run apr-cli --lib commands::serve apr-cli --lib commands::serve_plan apr-cli --lib commands::test_llm_band apr-cli --lib commands::tokenize +apr-cli --test backend_refusal_case_table apr-cli --test beat_apr_sibling_cli_reach apr-cli --test cli_commands apr-cli --test pixel_regression @@ -54,6 +56,7 @@ aprender-contracts-cli --lib contract_walk aprender-contracts-cli --test cli_integration aprender-contracts-cli --test ground_truth aprender-contracts-cli --test pv_surface_gate +aprender-contracts-cli --test pvl_zero_contracts aprender-core --lib format::metadata_bounds_contract_falsify aprender-core --lib format::model_family_loader aprender-core --lib format::ship_001 @@ -123,4 +126,3 @@ aprender-test-lib --lib perf_gate::witness aprender-train --lib hf_pipeline::export::pipeline aprender-train --lib models::llama_370m aprender-train --lib research::archive::tests -aprender-contracts-cli --test pvl_zero_contracts