diff --git a/Cargo.lock b/Cargo.lock index 81f4d1e072..9b84a22f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,6 +1107,7 @@ name = "aprender-profile-core" version = "0.68.1" dependencies = [ "hex", + "opentelemetry", "serde", "serde_json", "static_assertions", @@ -1395,6 +1396,7 @@ dependencies = [ "aprender-present-core", "aprender-present-terminal", "aprender-present-test", + "axum 0.7.9", "bincode", "bitflags 2.13.0", "blake3", @@ -1406,6 +1408,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 a5d8e2d630..649c29a780 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/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 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-core/Cargo.toml b/crates/aprender-core/Cargo.toml index ff9b7eff20..a8886ce6a2 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-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-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; 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(); 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 1324f7f0ff..0fa476d8e3 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-simulate/Cargo.toml b/crates/aprender-simulate/Cargo.toml index 7e50043c91..4b007065c5 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) { 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, diff --git a/crates/aprender-viz/src/plots/histogram.rs b/crates/aprender-viz/src/plots/histogram.rs index ba3ebdf026..77cb3cd399 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 {