From db8210a7ed651dd21be2c02395788fc04183853d Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 12:28:56 +0200 Subject: [PATCH 1/6] fix(orchestrate): apr_serve gated on native, but it needs realizar+libc from inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo check -p aprender-orchestrate --no-default-features --features agents was RED with 6 errors: apr_serve.rs calls super::realizar::parse_tool_calls_pub and libc::prctl/PR_SET_PDEATHSIG. Both come from `inference = ["realizar", "native", "libc"]`, but the module was gated `#[cfg(feature = "native")]` and `agents = ["native"]`. So every selection that turned on agents WITHOUT inference — agents, agents-browser, agents-rag, agents-mcp, agents-contracts — could not compile. The feature table already declares the intended split: `agents-inference = ["agents", "inference"]` is "Agent with local inference (RealizarDriver)". The gate just did not match the declaration. Re-gate driver::apr_serve on `inference` and give its call site in code.rs the same shape build_fallback_driver already has: a cfg twin returning None when inference is off, so the caller falls through to the embedded/mock driver exactly as it does when the `apr` binary is missing. No runtime behaviour change on any selection that already built. Found by the 430-pair (crate, feature) sweep behind night.yml — the nightly would have been born red on this. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- crates/aprender-orchestrate/src/agent/code.rs | 44 +++++++++++++------ .../src/agent/driver/mod.rs | 2 +- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/crates/aprender-orchestrate/src/agent/code.rs b/crates/aprender-orchestrate/src/agent/code.rs index c4b3d27cb4..11e828e735 100644 --- a/crates/aprender-orchestrate/src/agent/code.rs +++ b/crates/aprender-orchestrate/src/agent/code.rs @@ -431,19 +431,9 @@ pub fn cmd_code( // Falls back to embedded RealizarDriver if `apr` binary not found. // PMAT-CODE-SPAWN-PARITY-001: driver stored as Arc so TaskTool can // share it with the AgentPool for sub-agent execution. - let driver: Arc = if let Some(model_path) = manifest.model.resolve_model_path() { - match crate::agent::driver::apr_serve::AprServeDriver::launch( - model_path, - manifest.model.context_window, - ) { - Ok(d) => Arc::new(d), - Err(e) => { - eprintln!("⚠ apr serve unavailable ({e}), using embedded inference"); - Arc::from(build_fallback_driver(&manifest)?) - } - } - } else { - Arc::from(build_fallback_driver(&manifest)?) + let driver: Arc = match try_apr_serve_driver(&manifest) { + Some(d) => Arc::from(d), + None => Arc::from(build_fallback_driver(&manifest)?), }; // PMAT-CODE-MCP-JSON-LOADER-001: merge `/.mcp.json` (Claude-Code- @@ -638,6 +628,34 @@ fn apply_settings_to_manifest( Ok(()) } +/// PMAT-160: launch `apr serve` as the driver when local inference is compiled in. +/// +/// `driver::apr_serve` is gated on `inference`, not `native`: it parses +/// realizar's tool-call syntax and uses `libc::prctl` for parent-death +/// signalling, neither of which `agents` alone links. Without that feature +/// there is no server to launch, so the caller falls through to +/// [`build_fallback_driver`] exactly as it does when `apr` is not installed. +#[cfg(feature = "inference")] +fn try_apr_serve_driver(manifest: &AgentManifest) -> Option> { + let model_path = manifest.model.resolve_model_path()?; + match crate::agent::driver::apr_serve::AprServeDriver::launch( + model_path, + manifest.model.context_window, + ) { + Ok(d) => Some(Box::new(d)), + Err(e) => { + eprintln!("⚠ apr serve unavailable ({e}), using embedded inference"); + None + } + } +} + +/// Without `inference` there is no `AprServeDriver` — see the gated twin above. +#[cfg(not(feature = "inference"))] +fn try_apr_serve_driver(_manifest: &AgentManifest) -> Option> { + None +} + /// Build fallback driver (embedded RealizarDriver) when AprServeDriver unavailable. fn build_fallback_driver(manifest: &AgentManifest) -> anyhow::Result> { #[cfg(feature = "inference")] diff --git a/crates/aprender-orchestrate/src/agent/driver/mod.rs b/crates/aprender-orchestrate/src/agent/driver/mod.rs index ef10e7b0dc..ab1520d50f 100644 --- a/crates/aprender-orchestrate/src/agent/driver/mod.rs +++ b/crates/aprender-orchestrate/src/agent/driver/mod.rs @@ -4,7 +4,7 @@ //! loop and LLM inference backends. The default implementation is //! `RealizarDriver` (sovereign, local GGUF/APR inference). -#[cfg(feature = "native")] +#[cfg(feature = "inference")] pub mod apr_serve; pub mod chat_template; pub mod mock; From fd83e24a24d2f3ff5349cc935034df352b5fecf4 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 12:54:46 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(apr-cli):=20--no-default-features=20cou?= =?UTF-8?q?ld=20not=20compile=20=E2=80=94=208=20errors,=20dark=20since=20n?= =?UTF-8?q?othing=20builds=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md documents `--no-default-features` as how you build the CLI without inference. It has not compiled: `cargo check -p apr-cli --no-default-features` was RED with 8 errors across 7 files, and so were 7 single-feature selections (axum, code, dev, dhat-heap, futures-util, hf-hub, renacer) that inherit the same lib. Found by the 430-pair (crate, feature) sweep behind the night lane; the nightly would have been born red on this. Every error is the same shape — a DEFINITION carries #[cfg(feature = "inference")] and its CALLER does not. Nothing ever builds this crate without inference, so no gate ever saw it: * lib.rs serve_auth re-exported `apply` (gated, axum middleware) and `AuthGate` (ungated struct) on one ungated line. Split. * chat.rs `detect_format_from_bytes` was gated though it only reads four magic bytes and returns the ungated ModelFormat. Ungated, not spread. * profile_ollama.rs `ROOFLINE_AI_UNMEASURED` — a `&str` sentinel — was gated while the ungated print_roofline_section compares against it. Ungated. * test_llm.rs `dispatch` builds a tokio runtime: cfg twin on `tokio` that refuses by name instead of failing to link. * test_llm_band.rs `cool` sleeps between lanes; the sleep is gated and the no-tokio arm is unreachable (dispatch refuses first) rather than a silently skipped §5.1 cooldown. * diff_05_aprt_stage.rs reads APRT bodies through realizar save_tensor: fn and call site gated on `realizar`, with a named error on the other side. * explain.rs took realizar only for `find_sibling_file`. `apr explain` reports metadata and must build without inference, so it gets a cfg shim: realizar when linked, literal parent-dir sibling otherwise. Touching lib.rs and explain.rs made the pre-commit complexity gate refuse the commit on four PRE-EXISTING violations in those files' module trees, none of them in code this change wrote. Paid down in the same commit, as the gate requires — behaviour-preserving, and each extraction follows the split this file already documents for `CodeArgs`/`dispatch_code_command`: dispatch_runtime_commands 41 -> under `apr run` arm extracted whole dispatch_diagnostic_commands 30 -> under `apr trace` arm + save-tensor pre-dispatch extracted; the diff arm's doubled and_then nesting factored into with_resolved_pair resolve (help_producer) 73 -> under token loop -> a Walk state struct with one method per token kind explain_tensor 28 -> under the --json half extracted Both extracted arms re-match the command behind a `let ... else` that refuses by name; neither panics, and both are unreachable from their single call site. 11 selections now check clean (bare + the 7 broken + realizar/tokio/trueno/ inference); apr-cli --lib is 7242/7242, including the three resolver behavioural tests that cover the rewritten `resolve`; fmt and clippy clean on both default and bare. `--all-features` is still RED, on a separate pre-existing wgpu defect in finetune.rs, tracked on its own. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- crates/apr-cli/src/commands/chat.rs | 6 +- crates/apr-cli/src/commands/diff.rs | 9 + .../src/commands/diff_05_aprt_stage.rs | 1 + crates/apr-cli/src/commands/explain.rs | 113 ++-- crates/apr-cli/src/commands/profile_ollama.rs | 4 +- crates/apr-cli/src/commands/test_llm.rs | 10 + crates/apr-cli/src/commands/test_llm_band.rs | 6 + crates/apr-cli/src/dispatch.rs | 530 ++++++++++-------- crates/apr-cli/src/help_producer_truth.rs | 172 +++--- crates/apr-cli/src/lib.rs | 7 +- 10 files changed, 505 insertions(+), 353 deletions(-) diff --git a/crates/apr-cli/src/commands/chat.rs b/crates/apr-cli/src/commands/chat.rs index 05fe77bac2..9af3b799ac 100644 --- a/crates/apr-cli/src/commands/chat.rs +++ b/crates/apr-cli/src/commands/chat.rs @@ -502,7 +502,11 @@ fn clean_chat_response(raw: &str) -> String { } /// Detect format from magic bytes (more reliable than extension) -#[cfg(feature = "inference")] +/// +/// Not gated: this reads four magic bytes and returns the ungated +/// `ModelFormat`. It carried `#[cfg(feature = "inference")]` while its caller +/// `format_from_leading_bytes` did not, so `--no-default-features` could not +/// compile the module at all. fn detect_format_from_bytes(data: &[u8]) -> ModelFormat { if data.len() < 8 { return ModelFormat::Demo; diff --git a/crates/apr-cli/src/commands/diff.rs b/crates/apr-cli/src/commands/diff.rs index e8d77e1148..7ae64467c1 100644 --- a/crates/apr-cli/src/commands/diff.rs +++ b/crates/apr-cli/src/commands/diff.rs @@ -245,7 +245,16 @@ pub(crate) fn run( // and run an element-wise diff per `apr-cli-trace-save-tensor-v1` // `apr_diff_values_compat` invariant. if is_aprt_stage_file(path1) && is_aprt_stage_file(path2) { + // The APRT body is read by realizar's save_tensor reader; without + // it there is nothing to fall back to, so say which build is needed + // rather than mis-diffing the pair as models. + #[cfg(feature = "realizar")] return run_aprt_stage_diff(path1, path2, limit, json_output); + #[cfg(not(feature = "realizar"))] + return Err(CliError::InvalidFormat( + "APRT stage files need the save-tensor reader; rebuild with --features inference" + .to_string(), + )); } // Run tensor value comparison run_tensor_value_diff(path1, path2, filter, limit, transpose_aware, json_output) diff --git a/crates/apr-cli/src/commands/diff_05_aprt_stage.rs b/crates/apr-cli/src/commands/diff_05_aprt_stage.rs index 7dac722c9e..9baa84148e 100644 --- a/crates/apr-cli/src/commands/diff_05_aprt_stage.rs +++ b/crates/apr-cli/src/commands/diff_05_aprt_stage.rs @@ -91,6 +91,7 @@ fn compute_aprt_stage_stats( // serde_json::json!() macro uses infallible unwrap internally #[allow(clippy::disallowed_methods)] +#[cfg(feature = "realizar")] fn run_aprt_stage_diff( path1: &Path, path2: &Path, diff --git a/crates/apr-cli/src/commands/explain.rs b/crates/apr-cli/src/commands/explain.rs index ec9d43c293..6fc26d672e 100644 --- a/crates/apr-cli/src/commands/explain.rs +++ b/crates/apr-cli/src/commands/explain.rs @@ -6,6 +6,25 @@ use std::path::{Path, PathBuf}; /// The positional argument is auto-detected: if it looks like a file path /// (exists on disk or has a model file extension), it's treated as `--file`. /// Otherwise it's treated as an error code or family name. +/// Companion-file lookup for `apr explain`. +/// +/// realizar's `safetensors::find_sibling_file` handles hash-prefixed and +/// symlinked model paths (CB-510 / publish-safety), so it is used whenever +/// `inference` links realizar. `apr explain` reports metadata and does not +/// infer, so it must still build without that feature: the fallback is the +/// literal sibling in the parent directory, returned only when it exists so +/// callers see the same `Option` contract either way. +#[cfg(feature = "inference")] +fn find_sibling(path: &Path, name: &str) -> Option { + realizar::safetensors::find_sibling_file(path, name) +} + +#[cfg(not(feature = "inference"))] +fn find_sibling(path: &Path, name: &str) -> Option { + let candidate = path.parent()?.join(name); + candidate.exists().then_some(candidate) +} + #[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)] #[provable_contracts_macros::contract( "apr-cli-operations-v1", @@ -228,7 +247,7 @@ fn resolve_family_from_model_file( // Companion lookup: find_sibling_file handles hash-prefixed / symlinked // model paths robustly (CB-510 / publish-safety) and only returns Some // when the file exists — subsuming the prior with_file_name + .exists(). - if let Some(config_path) = realizar::safetensors::find_sibling_file(&real_path, "config.json") { + if let Some(config_path) = find_sibling(&real_path, "config.json") { resolve_from_config_json(&config_path) } else { emit_kernel_error( @@ -341,7 +360,7 @@ fn resolve_config_mapping( // hash-prefixed / symlinked model paths); fall back to the literal // sibling dir so extract_config_mapping reports not-found as before // (parent().join avoids with_file_name per publish-safety). - realizar::safetensors::find_sibling_file(p, "config.json").unwrap_or_else(|| { + find_sibling(p, "config.json").unwrap_or_else(|| { p.parent().map_or_else( || std::path::PathBuf::from("config.json"), |d| d.join("config.json"), @@ -467,48 +486,64 @@ fn explain_error_code(code: &str, json: bool) { /// PMAT-266: Explain tensor — look up in actual model file via RosettaStone // serde_json::json!() macro uses infallible unwrap internally #[allow(clippy::disallowed_methods)] -fn explain_tensor(tensor_name: &str, file: Option<&Path>, json: bool) { - // GH-510: Respect --json flag for tensor explanations - if json { - let role = TENSOR_ROLES +/// The tensors in `path` whose name matches `tensor_name`, as JSON objects. +/// +/// `None` when there is no readable model at `path`; an empty vector when the +/// model has no matching tensor. The caller distinguishes the two. +fn matching_tensors_json(tensor_name: &str, path: &Path) -> Option> { + if !path.exists() { + return None; + } + let report = aprender::format::rosetta::RosettaStone::new() + .inspect(path) + .ok()?; + Some( + report + .tensors .iter() - .find(|(patterns, _)| patterns.iter().any(|p| tensor_name.contains(p))) - .map(|(_, desc)| *desc); + .filter(|t| t.name == tensor_name || t.name.contains(tensor_name)) + .map(|t| { + serde_json::json!({ + "name": t.name, + "shape": t.shape, + "dtype": format!("{:?}", t.dtype), + }) + }) + .collect(), + ) +} - let mut output = serde_json::json!({ - "tensor": tensor_name, - "role": role.unwrap_or("unknown"), - }); +/// GH-510: the `--json` half of [`explain_tensor`]. +fn explain_tensor_json(tensor_name: &str, file: Option<&Path>) { + let role = TENSOR_ROLES + .iter() + .find(|(patterns, _)| patterns.iter().any(|p| tensor_name.contains(p))) + .map(|(_, desc)| *desc); - // If a file is provided, try to look up the actual tensor - if let Some(path) = file { - if path.exists() { - let rosetta = aprender::format::rosetta::RosettaStone::new(); - if let Ok(report) = rosetta.inspect(path) { - let matching: Vec<_> = report - .tensors - .iter() - .filter(|t| t.name == tensor_name || t.name.contains(tensor_name)) - .map(|t| { - serde_json::json!({ - "name": t.name, - "shape": t.shape, - "dtype": format!("{:?}", t.dtype), - }) - }) - .collect(); - if !matching.is_empty() { - output["matches"] = serde_json::json!(matching); - } - output["file"] = serde_json::json!(path.display().to_string()); - } - } + let mut output = serde_json::json!({ + "tensor": tensor_name, + "role": role.unwrap_or("unknown"), + }); + + if let Some((path, matching)) = + file.and_then(|p| Some((p, matching_tensors_json(tensor_name, p)?))) + { + if !matching.is_empty() { + output["matches"] = serde_json::json!(matching); } + output["file"] = serde_json::json!(path.display().to_string()); + } - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); + println!( + "{}", + serde_json::to_string_pretty(&output).unwrap_or_default() + ); +} + +fn explain_tensor(tensor_name: &str, file: Option<&Path>, json: bool) { + // GH-510: Respect --json flag for tensor explanations + if json { + explain_tensor_json(tensor_name, file); return; } diff --git a/crates/apr-cli/src/commands/profile_ollama.rs b/crates/apr-cli/src/commands/profile_ollama.rs index 21f61ef7c2..ead22b99f2 100644 --- a/crates/apr-cli/src/commands/profile_ollama.rs +++ b/crates/apr-cli/src/commands/profile_ollama.rs @@ -405,7 +405,9 @@ fn safe_ratio(num: f64, denom: f64) -> f64 { /// Printed in place of a memory-vs-compute verdict while the arithmetic /// intensity is not measured. A magnitude may be reported; a verdict decided by /// a number nothing measured may not. -#[cfg(feature = "inference")] +// Not gated: a sentinel string compared by the ungated +// `print_roofline_section`, which the gate left unresolvable without +// `inference`. pub(crate) const ROOFLINE_AI_UNMEASURED: &str = "UNMEASURED"; /// Does the arithmetic intensity actually depend on the workload? diff --git a/crates/apr-cli/src/commands/test_llm.rs b/crates/apr-cli/src/commands/test_llm.rs index fb0cc47d36..a9c4f9b6f9 100644 --- a/crates/apr-cli/src/commands/test_llm.rs +++ b/crates/apr-cli/src/commands/test_llm.rs @@ -411,6 +411,7 @@ use crate::LlmSubcommand; /// /// # Errors /// Propagates whichever mode ran. +#[cfg(feature = "tokio")] pub fn dispatch(command: &LlmSubcommand) -> Result<()> { let rt = tokio::runtime::Runtime::new() .map_err(|e| CliError::InferenceFailed(format!("tokio runtime: {e}")))?; @@ -420,6 +421,15 @@ pub fn dispatch(command: &LlmSubcommand) -> Result<()> { } } +/// Both modes are async and need a runtime to drive. Without `tokio` there is +/// none, so the command refuses by name instead of failing to link. +#[cfg(not(feature = "tokio"))] +pub fn dispatch(_command: &LlmSubcommand) -> Result<()> { + Err(CliError::InferenceFailed( + "`apr test llm` needs an async runtime; rebuild with --features inference".to_string(), + )) +} + /// TWO MODES, ONE ENTRYPOINT — the §4.4-conformant one. /// /// `--band` selects PERF-024's `run_band`, which until PERF-025 was called by diff --git a/crates/apr-cli/src/commands/test_llm_band.rs b/crates/apr-cli/src/commands/test_llm_band.rs index aecf2406a1..90697a2396 100644 --- a/crates/apr-cli/src/commands/test_llm_band.rs +++ b/crates/apr-cli/src/commands/test_llm_band.rs @@ -1770,7 +1770,13 @@ async fn cool(first_lane: &mut bool, cooldown: std::time::Duration, interleaved: *first_lane = false; return; } + // Unreachable without `tokio` — `dispatch` refuses before any band runs — + // but the path still has to compile, and a silently-skipped cooldown would + // be a §5.1 protocol violation if it ever did run. + #[cfg(feature = "tokio")] tokio::time::sleep(cooldown).await; + #[cfg(not(feature = "tokio"))] + let _ = cooldown; } /// Run the §5.1 protocol over every requested band and write the receipts. diff --git a/crates/apr-cli/src/dispatch.rs b/crates/apr-cli/src/dispatch.rs index a28c8e0d91..98d74f429a 100644 --- a/crates/apr-cli/src/dispatch.rs +++ b/crates/apr-cli/src/dispatch.rs @@ -105,155 +105,7 @@ fn dispatch_runtime_commands(cli: &Cli) -> Option> { // GH-685: forward cli.verbose to check Commands::Check { file, no_gpu, json } => crate::error::resolve_model_path(file) .and_then(|r| commands::check::run(&r, *no_gpu, *json || cli.json, cli.verbose)), - Commands::Run { - source, - positional_prompt, - input, - prompt, - max_tokens, - stream, - language, - task, - format, - no_gpu, - gpu, - offline, - benchmark, - trace, - trace_steps, - trace_verbose, - trace_output, - trace_level, - trace_payload, - profile, - temperature, - top_k, - top_p, - seed, - repeat_penalty, - repeat_last_n, - chat, - split_prompt, - batch_jsonl, - verbose, - 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 - }; - - // 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, - )); - } - - // GH-240: merge global --json flag into output format - let effective_format = if cli.json { "json" } else { format.as_str() }; - dispatch_run( - source, - positional_prompt.as_ref(), - input.as_deref(), - prompt.as_ref(), - *max_tokens, - *stream, - language.as_deref(), - task.as_deref(), - effective_format, - effective_no_gpu, - *offline, - *benchmark, - *verbose || cli.verbose, - *trace, - *trace_payload, - trace_steps.as_deref(), - *trace_verbose, - trace_output.clone(), - trace_level.as_str(), - *profile, - *chat, - // PMAT-496: Sampling parameters — no longer silently dropped - *temperature, - *top_k, - *top_p, - *seed, - *repeat_penalty, - *repeat_last_n, - *split_prompt, - ) - } + Commands::Run { .. } => dispatch_run_command(cli), Commands::Serve { command } => dispatch_serve_command(command, cli), @@ -287,6 +139,171 @@ or drop `--backend`." }) } +/// Dispatch `apr run`. +/// +/// Split out of [`dispatch_runtime_commands`] for the same reason as +/// [`dispatch_code_command`]: this arm's pre-flight guards and its +/// 28-argument hand-off to `dispatch_run` were most of that function's +/// cognitive complexity. The command is re-matched here rather than threaded +/// through a 28-field borrowed-args struct; the `else` arm is unreachable from +/// the single call site and refuses by name rather than panicking. +fn dispatch_run_command(cli: &Cli) -> Result<(), CliError> { + let Commands::Run { + source, + positional_prompt, + input, + prompt, + max_tokens, + stream, + language, + task, + format, + no_gpu, + gpu, + offline, + benchmark, + trace, + trace_steps, + trace_verbose, + trace_output, + trace_level, + trace_payload, + profile, + temperature, + top_k, + top_p, + seed, + repeat_penalty, + repeat_last_n, + chat, + split_prompt, + batch_jsonl, + verbose, + backend: BackendArg { backend }, + } = cli.command.as_ref() + else { + return Err(CliError::ValidationFailed( + "dispatch_run_command reached for a command other than `apr run`".to_string(), + )); + }; + + // 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 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 Err(e); + } + + // GH-326: --gpu overrides --no-gpu when both specified + let effective_no_gpu = if *gpu { + false + } else { + *no_gpu || backend_forces_cpu + }; + + // Batch JSONL mode: load model once, process all prompts + #[cfg(feature = "inference")] + if let Some(ref batch_file) = batch_jsonl { + return run::run_batch( + source, + batch_file, + *max_tokens, + *temperature, + *top_k, + effective_no_gpu, + *verbose || cli.verbose, + ); + } + + // GH-240: merge global --json flag into output format + let effective_format = if cli.json { "json" } else { format.as_str() }; + dispatch_run( + source, + positional_prompt.as_ref(), + input.as_deref(), + prompt.as_ref(), + *max_tokens, + *stream, + language.as_deref(), + task.as_deref(), + effective_format, + effective_no_gpu, + *offline, + *benchmark, + *verbose || cli.verbose, + *trace, + *trace_payload, + trace_steps.as_deref(), + *trace_verbose, + trace_output.clone(), + trace_level.as_str(), + *profile, + *chat, + // PMAT-496: Sampling parameters — no longer silently dropped + *temperature, + *top_k, + *top_p, + *seed, + *repeat_penalty, + *repeat_last_n, + *split_prompt, + ) +} + /// Borrowed view of the parsed `apr code` flags, so [`dispatch_code_command`] /// takes one argument instead of ten. struct CodeArgs<'a> { @@ -504,74 +521,113 @@ fn dispatch_inspection_commands(cli: &Cli) -> Option> { Some(result) } +/// Dispatch `apr trace`. +/// +/// Split out of [`dispatch_diagnostic_commands`] for the same reason as +/// [`dispatch_run_command`]: the save-tensor pre-dispatch nested inside this +/// arm's closure was what held that function over the cognitive threshold. +/// The `else` arm is unreachable from the single call site. +fn dispatch_trace_command(cli: &Cli) -> Result<(), CliError> { + let Commands::Trace { + file, + layer, + reference, + json, + verbose, + payload, + diff, + interactive, + save_tensor, + save_tensor_dir, + save_tensor_layers, + } = cli.command.as_ref() + else { + return Err(CliError::ValidationFailed( + "dispatch_trace_command reached for a command other than `apr trace`".to_string(), + )); + }; + let r = crate::error::resolve_model_path(file)?; + // 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() { + if let Some(handled) = + dispatch_trace_save_tensor(&r, stages, save_tensor_dir.as_deref(), save_tensor_layers) + { + return handled; + } + } + trace::run( + &r, + layer.as_deref(), + reference.as_deref(), + *json || cli.json, + *verbose || cli.verbose, + *payload, + *diff, + *interactive, + ) +} + +/// Resolve both operands of `apr diff` before running `f` on them. +/// +/// Both branches of that arm resolved the same two paths through a pair of +/// nested `and_then` closures each; the nesting, not the work, was what put +/// [`dispatch_diagnostic_commands`] over the cognitive threshold. +fn with_resolved_pair(file1: &Path, file2: &Path, f: F) -> Result<(), CliError> +where + F: FnOnce(&Path, &Path) -> Result<(), CliError>, +{ + let r1 = crate::error::resolve_model_path(file1)?; + let r2 = crate::error::resolve_model_path(file2)?; + f(&r1, &r2) +} + +/// SHIP-007: the `apr trace --save-tensor` wrapper dispatch. +/// +/// `Some(result)` when the save-tensor path handled the file; `None` when the +/// caller should fall through to the ordinary trace path. Split out of +/// [`dispatch_diagnostic_commands`] — this nested match inside that arm's +/// closure was most of its cognitive complexity. +#[cfg(feature = "inference")] +fn dispatch_trace_save_tensor( + path: &Path, + stages: &str, + dir: Option<&Path>, + layers: &str, +) -> Option> { + let ext_lower = path + .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( + path, stages, dir, layers, + )), + // 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. + Some("gguf") => Some(crate::commands::trace_save_tensor::run_save_tensor_gguf_moe( + path, stages, dir, 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 {})", + path.display() + ); + None + } + } +} + /// Dispatch diagnostic commands: trace, tensors, diff. fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { Some(match cli.command.as_ref() { - Commands::Trace { - file, - layer, - reference, - json, - verbose, - payload, - diff, - interactive, - save_tensor, - 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() - ); - } - } - } - trace::run( - &r, - layer.as_deref(), - reference.as_deref(), - *json || cli.json, - *verbose || cli.verbose, - *payload, - *diff, - *interactive, - ) - }), + Commands::Trace { .. } => dispatch_trace_command(cli), Commands::Tensors { file, @@ -602,35 +658,23 @@ fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { 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, - ) - }) - }) - } + with_resolved_pair(file1, file2, |r1, r2| { + if *quant_roundtrip { + // CRUX-B-20: per-tensor quant roundtrip error report. + dispatch_quant_roundtrip(r1, r2, *threshold, *no_threshold, *json || cli.json) + } else { + diff::run( + r1, + r2, + *weights, + *values, + filter.as_deref(), + *limit, + *transpose_aware, + *json || cli.json, + ) + } + }) } _ => return None, diff --git a/crates/apr-cli/src/help_producer_truth.rs b/crates/apr-cli/src/help_producer_truth.rs index 8d7d3df7c7..5439d2325e 100644 --- a/crates/apr-cli/src/help_producer_truth.rs +++ b/crates/apr-cli/src/help_producer_truth.rs @@ -45,92 +45,130 @@ fn quoted_apr_invocations(site: &str, help: &str) -> Vec { found } -/// Resolve a quoted invocation against the real clap tree. +/// The parser state while walking one quoted invocation's tokens. /// -/// 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 tokens = quoted.split_whitespace(); - let _apr = tokens.next(); // "apr" - let mut path = String::from("apr"); - let mut positionals_used = 0usize; - - // 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; +/// `resolve` used to carry all of this as locals inside a single loop whose +/// three token kinds were three inline blocks; each kind is now a method, and +/// `resolve` is just the dispatch between them. +struct Walk<'a> { + /// The root command, re-consulted for `global = true` args. + root: &'a clap::Command, + /// The command the walk has descended to. + cmd: &'a clap::Command, + /// Dotted path to `cmd`, used in error messages. + path: String, + /// Bare words consumed as positional VALUES so far. + positionals_used: usize, + /// Set when the previous token was a flag that takes a value, so the next + /// bare word is that value and not a subcommand or positional. + awaiting_value: bool, +} - for tok in tokens { - 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; - } - 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; +impl<'a> Walk<'a> { + fn new(root: &'a clap::Command) -> Self { + Self { + root, + cmd: root, + path: String::from("apr"), + positionals_used: 0, + awaiting_value: false, } - if tok.starts_with('<') || tok.starts_with('"') { - awaiting_value = false; - continue; // placeholders are not checked + } + + /// `--name` or `--name=value`. Errors naming the command that has no such flag. + fn long_flag(&mut self, flag: &str) -> Result<(), String> { + let (cmd, root) = (self.cmd, self.root); + self.awaiting_value = false; + let name = flag.split('=').next().unwrap_or(flag); + if name.is_empty() { + return Ok(()); // bare `--` } - if awaiting_value { - awaiting_value = false; - continue; // this bare word is the previous flag's value + 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!("`{}` 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`. Short flags are not name-checked (help text uses them rarely), + /// but a short flag that takes a value consumes the next word. + fn short_flag(&mut self, short: &str) { + let (cmd, root) = (self.cmd, self.root); + let c = short.chars().next(); + self.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; + } + + /// A bare word: the previous flag's value, a subcommand, or a positional. + fn bare_word(&mut self, tok: &str) -> Result<(), String> { + if self.awaiting_value { + self.awaiting_value = false; + return Ok(()); // this bare word is the previous flag's value } + let cmd = self.cmd; // 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 { + let sub = if self.positionals_used == 0 { 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(cmd) { + self.positionals_used += 1; + return Ok(()); } if cmd.get_subcommands().next().is_some() { - return Err(format!("`{path}` has no subcommand `{tok}`")); + return Err(format!("`{}` has no subcommand `{tok}`", self.path)); } - return Err(format!( - "`{path}` takes {} positional(s); `{tok}` is one too many", + Err(format!( + "`{}` takes {} positional(s); `{tok}` is one too many", + self.path, positional_capacity(cmd) - )); + )) + } +} + +/// Resolve a quoted invocation against the real clap tree. +/// +/// Returns `Err(reason)` naming the first token the parser would reject. +fn resolve(root: &clap::Command, quoted: &str) -> Result<(), String> { + let mut walk = Walk::new(root); + let mut tokens = quoted.split_whitespace(); + let _apr = tokens.next(); // "apr" + + for tok in tokens { + if let Some(flag) = tok.strip_prefix("--") { + walk.long_flag(flag)?; + } else if let Some(short) = tok.strip_prefix('-') { + walk.short_flag(short); + } else if tok.starts_with('<') || tok.starts_with('"') { + walk.awaiting_value = false; // placeholders are not checked + } else { + walk.bare_word(tok)?; + } } Ok(()) } diff --git a/crates/apr-cli/src/lib.rs b/crates/apr-cli/src/lib.rs index 76169e7b43..457cab10b0 100644 --- a/crates/apr-cli/src/lib.rs +++ b/crates/apr-cli/src/lib.rs @@ -69,9 +69,12 @@ pub mod model_pull { // `tests/falsify_auth_*.rs` can construct gates and middleware without // reaching into the private `commands` tree. pub mod serve_auth { + // `apply` and `layer` are axum middleware and carry the same `inference` + // gate as their definitions in `commands::serve::auth`; `AuthGate` is the + // plain configuration struct and is always available. + pub use crate::commands::serve::auth::AuthGate; #[cfg(feature = "inference")] - pub use crate::commands::serve::auth::layer; - pub use crate::commands::serve::auth::{apply, AuthGate}; + pub use crate::commands::serve::auth::{apply, layer}; } // PMAT-923: e2e seam so `tests/ollama_api_serve_compat.rs` can build the REAL From 0d95079f6a7275c1af8c7a990d20b51c1d5367b1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 13:06:24 +0200 Subject: [PATCH 3/6] fix(train): the `tui` feature was declared in Cargo.toml and never applied in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tui = ["dep:presentar-terminal"]` (PMAT-507, "gated due to presentar API drift") makes the dep optional, but no `#[cfg(feature = "tui")]` existed anywhere in the crate — `grep -rn 'feature = "tui"' --include='*.rs'` returned nothing. So `monitor/tui/dashboard.rs` and `TuiMonitor::run` used `presentar_terminal` unconditionally, and every selection that did not happen to enable the default `tui` failed to link it: 17 of the 430 (crate, feature) pairs in the night sweep, all on this one cause. Gate what actually needs the backend and nothing more. The module's metric-store half — `TrainingStateWriter` and the state types — stays unconditional, which is what the feature table's own comment says it is ("TUI IPC writer always available"). Without `tui`, `TuiMonitor::run` returns an error naming the build it needs rather than failing to compile. Fixing that exposed a second defect the first was masking: `autograd/ops/mod.rs` re-exported `pre_warm_realizador_gemm` under `#[cfg(feature = "realizar")]` while the definition is `#[cfg(all(feature = "realizar", feature = "cuda"))]` (C-PREWARM-001 — the pre-warm only exists on the CUDA path). Re-export now carries the same pair. 24 selections check clean (axum, citl, cpu-fallback, cuda, dhat-heap, getrandom, gguf, gpu, hub, hub-publish, js-sys, kani, monitor, nvml, nvml-wrapper, parquet, realizar, renacer, ruchy-sessions, server, tracing, tui, viz, wasm) plus default; aprender-train --lib is 7624/7624. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- crates/aprender-train/src/autograd/ops/mod.rs | 4 +++- crates/aprender-train/src/monitor/tui/app.rs | 12 ++++++++++++ crates/aprender-train/src/monitor/tui/mod.rs | 4 ++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/aprender-train/src/autograd/ops/mod.rs b/crates/aprender-train/src/autograd/ops/mod.rs index 6d5b75d6af..58968d5d7e 100644 --- a/crates/aprender-train/src/autograd/ops/mod.rs +++ b/crates/aprender-train/src/autograd/ops/mod.rs @@ -14,7 +14,9 @@ mod normalize; pub use activations::{gelu, relu, softmax, swish}; pub use attention::{attention, attention_causal}; pub use basic::{add, add_scaled, mul, scale, sum}; -#[cfg(feature = "realizar")] +// C-PREWARM-001: the definition is `all(realizar, cuda)` — the pre-warm only +// exists on the CUDA path — so the re-export must carry the same pair. +#[cfg(all(feature = "realizar", feature = "cuda"))] pub use matmul::pre_warm_realizador_gemm; pub use matmul::{matmul, matmul_compute, matmul_nt, transpose, transpose_tracked}; #[cfg(feature = "gpu")] diff --git a/crates/aprender-train/src/monitor/tui/app.rs b/crates/aprender-train/src/monitor/tui/app.rs index 72c5b87841..fde61fb5db 100644 --- a/crates/aprender-train/src/monitor/tui/app.rs +++ b/crates/aprender-train/src/monitor/tui/app.rs @@ -66,6 +66,7 @@ impl TuiMonitor { /// - Ctrl+C / 'q' handling with clean cursor restore /// - Smart diffing (only redraws changed cells) /// - 60fps rendering with frame budgets + #[cfg(feature = "tui")] pub fn run(&mut self) -> io::Result<()> { // Wait for state file to appear eprintln!("Waiting for training state file at {}...", self.state.path().display()); @@ -96,6 +97,17 @@ impl TuiMonitor { eprintln!("\nDetached from training session. Training continues in background."); Ok(()) } + + /// Without `tui` there is no presentar-terminal to render through, so the + /// monitor says which build it needs instead of failing to link. The + /// metric-store side of this module (`TrainingStateWriter`) is unaffected + /// and stays available unconditionally, as the feature table intends. + #[cfg(not(feature = "tui"))] + pub fn run(&mut self) -> io::Result<()> { + Err(io::Error::other( + "the TUI monitor needs the presentar-terminal backend; rebuild with --features tui", + )) + } } #[cfg(test)] diff --git a/crates/aprender-train/src/monitor/tui/mod.rs b/crates/aprender-train/src/monitor/tui/mod.rs index 52b0d9574b..6d9884433d 100644 --- a/crates/aprender-train/src/monitor/tui/mod.rs +++ b/crates/aprender-train/src/monitor/tui/mod.rs @@ -30,6 +30,9 @@ pub mod app; pub mod color; +// PMAT-507: the presentar-terminal dashboard renders through the optional +// `presentar-terminal` dep, which only the `tui` feature links. +#[cfg(feature = "tui")] pub mod dashboard; pub mod headless; pub mod panel; @@ -38,6 +41,7 @@ pub mod state; pub use app::{TrainingStateWriter, TuiMonitor, TuiMonitorConfig}; pub use color::{colored_bar, colored_value, ColorMode, Rgb, Styled, TrainingPalette}; +#[cfg(feature = "tui")] pub use dashboard::TrainingDashboard; pub use headless::{ HeadlessGpu, HeadlessMonitor, HeadlessOutput, HeadlessSample, HeadlessWriter, OutputFormat, From 8c1b67839b93ec25c89a78165631d8b1b728e732 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 13:20:19 +0200 Subject: [PATCH 4/6] fix(features): four crates declared features that could not compile, and two that never can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing the (crate, feature) sweep behind the night lane. Same class as the apr-cli and aprender-train fixes: a feature is declared, code is gated on it, and the declaration does not supply what that code needs. BUILDABLE NOW — the declaration was simply wrong: * apr-cli `wgpu = ["inference"]` — commands/finetune.rs uses `entrenar::finetune::wgpu_pipeline::WgpuInstructPipeline` and `entrenar::autograd::wgpu_training`, both gated on aprender-train's `gpu`. The feature enabled neither entrenar nor that gate, so `--features wgpu` AND `--all-features` did not compile. Now `["inference", "training", "entrenar/gpu"]`; `aprender --features wgpu` and `apr-cli --all-features` both check clean. * aprender-data `cli = ["dep:clap", "dep:crossterm"]` — cli/registry.rs imports `backend::LocalBackend` (gated `local`) and cli/mod.rs dispatches `cmd_mix` (gated `shuffle`). Added both. All 13 selections clean. * aprender-simulate `web` and `schema-validation` — CB-081 removed axum and jsonschema to cut Cargo.lock bloat and left the code gated behind features that pulled nothing. Both crates are ALREADY in Cargo.lock (axum 0.7.9 via 7 other members; jsonschema 0.28.3 via aprender-train and aprender-mcp), so re-adding at those versions costs zero new lock entries — measured: the Cargo.lock diff is two dependency edges and no new [[package]]. That is the thing CB-081 was protecting. (The 0.29 jsonschema the old note suggested WOULD have added one; the note was not re-measured.) * aprender-simulate `wasm` — three sites in orbit/wasm.rs passed scalars (`f64`, `u32`) to `contract_pre_iterator!`, which asserts `input.len() > 0` on a SLICE. They never compiled, so they never checked anything. `0` steps and `0.0` days are both legitimate here, so they take the no-arg marker form the other 15 sites use rather than inventing a scalar precondition the contract does not state. CANNOT BE BUILT — the dependency is deliberately absent, so the feature now refuses by name instead of emitting a cascade of unresolved-import errors: * aprender-simulate `z3-proofs` — CB-081 removed `z3`. Unlike axum and jsonschema it is NOT in Cargo.lock and needs the libz3 system library, so re-adding it is a real cost and a real decision. * aprender-core `showcase-profile` — `renacer` is a DEV-dependency here because renacer depends on aprender; lib code can never link it. A stub profiler already existed for the off path. Both get one `compile_error!` naming the dependency and the exact edit that would enable it, plus a private `__z3-linked` / `__renacer-linked` feature that actually gates the code — so the refusal is the ONLY diagnostic, and the aprender-core stub still compiles. aprender-simulate --lib 3677/3677; aprender-data and aprender-core default builds clean. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 ++ crates/apr-cli/Cargo.toml | 7 +++++- crates/aprender-core/Cargo.toml | 4 +++ .../aprender-core/src/showcase/profiling.rs | 17 +++++++++++-- crates/aprender-data/Cargo.toml | 5 +++- crates/aprender-simulate/Cargo.toml | 25 +++++++++++++------ crates/aprender-simulate/src/edd/mod.rs | 2 +- crates/aprender-simulate/src/edd/prover.rs | 16 +++++++++++- crates/aprender-simulate/src/orbit/wasm.rs | 11 +++++--- 9 files changed, 72 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17f0f58887..329c2156d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,6 +1392,7 @@ dependencies = [ "aprender-present-core", "aprender-present-terminal", "aprender-present-test", + "axum 0.7.9", "bincode", "bitflags 2.13.0", "blake3", @@ -1403,6 +1404,7 @@ dependencies = [ "getrandom 0.2.17", "indexmap 2.14.0", "js-sys", + "jsonschema", "memmap2", "num-traits", "proptest", diff --git a/crates/apr-cli/Cargo.toml b/crates/apr-cli/Cargo.toml index e6c65c6989..cef683c807 100644 --- a/crates/apr-cli/Cargo.toml +++ b/crates/apr-cli/Cargo.toml @@ -97,7 +97,12 @@ cuda = ["inference", "realizar/cuda", "entrenar/cuda", "aprender-train-distill?/ # the binary was built with it (§2.1 INVALID-BUILD reads that entry). cuda-batch = ["cuda"] # PMAT-335: WGPU backend for AMD/Intel/Apple GPU inference via Vulkan/Metal/WebGPU -wgpu = ["inference"] +# PMAT-335: the `wgpu` code in commands/finetune.rs uses +# `entrenar::finetune::wgpu_pipeline::WgpuInstructPipeline` and +# `entrenar::autograd::wgpu_training`, both gated on aprender-train's `gpu` +# feature. `wgpu = ["inference"]` enabled neither entrenar nor that gate, so +# `--features wgpu` (and therefore `--all-features`) did not compile. +wgpu = ["inference", "training", "entrenar/gpu"] visualization = ["renacer", "trueno-viz"] zram = ["trueno-zram-core"] training = ["dep:entrenar", "dep:entrenar-lora", "dep:aprender-train-distill", "dep:aprender-train-common", "aprender-train-distill?/shard-batch-source"] diff --git a/crates/aprender-core/Cargo.toml b/crates/aprender-core/Cargo.toml index 2c016a41f5..82651201e7 100644 --- a/crates/aprender-core/Cargo.toml +++ b/crates/aprender-core/Cargo.toml @@ -267,6 +267,10 @@ model-tests = [] # Enable heavy model/inference tests (requires models/ dir, ol cuda = ["trueno/cuda-monitor"] # Enable CUDA monitoring via trueno-gpu (NVIDIA GPUs) cpu-only = [] showcase-profile = [] # Renacer profiling (disabled: renacer→aprender circular dep) +# Private. renacer is a DEV-dependency here (the cycle above), so lib code can +# never link it; this is what actually gates the real profiler, leaving +# `showcase-profile` to report one named refusal instead of a cascade. +__renacer-linked = [] showcase-zram = ["trueno-zram-core"] # Enable trueno-zram KV cache compression for showcase benchmarks (PAR-040) explainable-monitor-integration = [] # Enable integration tests for aprender explainability + entrenar InferenceMonitor (GH-305) # Chaos engineering features (from renacer) diff --git a/crates/aprender-core/src/showcase/profiling.rs b/crates/aprender-core/src/showcase/profiling.rs index cfd1d94aa9..d89a3ff50a 100644 --- a/crates/aprender-core/src/showcase/profiling.rs +++ b/crates/aprender-core/src/showcase/profiling.rs @@ -275,6 +275,19 @@ impl PmatVerification { // Renacer Integration (feature-gated) // ============================================================================ +// `renacer` is a DEV-dependency of aprender-core — it depends on aprender, so it +// cannot be a runtime dep without closing the cycle (see Cargo.toml). Lib code +// therefore cannot link it, and `--features showcase-profile` produced a +// cascade of unresolved-`renacer` errors. The feature now refuses by name, and +// the real module is gated on the private linkage feature so the stub below +// still compiles. +#[cfg(feature = "showcase-profile")] +compile_error!( + "feature `showcase-profile` needs `renacer`, which is a dev-dependency here \ + because renacer depends on aprender (circular). The stub profiler is what \ + builds without it; breaking the cycle is a prerequisite for enabling this." +); + /// Renacer-based profiler for deep GPU kernel analysis /// /// When the `showcase-profile` feature is enabled, this wraps renacer's @@ -290,7 +303,7 @@ impl PmatVerification { /// // ... run GPU inference ... /// let hotspots = profiler.finish()?; /// ``` -#[cfg(feature = "showcase-profile")] +#[cfg(feature = "__renacer-linked")] pub mod profiler { use super::{explain_component, Duration, ProfilingHotspot}; use renacer::time_attribution::Hotspot; @@ -341,7 +354,7 @@ pub mod profiler { } /// Stub module when renacer is not available -#[cfg(not(feature = "showcase-profile"))] +#[cfg(not(feature = "__renacer-linked"))] pub mod profiler { /// Stub config when showcase-profile is disabled #[derive(Debug, Clone, Default)] diff --git a/crates/aprender-data/Cargo.toml b/crates/aprender-data/Cargo.toml index 7294a0488a..12c6276093 100644 --- a/crates/aprender-data/Cargo.toml +++ b/crates/aprender-data/Cargo.toml @@ -21,7 +21,10 @@ tokio-runtime = ["dep:tokio"] s3 = ["dep:aws-sdk-s3", "dep:aws-config", "tokio-runtime"] http = ["dep:reqwest"] hf-hub = ["http", "dep:base64", "dep:hex", "dep:sha2"] -cli = ["dep:clap", "dep:crossterm"] +# `local` and `shuffle` are not optional extras for the CLI: cli/registry.rs +# imports `backend::LocalBackend` (gated on `local`) and cli/mod.rs dispatches +# `cmd_mix` (gated on `shuffle`). Without them `--features cli` did not compile. +cli = ["dep:clap", "dep:crossterm", "local", "shuffle"] repl = ["cli", "dep:reedline", "dep:nu-ansi-term"] wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:js-sys", "dep:getrandom"] format-encryption = ["dep:aes-gcm", "dep:argon2", "dep:x25519-dalek", "dep:hkdf", "dep:sha2", "dep:rand_core", "dep:getrandom"] diff --git a/crates/aprender-simulate/Cargo.toml b/crates/aprender-simulate/Cargo.toml index b15fbc2540..34ba86fad4 100644 --- a/crates/aprender-simulate/Cargo.toml +++ b/crates/aprender-simulate/Cargo.toml @@ -25,9 +25,12 @@ bincode = "1.3" # Validation validator = { version = "0.20", features = ["derive"] } thiserror = "2.0" -# jsonschema removed from Cargo.toml to reduce Cargo.lock bloat (CB-081) -# Re-add with: jsonschema = { version = "0.29", optional = true } -# Code gated behind #[cfg(feature = "schema-validation")] +# CB-081 removed jsonschema to cut Cargo.lock bloat but left the code behind +# `schema-validation`, so that feature could not compile at all. 0.28 is ALREADY +# in Cargo.lock (aprender-train, aprender-mcp), so re-adding at that version +# costs zero new lock entries — which is what CB-081 was protecting. (The 0.29 +# the old note suggested would have added one.) +jsonschema = { version = "0.28", optional = true } # Random number generation (deterministic) rand = "0.9" @@ -48,9 +51,11 @@ memmap2 = "0.9" # Async runtime (for web visualization) tokio = { version = "1.0", features = ["full"], optional = true } -# Web server removed from Cargo.toml to reduce Cargo.lock bloat (CB-081) -# Re-add with: axum = { version = "0.7", features = ["ws"], optional = true } -# Code gated behind #[cfg(feature = "web")] +# Same story as jsonschema above: CB-081 dropped axum and left +# `visualization/web.rs` gated behind a `web` feature that pulled only tokio. +# axum 0.7.9 is already in Cargo.lock, and web.rs is written against the 0.7 +# API, so this re-add is both zero-bloat and zero-migration. +axum = { version = "0.7", features = ["ws"], optional = true } # TUI (optional) crossterm = { version = "0.28", optional = true } @@ -110,7 +115,7 @@ presentar-test = { path = "../aprender-present-test", package = "aprender-presen [features] default = [] tui = ["dep:crossterm", "dep:presentar-terminal", "dep:presentar-core"] -web = ["dep:tokio"] +web = ["dep:tokio", "dep:axum"] wasm = ["dep:wasm-bindgen", "dep:js-sys", "dep:web-sys", "dep:getrandom", "dep:serde-wasm-bindgen", "dep:console_error_panic_hook"] full = ["tui", "web"] # EDD verification feature - enables extended verification tests @@ -119,8 +124,12 @@ verification = [] proptest = [] # Z3 equation proofs - re-add z3 dep to enable z3-proofs = [] +# Private. Set by `z3-proofs` only once the `z3` dependency is re-added; it is +# what actually gates the z3 code, so `z3-proofs` on its own reports the single +# named refusal in edd/prover.rs instead of a cascade of unresolved imports. +__z3-linked = [] # JSON Schema validation - re-add jsonschema dep to enable -schema-validation = [] +schema-validation = ["dep:jsonschema"] [lib] name = "simular" diff --git a/crates/aprender-simulate/src/edd/mod.rs b/crates/aprender-simulate/src/edd/mod.rs index b41914c501..f237d715ca 100644 --- a/crates/aprender-simulate/src/edd/mod.rs +++ b/crates/aprender-simulate/src/edd/mod.rs @@ -58,7 +58,7 @@ pub use gui_coverage::GuiCoverage; pub use loader::{EmcYaml, ExperimentYaml}; pub use model_card::{DomainConstraint, EmcBuilder, EquationModelCard}; pub use operations::{BullwhipEffect, KingmanFormula, LittlesLaw, SquareRootLaw}; -#[cfg(feature = "z3-proofs")] +#[cfg(feature = "__z3-linked")] pub use prover::z3_impl; pub use prover::{ProofError, ProofResult, Z3Provable}; pub use report::{ReportFormat, ReportGenerator}; diff --git a/crates/aprender-simulate/src/edd/prover.rs b/crates/aprender-simulate/src/edd/prover.rs index b2ee69e0b0..2e79b809e3 100644 --- a/crates/aprender-simulate/src/edd/prover.rs +++ b/crates/aprender-simulate/src/edd/prover.rs @@ -126,7 +126,21 @@ pub trait Z3Provable { // Z3-backed implementations (only available with z3-proofs feature) // ============================================================================= +// CB-081 removed the `z3` dependency to cut Cargo.lock bloat and left this +// module gated behind a `z3-proofs` feature that pulls nothing, so enabling it +// produced a cascade of unresolved-import errors. Unlike axum and jsonschema, +// z3 is NOT already in Cargo.lock and needs the libz3 system library, so +// re-adding it is a real cost and a real decision. Until that decision is +// made, the feature refuses by name instead. #[cfg(feature = "z3-proofs")] +compile_error!( + "feature `z3-proofs` needs the `z3` crate, which CB-081 removed from \ + aprender-simulate/Cargo.toml. Re-add `z3 = { version = \"0.12\", optional = true }` \ + and change the feature to `z3-proofs = [\"dep:z3\"]` (libz3 must be installed \ + on the build host) to use it." +); + +#[cfg(feature = "__z3-linked")] pub mod z3_impl { use super::{ProofError, ProofResult}; use std::time::Instant; @@ -485,7 +499,7 @@ mod tests { assert_eq!(cloned.theorem, result.theorem); } - #[cfg(feature = "z3-proofs")] + #[cfg(feature = "__z3-linked")] mod z3_tests { use super::super::z3_impl::*; diff --git a/crates/aprender-simulate/src/orbit/wasm.rs b/crates/aprender-simulate/src/orbit/wasm.rs index 1335331477..0f65d4dc83 100644 --- a/crates/aprender-simulate/src/orbit/wasm.rs +++ b/crates/aprender-simulate/src/orbit/wasm.rs @@ -129,21 +129,26 @@ impl OrbitSimulation { /// Step the simulation forward by days. #[wasm_bindgen] pub fn step_days(&mut self, days: f64) -> bool { - contract_pre_iterator!(days); + // `contract_pre_iterator!` asserts `input.len() > 0` — a SLICE + // precondition. Passing a scalar never compiled, so this site has + // never checked anything; `0` steps / `0.0` days are both legitimate + // here, so the no-arg marker form is used rather than inventing a + // scalar precondition the contract does not state. + contract_pre_iterator!(); self.step(days * 86400.0) } /// Step the simulation forward by hours. #[wasm_bindgen] pub fn step_hours(&mut self, hours: f64) -> bool { - contract_pre_iterator!(hours); + contract_pre_iterator!(); self.step(hours * 3600.0) } /// Run multiple steps with a given dt (for performance). #[wasm_bindgen] pub fn run_steps(&mut self, num_steps: u32, dt_seconds: f64) -> u32 { - contract_pre_iterator!(num_steps); + contract_pre_iterator!(); let mut completed = 0; for _ in 0..num_steps { if !self.step(dt_seconds) { From 0d65aea03d7e31ca09adda16a22b605b8504a515 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 13:31:57 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(features):=20six=20more=20crates=20?= =?UTF-8?q?=E2=80=94=20declared=20selections=20that=20never=20compiled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing the (crate, feature) sweep. Same class throughout: the feature is declared, code is gated on it, the declaration does not supply what that code needs. None of these are reachable from the default set, which is why they went dark. * aprender-serve `cuda` — the CUDA generate path reports `crate::api::PhaseTimings` (api is `server`-gated), the cached-weights path it shares uses `DequantizedWeightCache` and `crate::gpu` (both `gpu`-gated), and the q4k scheduler calls `cli::inference::{argmax, sample_with_temperature}`. `cuda = ["dep:trueno-gpu", "dep:tracing"]` supplied none of them, so `--features cuda` and `--features gpu,cuda` both failed — only the default set, which happens to carry server+cli+gpu, ever built this code. Declared as what it needs. Those two sampling helpers are PURE; moving them out of the cli-gated module would let `cuda` drop that edge and stop pulling clap and presentar-terminal — noted for follow-up, not done here. * aprender-mcp `pmcp-dispatcher = ["dep:pmcp"]` — server.rs's dispatcher uses `anyhow` and `tools::args::json_type_name`, both behind `native`. Added. * aprender-profile-core `otlp = []` — an EMPTY feature whose code converts to `opentelemetry::trace::{TraceId, SpanId}`. The sibling aprender-profile declares the same feature as `["dep:opentelemetry", …]`; -core copied the name without the dependency. Added at 0.31.0, the version already in Cargo.lock via that sibling, so no new locked package (verified: the Cargo.lock diff adds zero `[[package]]` entries). * aprender-compute `gpu-wasm` — `GpuDevice::new_async` is the wasm-capable constructor and carries no cfg, like the module (`any(gpu, gpu-wasm)`), but its helpers `shared_instance`/`gpu_backends` were `all(gpu, not(wasm32))`. So `gpu-wasm` was broken on every target INCLUDING wasm32, the one it exists for. Helpers widened to the module's own gate; their bodies are target-agnostic and `Backends::PRIMARY` already includes BROWSER_WEBGPU. The `gpu` path is untouched. This also unblocked aprender-viz `gpu-wasm`. * aprender-viz — `Histogram` had `width`/`height` fields, defaulted 800x600, read by `to_framebuffer`, and NO setter, while every sibling widget (sparkline, resource bar, ggplot) has `dimensions()`. aprender-serve's latency histogram called it. Added the missing builder rather than deleting the caller's intent. * aprender-rag-cli `eval` — `EvalAction` is a field of `Commands::Eval` and `Commands` derives `Subcommand, Clone, Debug`; `EvalAction` derived only `Subcommand`. Matched. aprender-compute --lib 3511/3511, aprender-viz + aprender-rag-cli --lib 605/605; aprender-serve clean on cuda, full, gpu, server, cli, kv-cache, visualization, registry, tui, trace and default. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + .../src/backends/gpu/device/mod.rs | 12 ++++++++++-- crates/aprender-mcp/Cargo.toml | 5 ++++- crates/aprender-profile-core/Cargo.toml | 8 +++++++- crates/aprender-rag-cli/src/lib.rs | 6 +++++- crates/aprender-serve/Cargo.toml | 12 +++++++++++- crates/aprender-viz/src/plots/histogram.rs | 14 ++++++++++++++ 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 329c2156d0..719742efd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,6 +1104,7 @@ name = "aprender-profile-core" version = "0.67.0" dependencies = [ "hex", + "opentelemetry", "serde", "serde_json", "static_assertions", diff --git a/crates/aprender-compute/src/backends/gpu/device/mod.rs b/crates/aprender-compute/src/backends/gpu/device/mod.rs index 3f6d26b8de..70258f3ab6 100644 --- a/crates/aprender-compute/src/backends/gpu/device/mod.rs +++ b/crates/aprender-compute/src/backends/gpu/device/mod.rs @@ -54,7 +54,11 @@ static DEVICE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// The mask is applied at BOTH the `wgpu::Instance` construction site (so the /// GLES backend is never even registered on the instance) and every /// `enumerate_adapters` call site. -#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] +// `new_async` is the wasm-capable constructor and is gated `any(gpu, +// gpu-wasm)` like the module itself, so its helpers must be too: under +// `gpu-wasm` alone this was declared and never compiled. The body is +// target-agnostic (PRIMARY already includes BROWSER_WEBGPU). +#[cfg(any(feature = "gpu", feature = "gpu-wasm"))] pub(crate) const fn gpu_backends() -> wgpu::Backends { // PRIMARY = VULKAN | METAL | DX12 | BROWSER_WEBGPU (never GL/GLES). wgpu::Backends::PRIMARY @@ -71,7 +75,11 @@ pub(crate) const fn gpu_backends() -> wgpu::Backends { /// instance for the whole process means the broken ICD is enumerated exactly /// once, eliminating the concurrent-init race entirely. `wgpu::Instance` is /// `Clone`/`Send`/`Sync`, so every adapter/device request can cheaply reuse it. -#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))] +// `new_async` is the wasm-capable constructor and is gated `any(gpu, +// gpu-wasm)` like the module itself, so its helpers must be too: under +// `gpu-wasm` alone this was declared and never compiled. The body is +// target-agnostic (PRIMARY already includes BROWSER_WEBGPU). +#[cfg(any(feature = "gpu", feature = "gpu-wasm"))] pub(crate) fn shared_instance() -> wgpu::Instance { use std::sync::OnceLock; static INSTANCE: OnceLock = OnceLock::new(); diff --git a/crates/aprender-mcp/Cargo.toml b/crates/aprender-mcp/Cargo.toml index 3bd65e308c..db9b4e05f5 100644 --- a/crates/aprender-mcp/Cargo.toml +++ b/crates/aprender-mcp/Cargo.toml @@ -35,7 +35,10 @@ native = ["dep:anyhow"] # stdio loop. Off by default — PR 1 wires the dep, PR 2 lands the parity test # (FALSIFY-MCP-009), PR 3 extends cancellation, PR 4/5 add SSE/WebSocket # transports. See docs/specifications/apr-mcp-server-spec.md §M5. -pmcp-dispatcher = ["dep:pmcp"] +# The dispatcher in server.rs uses `anyhow` (pulled by `native`) and +# `tools::args::json_type_name`, also gated on `native`. Declared without it, +# `--features pmcp-dispatcher` did not compile. +pmcp-dispatcher = ["dep:pmcp", "native"] [dependencies] serde = { workspace = true } diff --git a/crates/aprender-profile-core/Cargo.toml b/crates/aprender-profile-core/Cargo.toml index bf43022e4c..9980163c2a 100644 --- a/crates/aprender-profile-core/Cargo.toml +++ b/crates/aprender-profile-core/Cargo.toml @@ -9,7 +9,12 @@ description = "Core tracing primitives for renacer — zero-dep span/event types rust-version = "1.75" [features] -otlp = [] +# trace_context.rs converts to `opentelemetry::trace::{TraceId, SpanId}` under +# this feature. The sibling aprender-profile declares the same feature as +# `["dep:opentelemetry", ...]`; -core copied the NAME without the dependency, so +# `--features otlp` never compiled. 0.31.0 is the version already in Cargo.lock +# (via aprender-profile), so this adds no new locked package. +otlp = ["dep:opentelemetry"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } @@ -18,6 +23,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } name = "renacer_core" [dependencies] +opentelemetry = { version = "0.31.0", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" hex = "0.4" diff --git a/crates/aprender-rag-cli/src/lib.rs b/crates/aprender-rag-cli/src/lib.rs index 0a59473681..8ad7d87964 100644 --- a/crates/aprender-rag-cli/src/lib.rs +++ b/crates/aprender-rag-cli/src/lib.rs @@ -314,8 +314,12 @@ pub enum Commands { } /// Eval sub-subcommands +/// +/// `Clone`/`Debug` like the parent [`Commands`] — it is a field of the `Eval` +/// variant, so the parent's derives require them. Without them `--features +/// eval` did not compile. #[cfg(feature = "eval")] -#[derive(Subcommand)] +#[derive(Subcommand, Clone, Debug)] enum EvalAction { /// Sample chunks from index for ground truth generation (no API needed) Sample { diff --git a/crates/aprender-serve/Cargo.toml b/crates/aprender-serve/Cargo.toml index 8cc95e708a..680b4f09b3 100644 --- a/crates/aprender-serve/Cargo.toml +++ b/crates/aprender-serve/Cargo.toml @@ -218,7 +218,17 @@ default = ["server", "cli", "gpu"] # Core features gpu = ["trueno/gpu"] # Enable GPU acceleration via Trueno (wgpu) -cuda = ["dep:trueno-gpu", "dep:tracing"] # Enable CUDA PTX generation for NVIDIA GPUs +# Enable CUDA PTX generation for NVIDIA GPUs. +# `gpu` and `server` are not optional here: the CUDA generate path reports +# `crate::api::PhaseTimings` (api is `server`-gated) and the cached-weights path +# it shares uses `DequantizedWeightCache`/`crate::gpu` (both `gpu`-gated). +# Declared without them, `--features cuda` and `--features gpu,cuda` did not +# compile — only the default set, which happens to carry both, ever did. +# `cli` is here for two PURE sampling helpers — cli::inference::{argmax, +# sample_with_temperature} — that the CUDA q4k scheduler calls. Moving those out +# of the cli-gated module would let `cuda` drop this edge and stop pulling clap +# and presentar-terminal; until then the declaration states what the code needs. +cuda = ["dep:trueno-gpu", "dep:tracing", "gpu", "server", "cli"] kv-cache = ["dep:trueno-db"] # KV cache for attention optimization apr-compression = ["dep:lz4_flex", "dep:zstd"] # LZ4/ZSTD decompression for APR v2 (GH-35) diff --git a/crates/aprender-viz/src/plots/histogram.rs b/crates/aprender-viz/src/plots/histogram.rs index 4ebd0a8de4..7801dc37f0 100644 --- a/crates/aprender-viz/src/plots/histogram.rs +++ b/crates/aprender-viz/src/plots/histogram.rs @@ -53,6 +53,20 @@ impl Histogram { } } + /// Set the rendered size in pixels. + /// + /// `width`/`height` were already fields, already defaulted (800x600), and + /// already read by [`Histogram::to_framebuffer`] — only the setter was + /// missing, so `aprender-serve`'s latency histogram could not compile + /// against it while every sibling widget (sparkline, resource bar, ggplot) + /// offers the same method. + #[must_use] + pub fn dimensions(mut self, width: u32, height: u32) -> Self { + self.width = width; + self.height = height; + self + } + /// Set the data. #[must_use] pub fn data(mut self, data: &[f32]) -> Self { From 1d7f70b10dae56a265a73c58f44a1ba069d951e1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 13:37:12 +0200 Subject: [PATCH 6/6] fix(orchestrate): the rag TUI gated on the dep-feature, not the composite that supplies it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `oracle/rag/tui.rs` carried 20 `#[cfg(feature = "presentar-terminal")]` gates. That is the IMPLICIT feature cargo creates for the optional dependency: it links presentar-terminal and nothing else. The module also uses `crossterm`, which only the composite `tui = ["presentar-terminal", "crossterm", "native"]` supplies — so `--features presentar-terminal` compiled the module without a crate it needs. Re-pointed at `tui`, the feature that actually provides what the module uses. The only behavioural change is that a selection which never compiled now excludes the module instead; `--features tui` is unaffected. The other 16 `presentar-terminal` gates in this crate are left alone — they guard code that needs only presentar. Touching the file put its PRE-EXISTING `run_loop` (cognitive 32) in front of the pre-commit complexity gate, so it is paid down here as the gate requires: the resize block becomes `sync_size` and the key match becomes `handle_key`, which returns `false` to quit. Behaviour-preserving — the `> 0` guard before a decrement is exactly `saturating_sub(1)`, and a non-Press event still falls through. aprender-orchestrate --lib 6535/6535; presentar-terminal and tui both check clean. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/oracle/rag/tui.rs | 109 ++++++++++-------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/crates/aprender-orchestrate/src/oracle/rag/tui.rs b/crates/aprender-orchestrate/src/oracle/rag/tui.rs index 33ec5c4d29..30f71cd248 100644 --- a/crates/aprender-orchestrate/src/oracle/rag/tui.rs +++ b/crates/aprender-orchestrate/src/oracle/rag/tui.rs @@ -8,14 +8,14 @@ //! Migrated from ratatui to presentar-terminal for stack consistency. //! Uses Brick Architecture with Jidoka verification gates. -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use std::collections::VecDeque; -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use std::io::{self, Write}; -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use std::time::Duration; -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use crossterm::{ cursor, event::{self, Event, KeyCode, KeyEventKind}, @@ -23,14 +23,14 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use presentar_terminal::{CellBuffer, Color, DiffRenderer, Modifiers}; -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] use super::types::{IndexHealthMetrics, RelevanceMetrics}; /// CYAN color constant (not in presentar-terminal) -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] const CYAN: Color = Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 }; /// Query record for history display @@ -49,7 +49,7 @@ pub struct QueryRecord { } /// TUI Dashboard state -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] pub struct OracleDashboard { /// Index health metrics pub index_health: IndexHealthMetrics, @@ -75,7 +75,7 @@ pub struct OracleDashboard { height: u16, } -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] impl OracleDashboard { /// Create a new dashboard pub fn new() -> Self { @@ -126,49 +126,56 @@ impl OracleDashboard { result } + /// Re-fit the buffer when the terminal has changed size. + fn sync_size(&mut self) { + let (w, h) = crossterm::terminal::size().unwrap_or((100, 30)); + if w == self.width && h == self.height { + return; + } + self.width = w; + self.height = h; + self.buffer.resize(w, h); + self.renderer.reset(); + } + + /// Apply one key event. `false` means the loop should quit. + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + if key.kind != KeyEventKind::Press { + return true; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return false, + KeyCode::Up | KeyCode::Char('k') => { + self.selected_component = self.selected_component.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.index_health.docs_per_component.len().saturating_sub(1); + if self.selected_component < max { + self.selected_component += 1; + } + } + // KeyCode::Char('r') would trigger a refresh — not implemented yet. + _ => {} + } + true + } + /// Main event loop fn run_loop(&mut self, stdout: &mut io::Stdout) -> anyhow::Result<()> { loop { - // Update terminal size - let (w, h) = crossterm::terminal::size().unwrap_or((100, 30)); - if w != self.width || h != self.height { - self.width = w; - self.height = h; - self.buffer.resize(w, h); - self.renderer.reset(); - } + self.sync_size(); - // Clear and render self.buffer.clear(); self.render(); - // Flush to terminal self.renderer.flush(&mut self.buffer, stdout)?; stdout.flush()?; if event::poll(self.refresh_interval)? { - let event = event::read()?; - let Event::Key(key) = event else { continue }; - if key.kind != KeyEventKind::Press { - continue; - } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), - KeyCode::Up | KeyCode::Char('k') => { - if self.selected_component > 0 { - self.selected_component -= 1; - } - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self.index_health.docs_per_component.len().saturating_sub(1); - if self.selected_component < max { - self.selected_component += 1; - } - } - KeyCode::Char('r') => { - // Trigger refresh - placeholder + if let Event::Key(key) = event::read()? { + if !self.handle_key(key) { + return Ok(()); } - _ => {} } } } @@ -444,7 +451,7 @@ impl OracleDashboard { } } -#[cfg(feature = "presentar-terminal")] +#[cfg(feature = "tui")] impl Default for OracleDashboard { fn default() -> Self { Self::new() @@ -604,7 +611,7 @@ mod tests { assert!(bar.contains("85%")); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_creation() { let dashboard = OracleDashboard::new(); @@ -612,7 +619,7 @@ mod tests { assert!(dashboard.latency_samples.is_empty()); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_record_query() { let mut dashboard = OracleDashboard::new(); @@ -625,7 +632,7 @@ mod tests { assert_eq!(dashboard.latency_samples.len(), 1); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_default() { let dashboard = OracleDashboard::default(); @@ -633,7 +640,7 @@ mod tests { assert_eq!(dashboard.selected_component, 0); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_update_health() { let mut dashboard = OracleDashboard::new(); @@ -652,7 +659,7 @@ mod tests { assert_eq!(dashboard.index_health.docs_per_component.len(), 1); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_latency_samples_bounded() { let mut dashboard = OracleDashboard::new(); @@ -666,7 +673,7 @@ mod tests { assert_eq!(dashboard.latency_samples.len(), 50); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_query_history_bounded() { let mut dashboard = OracleDashboard::new(); @@ -680,7 +687,7 @@ mod tests { assert_eq!(dashboard.query_history.len(), 100); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_dashboard_query_order() { let mut dashboard = OracleDashboard::new(); @@ -785,7 +792,7 @@ mod tests { assert!(!record.success); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_health_color_red() { let dashboard = OracleDashboard::new(); @@ -793,7 +800,7 @@ mod tests { assert_eq!(color, Color::RED); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_health_color_yellow() { let dashboard = OracleDashboard::new(); @@ -801,7 +808,7 @@ mod tests { assert_eq!(color, Color::YELLOW); } - #[cfg(feature = "presentar-terminal")] + #[cfg(feature = "tui")] #[test] fn test_health_color_green() { let dashboard = OracleDashboard::new();