From c3061520d10a2f212ba699e727dde19870411313 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 10:03:38 +0200 Subject: [PATCH 1/8] fix(serve): request tools never reached the chat template; Qwen3.5 XML tool calls never parsed (#4650) The render context had no `tools` key. `{% if tools %}` on an undefined variable is false with no error, so every Qwen template (2.5, 3, 3.5) skipped its `# Tools` block and the model never learned a tool existed. - render_official_with_tools / *_thinking_tools carry request.tools into the template from the Qwen3.5, CUDA and dense chat paths - the `tojson` filter now writes what HF's json.dumps writes - ToolCallFormat::QwenXml parses `` calls, typed by the tool's JSON schema, ahead of JSON when the text has ` --- .../src/api/cuda_chat_backend.rs | 247 +++++++++++------- .../src/api/gpu_completions_handler.rs | 44 ++-- crates/aprender-serve/src/api/mod.rs | 8 +- .../aprender-serve/src/api/mod_create_demo.rs | 16 +- .../aprender-serve/src/api/openai_handlers.rs | 141 +++++++++- .../src/api/qwen35_chat_backend.rs | 22 +- .../src/api/realize_handlers.rs | 55 +++- .../realize_handlers_official_3990_tests.rs | 35 +++ .../src/api/tests/dense_session_4268.rs | 2 +- .../src/chat_template_official.rs | 109 +++++++- .../src/chat_template_official_tests.rs | 92 +++++++ crates/aprender-serve/src/grammar/tool.rs | 73 ++++++ docs/audits/surface_audit.csv | 2 +- docs/roadmaps/entries/PMAT-4650.yaml | 21 ++ docs/roadmaps/roadmap.yaml | 21 ++ 15 files changed, 747 insertions(+), 141 deletions(-) create mode 100644 docs/roadmaps/entries/PMAT-4650.yaml diff --git a/crates/aprender-serve/src/api/cuda_chat_backend.rs b/crates/aprender-serve/src/api/cuda_chat_backend.rs index 76f9d7e9f8..79831007e9 100644 --- a/crates/aprender-serve/src/api/cuda_chat_backend.rs +++ b/crates/aprender-serve/src/api/cuda_chat_backend.rs @@ -27,11 +27,12 @@ fn try_safetensors_cuda_backend( // #4007: the loaded model's architecture, not the client's `model` string. // #3723: the request's thinking mode; an ON the template cannot express is refused by name. - let prompt = match crate::api::realize_handlers::format_chat_messages_for_state_thinking( + let prompt = match crate::api::realize_handlers::format_chat_messages_for_state_thinking_tools( state, &request.messages, Some(&request.model), request.thinking(), + request.tools.as_deref(), ) { Ok(p) => p, Err(e) => return Some(fail_response(state, StatusCode::BAD_REQUEST, e.to_string())), @@ -121,7 +122,7 @@ async fn try_cuda_backend( // D5: refused before any path (streaming, batch scheduler, direct) takes the // model, so the client gets a 400 rather than a 500 or a mid-stream error. let tokenized = - tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), state); + tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), request.tools.as_deref(), state); let prompt_ids = match fit_serving_context(state, tokenized) { Ok(ids) => ids, @@ -152,49 +153,10 @@ async fn try_cuda_backend( let (timing_tx, timing_rx) = tokio::sync::oneshot::channel::(); - // PMAT-044: Use batch scheduler if available (continuous batching) - if let Some(batch_tx) = state.cuda_batch_tx() { - let batch_req = super::cuda_batch_scheduler::CudaBatchRequest { - prompt_ids, - config: q_config, - token_tx: tx, - non_streaming: false, - enqueue_time: std::time::Instant::now(), - timing_tx: Some(timing_tx), - }; - if let Err(e) = batch_tx.try_send(batch_req) { - // §5.2: this 503 is the one admission REFUSAL this server has - // (the policy is otherwise `queue`), so it is counted where it - // is returned — `kv.admission_rejected` reads this counter. - state.record_admission_rejected(); - return Some(fail_response( - state, - StatusCode::SERVICE_UNAVAILABLE, - format!("Batch queue full: {e}"), - )); - } - } else { - // Fallback: direct RwLock path (serialized) - let cuda_model_clone = cuda_model_lock.clone(); - let prompt_ids_clone = prompt_ids.clone(); - let q_config_clone = q_config.clone(); - let sink_metrics = state.metrics.clone(); - - tokio::task::spawn_blocking(move || { - let mut cuda_model = cuda_model_clone.write().expect("operation failed"); - let generate_start = std::time::Instant::now(); - // Stops when the client goes away — see `streaming_token_sink`. - let sink = - crate::api::openai_handlers::streaming_token_sink(tx.clone(), sink_metrics); - let result = - dense_cuda_turn(&mut cuda_model, &prompt_ids_clone, &q_config_clone, sink); - // Taken under the SAME write lock the request ran under, so the - // split belongs to this request and to no other. - let _ = timing_tx.send(phase_split(&mut cuda_model, generate_start)); - if let Err(e) = result { - let _ = tx.blocking_send(Err(e.to_string())); - } - }); + if let Err(r) = + dispatch_cuda_stream(state, cuda_model_lock, prompt_ids, q_config, tx, timing_tx) + { + return Some(r); } return Some(true_streaming_sse_response( @@ -213,54 +175,14 @@ async fn try_cuda_backend( // Non-streaming CUDA — route through batch scheduler when available (realizr#211) let (timing_tx, timing_rx) = tokio::sync::oneshot::channel::(); - let (token_ids, completion_tokens, response_text) = if let Some(batch_tx) = state.cuda_batch_tx() { - // Use batch scheduler: submit request and collect all tokens - // realizr#212: capacity 512 for bulk-send after non-streaming generation - let (tx, mut rx) = tokio::sync::mpsc::channel::>(512); - let batch_req = super::cuda_batch_scheduler::CudaBatchRequest { - prompt_ids, - config: q_config, - token_tx: tx, - non_streaming: true, // realizr#212: scheduler accumulates + bulk-sends - enqueue_time: std::time::Instant::now(), - timing_tx: Some(timing_tx), - }; - if let Err(e) = batch_tx.try_send(batch_req) { - // §5.2: counted at the refusal, as above. - state.record_admission_rejected(); - return Some(fail_response( - state, - StatusCode::SERVICE_UNAVAILABLE, - format!("Batch queue full: {e}"), - )); - } - // Collect all tokens via async receive (realizr#211) - let mut tokens = Vec::new(); - while let Some(result) = rx.recv().await { - match result { - Ok(token_id) => tokens.push(token_id), - // D5: `fit_serving_context` above already refused an over-context - // prompt with a 400; the scheduler's errors are strings, and what - // reaches here is a server fault. - Err(e) => return Some(fail_response(state, StatusCode::INTERNAL_SERVER_ERROR, e)), - } - } - let n = tokens.len(); - let text = tokenizer.decode(&tokens).unwrap_or_else(|_| String::new()); - (tokens, n, clean_chat_output(&text)) + let generated = if let Some(batch_tx) = state.cuda_batch_tx() { + cuda_batch_collect(state, batch_tx, prompt_ids, q_config, timing_tx, &tokenizer).await } else { - // Fallback: direct RwLock path (serialized, no batch scheduler) - let mut cuda_model = cuda_model_lock.write().expect("operation failed"); - let generate_start = std::time::Instant::now(); - let generated = match dense_cuda_turn(&mut cuda_model, &prompt_ids, &q_config, |_| true) { - Ok(g) => g, - Err(e) => return Some(fail_response(state, crate::api::generation_error_status(&e), e)), - }; - let _ = timing_tx.send(phase_split(&mut cuda_model, generate_start)); - let tokens: Vec = generated.iter().skip(prompt_tokens).copied().collect(); - let n = tokens.len(); - let text = tokenizer.decode(&tokens).unwrap_or_else(|_| String::new()); - (tokens, n, clean_chat_output(&text)) + cuda_direct_collect(state, cuda_model_lock, &prompt_ids, &q_config, timing_tx, &tokenizer, prompt_tokens) + }; + let (token_ids, completion_tokens, response_text) = match generated { + Ok(g) => g, + Err(r) => return Some(r), }; let latency = start.elapsed(); @@ -287,6 +209,137 @@ async fn try_cuda_backend( )) } +/// `try_cuda_backend`'s streaming dispatch: the batch scheduler when one is +/// running, else the direct RwLock path on a blocking task. `Err` is the 503 +/// for a full batch queue. +#[cfg(feature = "cuda")] +#[allow(clippy::result_large_err)] +fn dispatch_cuda_stream( + state: &AppState, + cuda_model_lock: &Arc>, + prompt_ids: Vec, + q_config: crate::gguf::QuantizedGenerateConfig, + tx: tokio::sync::mpsc::Sender>, + timing_tx: tokio::sync::oneshot::Sender, +) -> Result<(), Response> { + // PMAT-044: Use batch scheduler if available (continuous batching) + if let Some(batch_tx) = state.cuda_batch_tx() { + let batch_req = super::cuda_batch_scheduler::CudaBatchRequest { + prompt_ids, + config: q_config, + token_tx: tx, + non_streaming: false, + enqueue_time: std::time::Instant::now(), + timing_tx: Some(timing_tx), + }; + if let Err(e) = batch_tx.try_send(batch_req) { + // §5.2: this 503 is the one admission REFUSAL this server has + // (the policy is otherwise `queue`), so it is counted where it + // is returned — `kv.admission_rejected` reads this counter. + state.record_admission_rejected(); + return Err(fail_response( + state, + StatusCode::SERVICE_UNAVAILABLE, + format!("Batch queue full: {e}"), + )); + } + } else { + // Fallback: direct RwLock path (serialized) + let cuda_model_clone = cuda_model_lock.clone(); + let sink_metrics = state.metrics.clone(); + + tokio::task::spawn_blocking(move || { + let mut cuda_model = cuda_model_clone.write().expect("operation failed"); + let generate_start = std::time::Instant::now(); + // Stops when the client goes away — see `streaming_token_sink`. + let sink = + crate::api::openai_handlers::streaming_token_sink(tx.clone(), sink_metrics); + let result = dense_cuda_turn(&mut cuda_model, &prompt_ids, &q_config, sink); + // Taken under the SAME write lock the request ran under, so the + // split belongs to this request and to no other. + let _ = timing_tx.send(phase_split(&mut cuda_model, generate_start)); + if let Err(e) = result { + let _ = tx.blocking_send(Err(e.to_string())); + } + }); + } + Ok(()) +} + +/// `try_cuda_backend`'s non-streaming turn through the batch scheduler +/// (realizr#211): submit, then collect every token. Returns +/// `(tokens, completion_tokens, cleaned text)`. +#[cfg(feature = "cuda")] +async fn cuda_batch_collect( + state: &AppState, + batch_tx: &tokio::sync::mpsc::Sender, + prompt_ids: Vec, + q_config: crate::gguf::QuantizedGenerateConfig, + timing_tx: tokio::sync::oneshot::Sender, + tokenizer: &BPETokenizer, +) -> Result<(Vec, usize, String), Response> { + // Use batch scheduler: submit request and collect all tokens + // realizr#212: capacity 512 for bulk-send after non-streaming generation + let (tx, mut rx) = tokio::sync::mpsc::channel::>(512); + let batch_req = super::cuda_batch_scheduler::CudaBatchRequest { + prompt_ids, + config: q_config, + token_tx: tx, + non_streaming: true, // realizr#212: scheduler accumulates + bulk-sends + enqueue_time: std::time::Instant::now(), + timing_tx: Some(timing_tx), + }; + if let Err(e) = batch_tx.try_send(batch_req) { + // §5.2: counted at the refusal, as in `dispatch_cuda_stream`. + state.record_admission_rejected(); + return Err(fail_response( + state, + StatusCode::SERVICE_UNAVAILABLE, + format!("Batch queue full: {e}"), + )); + } + // Collect all tokens via async receive (realizr#211) + let mut tokens = Vec::new(); + while let Some(result) = rx.recv().await { + match result { + Ok(token_id) => tokens.push(token_id), + // D5: `fit_serving_context` already refused an over-context + // prompt with a 400; the scheduler's errors are strings, and what + // reaches here is a server fault. + Err(e) => return Err(fail_response(state, StatusCode::INTERNAL_SERVER_ERROR, e)), + } + } + let n = tokens.len(); + let text = tokenizer.decode(&tokens).unwrap_or_else(|_| String::new()); + Ok((tokens, n, clean_chat_output(&text))) +} + +/// `try_cuda_backend`'s non-streaming turn when no batch scheduler runs: the +/// direct RwLock path (serialized). Same return shape as `cuda_batch_collect`. +#[cfg(feature = "cuda")] +#[allow(clippy::result_large_err)] +fn cuda_direct_collect( + state: &AppState, + cuda_model_lock: &Arc>, + prompt_ids: &[u32], + q_config: &crate::gguf::QuantizedGenerateConfig, + timing_tx: tokio::sync::oneshot::Sender, + tokenizer: &BPETokenizer, + prompt_tokens: usize, +) -> Result<(Vec, usize, String), Response> { + let mut cuda_model = cuda_model_lock.write().expect("operation failed"); + let generate_start = std::time::Instant::now(); + let generated = match dense_cuda_turn(&mut cuda_model, prompt_ids, q_config, |_| true) { + Ok(g) => g, + Err(e) => return Err(fail_response(state, crate::api::generation_error_status(&e), e)), + }; + let _ = timing_tx.send(phase_split(&mut cuda_model, generate_start)); + let tokens: Vec = generated.iter().skip(prompt_tokens).copied().collect(); + let n = tokens.len(); + let text = tokenizer.decode(&tokens).unwrap_or_else(|_| String::new()); + Ok((tokens, n, clean_chat_output(&text))) +} + /// §3: pair the engine's prefill measurement with the decode remainder. /// /// `take_phase_timings` clears as it reads, so a request that took a @@ -358,7 +411,7 @@ fn try_quantized_backend( // GH-319: Use actual model architecture for chat template detection let arch_hint = state.model_architecture(); let prompt_ids = - match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), state) { + match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), request.tools.as_deref(), state) { Ok(ids) => ids, Err(r) => return Some(r), }; @@ -516,7 +569,7 @@ fn try_apr_transformer_backend( }; let arch_hint = state.model_architecture(); let prompt_ids = - match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), state) { + match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), request.tools.as_deref(), state) { Ok(ids) => ids, Err(r) => return Some(r), }; @@ -613,11 +666,12 @@ fn registry_prompt_ids( request: &ChatCompletionRequest, tokenizer: &BPETokenizer, ) -> Result, Response> { - let prompt_text = match crate::api::realize_handlers::format_chat_messages_for_state_thinking( + let prompt_text = match crate::api::realize_handlers::format_chat_messages_for_state_thinking_tools( state, &request.messages, Some(&request.model), request.thinking(), + request.tools.as_deref(), ) { Ok(p) => p, Err(e) => return Err(fail_response(state, StatusCode::BAD_REQUEST, e.to_string())), @@ -820,7 +874,7 @@ async fn try_apr_q4k_chat_backend( }; let arch_hint = state.model_architecture(); let prompt_ids = - match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), state) { + match tokenize_chat_prompt(&tokenizer, &request.messages, arch_hint.as_deref(), request.thinking(), request.tools.as_deref(), state) { Ok(ids) => ids, Err(r) => return Some(r), }; @@ -1229,6 +1283,7 @@ fn try_qwen3_moe_backend( &request.messages, Some(&request.model), request.thinking(), + request.tools.as_deref(), state, ) { Ok(ids) => ids, diff --git a/crates/aprender-serve/src/api/gpu_completions_handler.rs b/crates/aprender-serve/src/api/gpu_completions_handler.rs index 3965052262..14c1ad0cc1 100644 --- a/crates/aprender-serve/src/api/gpu_completions_handler.rs +++ b/crates/aprender-serve/src/api/gpu_completions_handler.rs @@ -598,10 +598,33 @@ async fn completions_inner( return Ok(r); } - // GH-627/637/670: Direct CUDA model fallback for APR GPU path - // with_cuda_model_and_vocab sets cuda_model but NOT model, - // so registry_completions fails with "No model available". #[cfg(feature = "cuda")] + if let Some(r) = try_cuda_direct_completions(&state, &request, max_tokens, temperature, start)? { + return Ok(r); + } + + // aprender#2609: the f32 APR / SafeTensors CPU backend, in the same position + // the chat chain puts it — after quantized, before the dense registry. + if let Some(r) = + try_apr_transformer_completions(&state, &request, max_tokens, temperature, start, &cancel)? + { + return Ok(r); + } + + registry_completions(&state, &request, max_tokens, temperature, start, &cancel) +} + +/// GH-627/637/670: Direct CUDA model fallback for APR GPU path. +/// with_cuda_model_and_vocab sets cuda_model but NOT model, +/// so registry_completions fails with "No model available". +#[cfg(feature = "cuda")] +fn try_cuda_direct_completions( + state: &AppState, + request: &CompletionRequest, + max_tokens: usize, + temperature: f32, + start: std::time::Instant, +) -> Result, RErr> { if let Some(cuda_lock) = state.cuda_model() { use crate::gguf::QuantizedGenerateConfig; let tokenizer = state.tokenizer.clone().ok_or_else(|| { @@ -636,7 +659,7 @@ async fn completions_inner( // #2465(2): this inline backend ignored `request.stop` too. let (text, finish_reason) = apply_stop_sequences(text, request.stop.as_deref(), completion_tokens, max_tokens); - return Ok(CompletionResponse { + return Ok(Some(CompletionResponse { id: format!("cmpl-cuda-{}", elapsed.as_millis()), object: "text_completion".to_string(), created: std::time::SystemTime::now() @@ -657,18 +680,9 @@ async fn completions_inner( }, // The inline CUDA block records no backend flag either (#3894). used_gpu: None, - }); + })); } - - // aprender#2609: the f32 APR / SafeTensors CPU backend, in the same position - // the chat chain puts it — after quantized, before the dense registry. - if let Some(r) = - try_apr_transformer_completions(&state, &request, max_tokens, temperature, start, &cancel)? - { - return Ok(r); - } - - registry_completions(&state, &request, max_tokens, temperature, start, &cancel) + Ok(None) } /// The `/v1/logprobs` generation config: greedy, logprobs on, and stopping on diff --git a/crates/aprender-serve/src/api/mod.rs b/crates/aprender-serve/src/api/mod.rs index a51c76bd10..db6007a969 100644 --- a/crates/aprender-serve/src/api/mod.rs +++ b/crates/aprender-serve/src/api/mod.rs @@ -122,9 +122,11 @@ pub use gpu_handlers::{spawn_batch_processor, BatchConfig}; mod realize_handlers; pub(crate) use realize_handlers::{ clean_chat_output, format_chat_messages, format_chat_messages_for_state, - format_chat_messages_for_state_thinking, format_chat_messages_official, - format_chat_messages_official_thinking, openai_completions_handler, openai_embeddings_handler, - realize_embed_handler, realize_model_handler, realize_reload_handler, + format_chat_messages_for_state_thinking, format_chat_messages_for_state_thinking_tools, + format_chat_messages_official, format_chat_messages_official_thinking, + format_chat_messages_official_thinking_tools, openai_completions_handler, + openai_embeddings_handler, realize_embed_handler, realize_model_handler, + realize_reload_handler, }; #[cfg(feature = "cuda")] pub(crate) use realize_handlers::{logprobs_handler, perplexity_handler}; diff --git a/crates/aprender-serve/src/api/mod_create_demo.rs b/crates/aprender-serve/src/api/mod_create_demo.rs index 0036602a7f..8ebddb2654 100644 --- a/crates/aprender-serve/src/api/mod_create_demo.rs +++ b/crates/aprender-serve/src/api/mod_create_demo.rs @@ -383,10 +383,24 @@ fn extract_tool_parameters( .and_then(|d| d.as_str()) .unwrap_or_default() .to_string(); + // #4650: the scalar JSON-Schema type, so a call parsed from text (Qwen XML) + // types its value as declared. Anything else stays String, as before. + let param_type = match spec.get("type").and_then(|t| t.as_str()) { + Some("integer") => ToolParameterType::Integer, + Some("number") => ToolParameterType::Number, + Some("boolean") => ToolParameterType::Boolean, + Some("array") => ToolParameterType::Array { + items: Box::new(ToolParameterType::String), + }, + Some("object") => ToolParameterType::Object { + properties: Vec::new(), + }, + _ => ToolParameterType::String, + }; ToolParameter { name: name.clone(), description, - param_type: ToolParameterType::String, + param_type, required: required.contains(name.as_str()), default: None, } diff --git a/crates/aprender-serve/src/api/openai_handlers.rs b/crates/aprender-serve/src/api/openai_handlers.rs index 01f580407e..4d7ea9fed8 100644 --- a/crates/aprender-serve/src/api/openai_handlers.rs +++ b/crates/aprender-serve/src/api/openai_handlers.rs @@ -21,7 +21,7 @@ use futures::stream::Stream; use super::{ build_trace_data, clean_chat_output, format_chat_messages, - format_chat_messages_for_state_thinking, AppState, ChatChoice, ChatCompletionChunk, + format_chat_messages_for_state_thinking_tools, AppState, ChatChoice, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ErrorResponse, FinishReason, OpenAIModel, OpenAIModelsResponse, StreamMode, Usage, }; @@ -90,12 +90,13 @@ fn tokenize_chat_prompt( messages: &[ChatMessage], model_hint: Option<&str>, thinking: Option, + tools: Option<&[super::OpenAiTool]>, state: &AppState, ) -> Result, Response> { // #3723: the request's thinking mode; an ON the model's template cannot express is the - // client's error, answered by name. + // client's error, answered by name. #4650: the request's tools reach the template too. let prompt_text = - format_chat_messages_for_state_thinking(state, messages, model_hint, thinking) + format_chat_messages_for_state_thinking_tools(state, messages, model_hint, thinking, tools) .map_err(|e| fail_response(state, StatusCode::BAD_REQUEST, e.to_string()))?; let ids = tokenizer.encode(&prompt_text); if ids.is_empty() { @@ -639,8 +640,21 @@ fn build_tool_calling_message( let defs: Vec = tools.iter().map(super::OpenAiTool::to_grammar).collect(); - let mut parser = ToolCallParser::new(defs); - let calls = parser.parse(&text); + // #4650: Qwen3.5's template instructs `` XML. When the + // text carries that marker the XML parser goes FIRST: a parameter value may hold + // JSON shaped like a tool call, and the default parser would take that inner object + // as the call. With the marker the XML result is FINAL, so an XML call to an + // undeclared tool stays content rather than falling back to the JSON inside it. The + // price: a JSON call whose string argument quotes a whole declared `` + // block resolves to that XML call, and JSON in text that only mentions the marker + // is not read. + let calls = if text.contains(" ids, @@ -1188,6 +1203,7 @@ fn try_cached_backend( &request.messages, arch_hint.as_deref(), request.thinking(), + request.tools.as_deref(), state, ) { Ok(ids) => ids, @@ -1426,6 +1442,109 @@ mod pmat801_tool_calling_tests { assert_eq!(msg.role, "assistant"); } + fn args_of(msg: &ChatMessage) -> serde_json::Value { + let calls = msg.tool_calls.as_ref().expect("tool_calls populated"); + assert_eq!(calls.len(), 1); + serde_json::from_str(&calls[0].function.arguments).expect("arguments is JSON") + } + + /// #4650 must-RED on main: Qwen3.5's template tells the model to call in XML, and the + /// parser read only JSON, so a correct call came back as plain content. + #[test] + fn qwen35_xml_tool_call_populates_tool_calls_4650() { + let tools = vec![weather_tool()]; + let generated = "I'll check.\n\n\n\n\nParis\n\n\n".to_string(); + let (msg, reason) = build_tool_calling_message(generated, "stop".to_string(), &tools, None); + assert_eq!(reason, "tool_calls"); + assert_eq!( + msg.tool_calls.as_ref().expect("calls")[0].function.name, + "get_weather" + ); + assert_eq!(args_of(&msg), serde_json::json!({"city": "Paris"})); + } + + /// Values are typed by the tool's schema: an integer parameter is a JSON number, a + /// string parameter stays the string the model wrote even when it looks numeric. + #[test] + fn qwen35_xml_values_follow_the_schema_types_4650() { + let tool = OpenAiTool { + tool_type: "function".to_string(), + function: OpenAiFunctionDef { + name: "lookup".to_string(), + description: "Lookup".to_string(), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { "zip": { "type": "string" }, "days": { "type": "integer" } }, + "required": ["zip", "days"], + })), + }, + }; + let generated = "\n\n\n02134\n\n\n3\n\n\n".to_string(); + let (msg, reason) = + build_tool_calling_message(generated, "stop".to_string(), &[tool], None); + assert_eq!(reason, "tool_calls"); + assert_eq!( + args_of(&msg), + serde_json::json!({"zip": "02134", "days": 3}) + ); + } + + /// must-RED with JSON parsed first: an XML call whose parameter value is itself a + /// JSON tool-call object. The default parser took the inner object as the call and + /// the real XML call was lost. + #[test] + fn qwen35_xml_call_wins_over_json_inside_a_parameter_4650() { + let note = OpenAiTool { + tool_type: "function".to_string(), + function: OpenAiFunctionDef { + name: "save_note".to_string(), + description: "Save a note".to_string(), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { "body": { "type": "string" } }, + "required": ["body"], + })), + }, + }; + let tools = vec![weather_tool(), note]; + let inner = r#"{"name": "get_weather", "arguments": {"city": "NYC"}}"#; + let generated = format!( + "\n\n\n{inner}\n\n\n" + ); + let (msg, reason) = build_tool_calling_message(generated, "stop".to_string(), &tools, None); + assert_eq!(reason, "tool_calls"); + assert_eq!( + msg.tool_calls.as_ref().expect("calls")[0].function.name, + "save_note" + ); + assert_eq!(args_of(&msg), serde_json::json!({"body": inner})); + } + + /// must-RED with a JSON fallback after XML: an XML call to an undeclared tool parses to + /// nothing, and the fallback took the JSON in its parameter as a declared call. + #[test] + fn qwen35_undeclared_xml_call_does_not_promote_inner_json_4650() { + let tools = vec![weather_tool()]; + let generated = "\n\n\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"NYC\"}}\n\n\n".to_string(); + let (msg, reason) = + build_tool_calling_message(generated.clone(), "stop".to_string(), &tools, None); + assert_eq!(reason, "stop"); + assert!(msg.tool_calls.is_none()); + assert_eq!(msg.content, generated); + } + + /// A call to a tool the request never declared is not a tool call. + #[test] + fn qwen35_xml_call_to_an_undeclared_tool_stays_content_4650() { + let tools = vec![weather_tool()]; + let generated = "\n\n\n/\n\n\n".to_string(); + let (msg, reason) = + build_tool_calling_message(generated.clone(), "stop".to_string(), &tools, None); + assert_eq!(reason, "stop"); + assert!(msg.tool_calls.is_none()); + assert_eq!(msg.content, generated); + } + /// tool_choice "none" skips parsing even when a tool call is present in text. #[test] fn tool_choice_none_skips_parsing() { @@ -1563,7 +1682,8 @@ mod chat_template_wiring_3990 { official, legacy, "the probe must distinguish the two renders" ); - let got = tokenize_chat_prompt(&tokenizer, &msgs, None, None, &state).expect("tokenizes"); + let got = + tokenize_chat_prompt(&tokenizer, &msgs, None, None, None, &state).expect("tokenizes"); assert_eq!( got, tokenizer.encode(&official), @@ -1604,8 +1724,9 @@ mod chat_template_wiring_3990 { official, "the probe must distinguish ({client_model})" ); - let got = tokenize_chat_prompt(&tokenizer, &msgs, Some(client_model), None, &state) - .expect("tokenizes"); + let got = + tokenize_chat_prompt(&tokenizer, &msgs, Some(client_model), None, None, &state) + .expect("tokenizes"); assert_eq!( got, tokenizer.encode(&official), @@ -1706,8 +1827,8 @@ mod thinking_on_refusal_3723 { content: "Hi".to_string(), ..Default::default() }]; - assert!(tokenize_chat_prompt(&tokenizer, &msgs, None, Some(false), &state).is_ok()); - let refused = tokenize_chat_prompt(&tokenizer, &msgs, None, Some(true), &state); + assert!(tokenize_chat_prompt(&tokenizer, &msgs, None, Some(false), None, &state).is_ok()); + let refused = tokenize_chat_prompt(&tokenizer, &msgs, None, Some(true), None, &state); assert!( refused.is_err(), "Qwen2.5's template renders ON == OFF; ON must be refused" diff --git a/crates/aprender-serve/src/api/qwen35_chat_backend.rs b/crates/aprender-serve/src/api/qwen35_chat_backend.rs index bf24311418..164999c9bb 100644 --- a/crates/aprender-serve/src/api/qwen35_chat_backend.rs +++ b/crates/aprender-serve/src/api/qwen35_chat_backend.rs @@ -106,15 +106,19 @@ async fn try_qwen35_backend( let architecture = state.model_architecture(); // #3723: the request's thinking mode, rendered by the model's own template. - let prompt_text = match crate::api::realize_handlers::format_chat_messages_official_thinking( - Some(&mapped.model), - &request.messages, - architecture.as_deref(), - request.thinking(), - ) { - Ok(p) => p, - Err(e) => return Some(fail_response(state, StatusCode::BAD_REQUEST, e.to_string())), - }; + // #4650: and the request's tools -- the template's `# Tools` block is the only place the + // model learns a tool exists and the `` format to call it in. + let prompt_text = + match crate::api::realize_handlers::format_chat_messages_official_thinking_tools( + Some(&mapped.model), + &request.messages, + architecture.as_deref(), + request.thinking(), + request.tools.as_deref(), + ) { + Ok(p) => p, + Err(e) => return Some(fail_response(state, StatusCode::BAD_REQUEST, e.to_string())), + }; let input_ids = mapped.model.encode(&prompt_text).unwrap_or_default(); if input_ids.is_empty() { return Some(fail_response( diff --git a/crates/aprender-serve/src/api/realize_handlers.rs b/crates/aprender-serve/src/api/realize_handlers.rs index 2b4bff57e1..0f0410e09f 100644 --- a/crates/aprender-serve/src/api/realize_handlers.rs +++ b/crates/aprender-serve/src/api/realize_handlers.rs @@ -247,6 +247,23 @@ pub fn format_chat_messages_official_thinking( messages: &[ChatMessage], model_hint: Option<&str>, thinking: Option, +) -> Result { + format_chat_messages_official_thinking_tools(gguf, messages, model_hint, thinking, None) +} + +/// #4650: [`format_chat_messages_official_thinking`] with the request's `tools`, rendered +/// by the model's own template (its `# Tools` block). An empty array is no tools, as in +/// OpenAI. The hand-coded family templates have no tools block, so a request with tools +/// that falls back to one says so on stderr instead of dropping them in silence. +/// +/// # Errors +/// See [`format_chat_messages_official_thinking`]. +pub fn format_chat_messages_official_thinking_tools( + gguf: Option<&crate::gguf::GGUFModel>, + messages: &[ChatMessage], + model_hint: Option<&str>, + thinking: Option, + tools: Option<&[super::OpenAiTool]>, ) -> Result { use crate::chat_template::{self, ChatMessage as TemplateMessage}; @@ -254,13 +271,28 @@ pub fn format_chat_messages_official_thinking( .iter() .map(|m| TemplateMessage::new(&m.role, &m.content)) .collect(); + let tools_json = tools + .filter(|t| !t.is_empty()) + .and_then(|t| serde_json::to_value(t).ok()); let own = gguf .filter(|g| g.metadata.contains_key("tokenizer.chat_template")) .map(|g| { - let msgs = &template_messages; - move |t: Option| chat_template::render_official_for_model(g, msgs, t) + let (msgs, tools) = (&template_messages, tools_json.as_ref()); + move |t: Option| { + chat_template::render_official_for_model_with_tools(g, msgs, t, tools) + } }); - chat_template::official_or_legacy(own, || format_chat_messages(messages, model_hint), thinking) + let legacy = || { + if tools_json.is_some() { + eprintln!( + "warning: this request carries tools, and the model's own chat template is not \ + being used; apr's built-in template has no tools block, so the model is not \ + told about them (#4650)" + ); + } + format_chat_messages(messages, model_hint) + }; + chat_template::official_or_legacy(own, legacy, thinking) } /// [`format_chat_messages_official`] against whatever GGUF the server retained. @@ -287,15 +319,30 @@ pub fn format_chat_messages_for_state_thinking( messages: &[ChatMessage], model_hint: Option<&str>, thinking: Option, +) -> Result { + format_chat_messages_for_state_thinking_tools(state, messages, model_hint, thinking, None) +} + +/// #4650: [`format_chat_messages_for_state_thinking`] with the request's `tools`. +/// +/// # Errors +/// See [`format_chat_messages_official_thinking`]. +pub fn format_chat_messages_for_state_thinking_tools( + state: &AppState, + messages: &[ChatMessage], + model_hint: Option<&str>, + thinking: Option, + tools: Option<&[super::OpenAiTool]>, ) -> Result { let mapped = state.mapped_gguf_model(); let architecture = state.model_architecture(); let hint = architecture.as_deref().or(model_hint); - format_chat_messages_official_thinking( + format_chat_messages_official_thinking_tools( mapped.as_ref().map(|m| &m.model), messages, hint, thinking, + tools, ) } diff --git a/crates/aprender-serve/src/api/realize_handlers_official_3990_tests.rs b/crates/aprender-serve/src/api/realize_handlers_official_3990_tests.rs index d831b447fa..9d116d5e33 100644 --- a/crates/aprender-serve/src/api/realize_handlers_official_3990_tests.rs +++ b/crates/aprender-serve/src/api/realize_handlers_official_3990_tests.rs @@ -45,6 +45,41 @@ mod serve_official_chat_template_3990 { assert_eq!(got, "be tersehi"); } + /// #4650 mutant kill (`!t.is_empty()` at realize_handlers.rs:275): the request's tools + /// reach the model's own template, and an empty array is no tools. + #[test] + fn request_tools_reach_the_ggufs_own_template_4650() { + use crate::api::{OpenAiFunctionDef, OpenAiTool}; + let g = gguf_with(Some( + "{% if tools %}T:{{ tools[0]['function']['name'] }}{% else %}NOTOOLS{% endif %}|{% for m in messages %}{{ m['content'] }}{% endfor %}", + )); + let tool = OpenAiTool { + tool_type: "function".to_string(), + function: OpenAiFunctionDef { + name: "get_weather".to_string(), + description: "Get the weather".to_string(), + parameters: None, + }, + }; + let msgs = [msg("user", "hi")]; + let render = |tools: Option<&[OpenAiTool]>| { + super::format_chat_messages_official_thinking_tools( + Some(&g), + &msgs, + Some("qwen2"), + Some(false), + tools, + ) + .expect("renders") + }; + assert_eq!( + render(Some(std::slice::from_ref(&tool))), + "T:get_weather|hi" + ); + assert_eq!(render(Some(&[])), "NOTOOLS|hi"); + assert_eq!(render(None), "NOTOOLS|hi"); + } + #[test] fn no_gguf_or_no_template_keeps_the_legacy_formatter_3990() { let msgs = [msg("user", "hi")]; diff --git a/crates/aprender-serve/src/api/tests/dense_session_4268.rs b/crates/aprender-serve/src/api/tests/dense_session_4268.rs index 755bb0cdde..2db28d6366 100644 --- a/crates/aprender-serve/src/api/tests/dense_session_4268.rs +++ b/crates/aprender-serve/src/api/tests/dense_session_4268.rs @@ -50,7 +50,7 @@ fn prompt_ids(state: &AppState, body: &serde_json::Value) -> Vec { serde_json::from_value(body.clone()).expect("a chat request"); let tokenizer = require_tokenizer(state).unwrap_or_else(|_| panic!("tokenizer")); let arch = state.model_architecture(); - tokenize_chat_prompt(&tokenizer, &request.messages, arch.as_deref(), None, state) + tokenize_chat_prompt(&tokenizer, &request.messages, arch.as_deref(), None, None, state) .unwrap_or_else(|_| panic!("prompt ids")) } diff --git a/crates/aprender-serve/src/chat_template_official.rs b/crates/aprender-serve/src/chat_template_official.rs index 24f3bc452a..ba3b27d148 100644 --- a/crates/aprender-serve/src/chat_template_official.rs +++ b/crates/aprender-serve/src/chat_template_official.rs @@ -123,6 +123,63 @@ fn str_split(s: &str, args: &[minijinja::Value]) -> Result( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_key( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_value( + &mut self, + writer: &mut W, + ) -> std::io::Result<()> { + writer.write_all(b": ") + } +} + +/// The `tojson` filter as HuggingFace defines it for chat templates (#4650): +/// `json.dumps(x, ensure_ascii=False)`. minijinja's own `tojson` writes compact separators +/// and HTML-escapes `<`, `>`, `&` and `'` to `<`-style escapes, so a tool schema that +/// says "a < b" reached the model as text it was never trained on. The `tools` block and +/// replayed tool-call arguments are the only places the Qwen templates call it. +fn py_tojson(value: &minijinja::Value) -> Result { + use serde::Serialize; + let mut out = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut out, PyJsonFormatter); + value.serialize(&mut ser).map_err(|e| { + minijinja::Error::new( + minijinja::ErrorKind::BadSerialization, + format!("tojson: {e}"), + ) + })?; + let s = String::from_utf8(out).map_err(|e| { + minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string()) + })?; + Ok(minijinja::Value::from_safe_string(s)) +} + /// Render a model's own jinja chat template, with llama.cpp/HuggingFace semantics (#3990). /// /// # Errors @@ -134,12 +191,44 @@ pub fn render_official( messages: &[ChatMessage], add_generation_prompt: bool, enable_thinking: Option, +) -> Result { + render_official_with_tools( + chat_template, + bos_token, + eos_token, + messages, + add_generation_prompt, + enable_thinking, + None, + ) +} + +/// [`render_official`] with the request's OpenAI `tools` array (#4650). +/// +/// Qwen2.5, Qwen3 and Qwen3.5 templates gate their whole `# Tools` system block on +/// `{% if tools %}`. The context used to carry no `tools` key, and minijinja renders an +/// UNDEFINED variable as false with no error -- so every tool a client sent was dropped +/// from the prompt, silently, and the model was never told a tool existed. `tools` is the +/// array as the client sent it (`[{"type":"function","function":{...}}]`), the shape +/// HuggingFace's `apply_chat_template(tools=...)` passes; `None` leaves it undefined. +/// +/// # Errors +/// See [`render_official`]. +pub fn render_official_with_tools( + chat_template: &str, + bos_token: Option<&str>, + eos_token: Option<&str>, + messages: &[ChatMessage], + add_generation_prompt: bool, + enable_thinking: Option, + tools: Option<&serde_json::Value>, ) -> Result { let mut env = Environment::new(); env.set_recursion_limit(MAX_RECURSION_DEPTH); env.set_trim_blocks(true); env.set_lstrip_blocks(true); env.set_unknown_method_callback(official_template_str_method); + env.add_filter("tojson", py_tojson); env.add_function( "raise_exception", |msg: String| -> Result { @@ -173,6 +262,9 @@ pub fn render_official( if let Some(t) = enable_thinking { ctx.insert("enable_thinking", minijinja::Value::from(t)); } + if let Some(t) = tools { + ctx.insert("tools", minijinja::Value::from_serialize(t)); + } tmpl.render(minijinja::Value::from(ctx)) .map_err(|e| RealizarError::FormatError { reason: format!("model chat_template failed to render: {e}"), @@ -189,6 +281,20 @@ pub fn render_official_for_model( gguf: &crate::gguf::GGUFModel, messages: &[ChatMessage], enable_thinking: Option, +) -> Result { + render_official_for_model_with_tools(gguf, messages, enable_thinking, None) +} + +/// [`render_official_for_model`] with the request's `tools` (#4650); see +/// [`render_official_with_tools`]. +/// +/// # Errors +/// See [`render_official_for_model`]. +pub fn render_official_for_model_with_tools( + gguf: &crate::gguf::GGUFModel, + messages: &[ChatMessage], + enable_thinking: Option, + tools: Option<&serde_json::Value>, ) -> Result { let Some(crate::gguf::GGUFValue::String(tpl)) = gguf.metadata.get("tokenizer.chat_template") else { @@ -202,13 +308,14 @@ pub fn render_official_for_model( v.get(usize::try_from(i).ok()?).cloned() }; let (bos, eos) = (piece(gguf.bos_token_id()), piece(gguf.eos_token_id())); - render_official( + render_official_with_tools( tpl, bos.as_deref(), eos.as_deref(), messages, true, enable_thinking, + tools, ) } diff --git a/crates/aprender-serve/src/chat_template_official_tests.rs b/crates/aprender-serve/src/chat_template_official_tests.rs index 6eda81acf7..482560e49e 100644 --- a/crates/aprender-serve/src/chat_template_official_tests.rs +++ b/crates/aprender-serve/src/chat_template_official_tests.rs @@ -216,3 +216,95 @@ mod official_chat_template_3990 { ); } } + +// #4650: a request's OpenAI `tools` must reach the model's own template. The context used +// to carry no `tools` key; `{% if tools %}` on an UNDEFINED variable is false with no error, +// so the whole `# Tools` block was skipped and tool calling was dead, silently. Yokoten: +// Qwen2.5 and Qwen3 gate their tools block the same way, so all three are checked. +#[cfg(test)] +mod tools_reach_the_template_4650 { + use super::*; + + const QWEN25: &str = include_str!("fixtures/chat_template_3990/qwen25.jinja"); + const QWEN3: &str = include_str!("fixtures/chat_template_3990/qwen3.jinja"); + const QWEN35: &str = include_str!("fixtures/chat_template_3990/qwen35.jinja"); + + fn tools() -> serde_json::Value { + serde_json::json!([{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Weather for a city, when a < b & c > d", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + }]) + } + + fn render(tpl: &str, tools: Option<&serde_json::Value>) -> String { + let msgs = vec![ChatMessage::user("What is the weather in Paris?")]; + render_official_with_tools(tpl, None, None, &msgs, true, Some(false), tools) + .expect("renders") + } + + /// The tool, as `json.dumps(tool, ensure_ascii=False)` writes it (HuggingFace's `tojson`): + /// `", "` / `": "` separators and no HTML escaping. Keys are in serde_json's order. + const TOOL_JSON: &str = r#"{"function": {"description": "Weather for a city, when a < b & c > d", "name": "get_weather", "parameters": {"properties": {"city": {"type": "string"}}, "required": ["city"], "type": "object"}}, "type": "function"}"#; + + /// must-RED on the pre-#4650 renderer: the tools block is absent. + #[test] + fn every_qwen_template_renders_the_tools_block_4650() { + for (name, tpl) in [("qwen25", QWEN25), ("qwen3", QWEN3), ("qwen35", QWEN35)] { + let got = render(tpl, Some(&tools())); + let block = format!("\n{TOOL_JSON}\n"); + assert!( + got.contains(&block), + "{name}: no tools block {block:?} in:\n{got}" + ); + assert!( + got.contains(""), + "{name}: no call format in:\n{got}" + ); + assert!( + got.contains("What is the weather in Paris?"), + "{name}: the user turn is gone:\n{got}" + ); + } + } + + /// Qwen3.5 is told to call in XML -- the format the response parser must read. + #[test] + fn qwen35_instructs_the_xml_call_format_4650() { + let got = render(QWEN35, Some(&tools())); + assert!( + got.contains("\n"), + "{got}" + ); + } + + /// No tools is byte-identical to the renderer without the parameter: the #3990 cells + /// (which carry no tools) cannot move. + #[test] + fn no_tools_renders_exactly_what_render_official_renders_4650() { + let msgs = vec![ChatMessage::user("What is the weather in Paris?")]; + for tpl in [QWEN25, QWEN3, QWEN35] { + let plain = + render_official(tpl, None, None, &msgs, true, Some(false)).expect("renders"); + assert_eq!(render(tpl, None), plain); + assert!(!plain.contains("")); + } + } + + /// `tojson` is `json.dumps`: no `<` escapes, Python separators. minijinja's own + /// filter fails both. + #[test] + fn tojson_writes_what_json_dumps_writes_4650() { + let v = + minijinja::Value::from_serialize(serde_json::json!({"a": [1, "x'"], "b": null})); + let got = py_tojson(&v).expect("serializes"); + assert_eq!(got.as_str(), Some(r#"{"a": [1, "x'"], "b": null}"#)); + } +} diff --git a/crates/aprender-serve/src/grammar/tool.rs b/crates/aprender-serve/src/grammar/tool.rs index 4c9e69af87..fd9b3b226e 100644 --- a/crates/aprender-serve/src/grammar/tool.rs +++ b/crates/aprender-serve/src/grammar/tool.rs @@ -146,6 +146,9 @@ pub enum ToolCallFormat { Anthropic, /// Hermes format: {"name": "tool", "arguments": {...}} Hermes, + /// Qwen3.5 / Qwen3-Coder XML (#4650), the format the Qwen3.5 template instructs: + /// `VALUE` + QwenXml, } impl ToolCallParser { @@ -190,6 +193,7 @@ impl ToolCallParser { ToolCallFormat::OpenAI => self.parse_openai(text), ToolCallFormat::Anthropic => self.parse_anthropic(text), ToolCallFormat::Hermes => self.parse_hermes(text), + ToolCallFormat::QwenXml => self.parse_qwen_xml(text), } } @@ -285,6 +289,75 @@ impl ToolCallParser { calls } + + /// #4650: Qwen3.5's own call format. Each `...` block is one + /// call; the `` wrapper is not required, because models drop it. A call to a + /// tool the request did not declare is not a call, as in the other formats. + fn parse_qwen_xml(&mut self, text: &str) -> Vec { + const OPEN: &str = "') else { + break; + }; + let name = text[head..head + name_end].trim(); + let body_start = head + name_end + 1; + let body_end = text[body_start..] + .find("") + .map_or(text.len(), |e| body_start + e); + if let Some(tool) = self.tools.iter().find(|t| t.name == name) { + let arguments = qwen_xml_arguments(&text[body_start..body_end], tool); + calls.push(ToolCall::new(self.generate_id(), name, arguments.to_string())); + } + pos = body_end; + } + calls + } +} + +/// #4650: the `VALUE` pairs of one Qwen XML call, as a JSON +/// object. A missing `` ends at the next ` serde_json::Value { + const OPEN: &str = "') else { + break; + }; + let name = head[..name_end].trim(); + let after = &head[name_end + 1..]; + let end = [after.find(""), after.find(OPEN)] + .into_iter() + .flatten() + .min() + .unwrap_or(after.len()); + let value = qwen_xml_value(&after[..end], tool, name); + args.insert(name.to_string(), value); + rest = &after[end..]; + } + serde_json::Value::Object(args) +} + +/// #4650: a Qwen XML parameter value. The template puts the value on its own line, so one +/// newline either side is framing, not content. A parameter the tool declares as a string +/// stays a string verbatim (a zip code "02134" is not the number 2134); any other is read as +/// JSON when it parses as JSON, and kept as the string when it does not. +fn qwen_xml_value(raw: &str, tool: &ToolDefinition, name: &str) -> serde_json::Value { + let raw = raw.strip_prefix('\n').unwrap_or(raw); + let raw = raw.strip_suffix('\n').unwrap_or(raw); + let declared_string = tool + .parameters + .iter() + .find(|p| p.name == name) + .is_some_and(|p| matches!(p.param_type, ToolParameterType::String)); + if declared_string { + return serde_json::Value::String(raw.to_string()); + } + serde_json::from_str(raw.trim()).unwrap_or_else(|_| serde_json::Value::String(raw.to_string())) } /// Process a single character for brace matching state machine. diff --git a/docs/audits/surface_audit.csv b/docs/audits/surface_audit.csv index b552fc767f..3b533391a3 100644 --- a/docs/audits/surface_audit.csv +++ b/docs/audits/surface_audit.csv @@ -72,7 +72,7 @@ apr,POST /stream/generate,2,x86_64-linux,llama.cpp,yes,3,http-apr-serve,crates/a apr,POST /tokenize,2,x86_64-linux,llama.cpp,yes,3,http-apr-serve,crates/aprender-serve/src/api/router.rs:96,high apr,POST /v1/batch/completions,9,x86_64-linux,vllm,yes,3,http-apr-serve,crates/aprender-serve/src/api/router.rs:152,medium apr,POST /v1/chat/completions,4,x86_64-linux;nvidia-cuda,llama.cpp,yes,3,http-apr-serve,crates/aprender-serve/src/api/router.rs:134,high -apr,"POST /v1/chat/completions (stream:true, SSE)",4,x86_64-linux;nvidia-cuda,llama.cpp,no,3,http-apr-serve,"crates/aprender-serve/src/api/openai_handlers.rs:755-787 (stream branches crates/aprender-serve/src/api/cuda_chat_backend.rs:129,275,405,520,986; ignore_eos fail-closed 12,362,471)",high +apr,"POST /v1/chat/completions (stream:true, SSE)",4,x86_64-linux;nvidia-cuda,llama.cpp,no,3,http-apr-serve,"crates/aprender-serve/src/api/openai_handlers.rs:762-794 (stream branches crates/aprender-serve/src/api/cuda_chat_backend.rs:129,275,405,520,986; ignore_eos fail-closed 12,362,471)",high apr,POST /v1/chat/completions/stream,4,x86_64-linux,llama.cpp,yes,3,http-apr-serve,crates/aprender-serve/src/api/router.rs:139,high apr,POST /v1/completions,2,x86_64-linux,llama.cpp,yes,3,http-apr-serve,crates/aprender-serve/src/api/router.rs:131,high apr,"POST /v1/completions (stream:true, SSE)",2,x86_64-linux,llama.cpp,no,3,http-apr-serve,crates/aprender-serve/src/api/gpu_completions_handler.rs:405 (SSE :340-378),high diff --git a/docs/roadmaps/entries/PMAT-4650.yaml b/docs/roadmaps/entries/PMAT-4650.yaml new file mode 100644 index 0000000000..6f1cfe616f --- /dev/null +++ b/docs/roadmaps/entries/PMAT-4650.yaml @@ -0,0 +1,21 @@ +- id: PMAT-4650 + github_issue: 4650 + item_type: task + title: 'apr serve: pass request tools into the GGUF chat template and parse Qwen3.5 XML tool calls (tool calling was silently dead)' + status: inprogress + priority: critical + assigned_to: aprender-w4650 + created: 2026-10-04T00:00:00Z + updated: 2026-10-04T00:00:00Z + acceptance_criteria: + - With request `tools`, the Qwen2.5, Qwen3 and Qwen3.5 templates render their `` block, and the tool JSON is written as HF tojson writes it (json.dumps with Python separators and no HTML escaping) + - With no tools, the rendered prompt is byte-identical to before, so the 16 #3990 oracle cells do not move + - A Qwen3.5 XML call (`V`) to a declared tool returns tool_calls with schema-typed arguments and finish_reason "tool_calls". A call to an undeclared tool stays content + - The Qwen3.5, CUDA and dense chat paths all pass request.tools to the renderer + - 'Out of scope, follow-ups: streaming tool_calls; replaying assistant tool_calls and tool-role messages into the template; tools on the legacy template path (it warns)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:bug + notes: 'Filed by alfredodeza. Operator: must hit 0.70.2.' diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 8dd540de02..02b7368345 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -24075,6 +24075,27 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe labels: - kind:code notes: 'Operator C187(a)/C188(a), 2026-10-01: ratchet, not a waiver.' +- id: PMAT-4650 + github_issue: 4650 + item_type: task + title: 'apr serve: pass request tools into the GGUF chat template and parse Qwen3.5 XML tool calls (tool calling was silently dead)' + status: inprogress + priority: critical + assigned_to: aprender-w4650 + created: 2026-10-04T00:00:00Z + updated: 2026-10-04T00:00:00Z + acceptance_criteria: + - With request `tools`, the Qwen2.5, Qwen3 and Qwen3.5 templates render their `` block, and the tool JSON is written as HF tojson writes it (json.dumps with Python separators and no HTML escaping) + - With no tools, the rendered prompt is byte-identical to before, so the 16 #3990 oracle cells do not move + - A Qwen3.5 XML call (`V`) to a declared tool returns tool_calls with schema-typed arguments and finish_reason "tool_calls". A call to an undeclared tool stays content + - The Qwen3.5, CUDA and dense chat paths all pass request.tools to the renderer + - 'Out of scope, follow-ups: streaming tool_calls; replaying assistant tool_calls and tool-role messages into the template; tools on the legacy template path (it warns)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:bug + notes: 'Filed by alfredodeza. Operator: must hit 0.70.2.' - id: PMAT-4651 github_issue: 4651 item_type: task From 06a71e72adcf90d438b4f7e10ba4143da4b712d7 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 10:13:38 +0200 Subject: [PATCH 2/8] test(serve): tokenize_chat_prompt must return the encoding of the prompt it rendered (#4650) The only callers hashed its output against itself, so a body returning a constant id list passed every test, and cargo-mutants reported both `Ok(vec![0])` and `Ok(vec![1])` at openai_handlers.rs:98 as MISSED. This test needs no model file: the demo state renders and encodes anywhere. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../aprender-serve/src/api/openai_handlers.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/aprender-serve/src/api/openai_handlers.rs b/crates/aprender-serve/src/api/openai_handlers.rs index 4d7ea9fed8..eb8d226335 100644 --- a/crates/aprender-serve/src/api/openai_handlers.rs +++ b/crates/aprender-serve/src/api/openai_handlers.rs @@ -1839,3 +1839,40 @@ mod thinking_on_refusal_3723 { ); } } + +/// #4711: `tokenize_chat_prompt` returns the encoding of the prompt it rendered. The only other +/// callers hash its output against itself, so a body returning a constant id list passed them +/// all. This one needs no model file: the demo state renders and encodes on any host. +#[cfg(test)] +mod tokenize_chat_prompt_4711 { + use super::*; + + fn user(content: &str) -> ChatMessage { + ChatMessage { + role: "user".to_string(), + content: content.to_string(), + ..Default::default() + } + } + + #[test] + fn returns_the_encoding_of_the_rendered_prompt_4711() { + let state = AppState::demo().expect("demo state"); + let tokenizer = require_tokenizer(&state).unwrap_or_else(|_| panic!("demo tokenizer")); + let msgs = [user("Hello there, how are you?")]; + let text = format_chat_messages_for_state_thinking_tools(&state, &msgs, None, None, None) + .expect("renders"); + let want = tokenizer.encode(&text); + // A one-id oracle could not tell the real body from `Ok(vec![0])` or `Ok(vec![1])`. + assert!( + want.len() >= 2, + "the probe must encode to more than one id, got {want:?}" + ); + let got = tokenize_chat_prompt(&tokenizer, &msgs, None, None, None, &state) + .unwrap_or_else(|_| panic!("tokenizes")); + assert_eq!( + got, want, + "the ids are not the encoding of the rendered prompt" + ); + } +} From 94b289cd618094436f9922af0b32e3aca305c315 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 10:54:51 +0200 Subject: [PATCH 3/8] evidence(pr-review): #4866 receipt at 06a71e72ad (reviewer claude-sonnet-5-5, DEGRADED, blocking none) diff_patch_id ddeb684e1daac73dd18d4655a2c5c28b84cbdefc. The 15 files of c3061520d1 are blob-equal to closed #4711's head 66a8f042a2. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../findings.sarif | 293 ++++++++++++++++++ .../receipt.intoto.jsonl | 1 + 2 files changed, 294 insertions(+) create mode 100644 evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/findings.sarif create mode 100644 evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/receipt.intoto.jsonl diff --git a/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/findings.sarif b/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/findings.sarif new file mode 100644 index 0000000000..29f4b8c71a --- /dev/null +++ b/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/findings.sarif @@ -0,0 +1,293 @@ +{ + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "pmat" + } + }, + "results": [ + { + "ruleId": "complexity_delta", + "level": "note", + "message": { + "text": "pmat analyze complexity over base (dee8802d9a) and head copies of the 9 changed non-test .rs files under crates/. Three functions rise, all under 10: build_tool_calling_message 3->4 (cognitive 6->7), to_grammar 1->3 (0->5), extract_tool_parameters 3->5 (2->6). The test module added by the 2nd commit adds no function above the base. Values equal the 25f354a206 receipt for #4711." + }, + "properties": { + "grounding": "measured", + "command": [ + "pmat", + "analyze", + "complexity", + "--format", + "json", + "--path", + "" + ], + "exit_code": 0, + "stdout_sha256": "f3a9202af50c7d04b3ae294fd02a3f3446625925112ef20381010ecf9b547597", + "failure_scenario": "a later branch in extract_tool_parameters crosses cyclomatic 10", + "precision_class": "advisory" + } + }, + { + "ruleId": "tdg_delta", + "level": "note", + "message": { + "text": "pmat analyze tdg over base and head copies of the same 9 files. No grade falls; chat_template_official.rs A -> A+ (94.45 -> 98.25); openai_handlers.rs stays A- (87.52 -> 86.35)." + }, + "properties": { + "grounding": "measured", + "command": [ + "pmat", + "analyze", + "tdg", + "--format", + "json", + "--path", + "" + ], + "exit_code": 0, + "stdout_sha256": "bf5da174452f1319442deeec34962f972bb2fba7efc7e8545def426d27b8944b", + "failure_scenario": "openai_handlers.rs drifts from A- to B+ as the file grows", + "precision_class": "advisory" + } + }, + { + "ruleId": "satd_none_introduced", + "level": "note", + "message": { + "text": "pmat analyze satd reports 0 violations on both base and head copies; the report digest is identical, so the diff introduces no SATD marker." + }, + "properties": { + "grounding": "measured", + "command": [ + "pmat", + "analyze", + "satd", + "--format", + "json", + "--path", + "" + ], + "exit_code": 0, + "stdout_sha256": "0f337997eb607f2a757f6c78182c5f1843f2dae49aa9a9dbe4fc8ddcbca088d5", + "failure_scenario": "a TODO or FIXME added by the diff would be listed as a violation", + "precision_class": "advisory" + } + }, + { + "ruleId": "duplication_scan", + "level": "note", + "message": { + "text": "scripts/pr_review_duplication_scan.sh swept 31 needles over HEAD, 1529 of 1529 sibling branches and the 6 merge-base-to-main files. 7 hits: 3 are the generic name args_of in aprender-contracts (not prior art); 4 are the filename PMAT-4650.yaml on origin branches of the SAME ticket (#4650 and the closed #4711, whose net change this PR rebuilds), which is expected lineage and not a second implementation." + }, + "properties": { + "grounding": "measured", + "command": [ + "bash", + "scripts/pr_review_duplication_scan.sh", + "--base", + "dee8802d9a", + "--head", + "06a71e72ad", + "--rust-semantic", + "--json", + "" + ], + "exit_code": 0, + "stdout_sha256": "d006d329c42c57b01177f8a09252e5235b189e89ad6d0fd80ac7389679ae67cd", + "failure_scenario": "a sibling branch implements the same tools-to-template path under a different name; the scan matches exact names only", + "precision_class": "advisory" + } + }, + { + "ruleId": "rebuilt_files_equal_closed_pr", + "level": "note", + "message": { + "text": "All 15 files of c3061520d1 have a blob at 06a71e72ad~1 equal to the blob at 66a8f042a2 (the #4711 head): 15 of 15 same, 0 DIFF. So the prior #4711 review (25f354a206, DEGRADED, no blocking class) is the verdict on the same code. The 2nd commit changes only openai_handlers.rs, by appending the test module." + }, + "properties": { + "grounding": "measured", + "command": [ + "bash", + "-c", + "for f in $(git diff --name-only c3061520d1~1 c3061520d1); do compare git rev-parse 06a71e72ad~1:$f with 66a8f042a2:$f; done" + ], + "exit_code": 0, + "stdout_sha256": "085390fc07f5605ea3cf5dc98d02c36a2393b707fd9ec27166c0664df0e24417", + "failure_scenario": "a file in the rebuilt commit that differs from the reviewed #4711 head carries code no prior review saw", + "precision_class": "advisory" + } + }, + { + "ruleId": "diff_patch_id_matches_ci", + "level": "note", + "message": { + "text": "prpid_compute over dee8802d9a..06a71e72ad for PR 4866 returns ddeb684e1daac73dd18d4655a2c5c28b84cbdefc, the value CI present Arm 4 expects." + }, + "properties": { + "grounding": "measured", + "command": [ + "bash", + "-c", + ". scripts/lib/pr_review_patch_id.sh; prpid_compute . dee8802d9a 06a71e72ad 4866" + ], + "exit_code": 0, + "stdout_sha256": "9ccf7635c4144e91949a4433d2387d28da1a97b23b367315d1457b89cf9bde2a", + "failure_scenario": "a receipt whose diff_patch_id differs from the recomputed one is refused by present", + "precision_class": "advisory" + } + }, + { + "ruleId": "new_test_discriminates_both_mutants", + "level": "note", + "message": { + "text": "tokenize_chat_prompt_4711 (openai_handlers.rs:1847-1880) kills Ok(vec![0]) and Ok(vec![1]) on reading, with the length as the kill: the demo vocabulary is plus token1..token99 with no <0xNN> entries, so the rendered prompt encodes to a run of unk ids, and the guard want.len() >= 2 makes a one-id body unequal to want on length, while Ok(vec![1]) also differs on value. The author reports a mutant proof on another host (1 passed on the real body, rc 101 with the assert message under each mutant); this run did not execute it." + }, + "properties": { + "grounding": "asserted", + "rationale": "read of tokenize_chat_prompt (openai_handlers.rs:84-102), AppState::demo (mod_app_state_new.rs:290-312) and BPETokenizer::encode/byte_fallback_id (tokenizer.rs:305-496); no cargo run on this host", + "failure_scenario": "if the demo vocabulary ever gained a token for a prompt byte, want could shrink to one id and the length guard would fail the test loudly rather than pass vacuously", + "precision_class": "advisory" + } + }, + { + "ruleId": "new_test_is_hermetic", + "level": "note", + "message": { + "text": "The test builds AppState::demo() in memory (no model file, no env var, no network, no clock) and calls two pure functions; with no GGUF mapped, the template path is the built-in one, so the result is deterministic on any host. The only side effect is an eprintln warning, and only when tools are given, which this test does not pass." + }, + "properties": { + "grounding": "asserted", + "rationale": "read of the test and of format_chat_messages_for_state_thinking_tools (realize_handlers.rs:330-347)", + "failure_scenario": "a host with a different default feature set could change AppState::demo; sibling tests use the same call, so a break would show there first", + "precision_class": "advisory" + } + }, + { + "ruleId": "new_test_oracle_is_length_not_content", + "level": "warning", + "message": { + "text": "Because want is a run of the unk id, assert_eq!(got, want) proves the length and the unk value, not that the ids encode THIS prompt. A body that encoded a different string of equal length, or returned vec![0; N] for the right N, would pass. The two named mutants are killed; a wider family is not. A probe containing a vocabulary word (for example token5) would make want content-bearing." + }, + "properties": { + "grounding": "asserted", + "rationale": "derived from the demo vocabulary and the byte_fallback_id fall-through to the unk id (openai_handlers.rs:1862-1880); not executed here", + "failure_scenario": "a future change that drops the system or user turn from the rendered text but keeps its length would pass this test", + "precision_class": "advisory" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "nvidia-cuda-docs" + } + }, + "invocations": [ + { + "executionSuccessful": true, + "toolExecutionNotifications": [] + } + ], + "results": [] + }, + { + "tool": { + "driver": { + "name": "crux" + } + }, + "results": [ + { + "ruleId": "crux_surface_covered", + "level": "note", + "message": { + "text": "The diff changes the /v1/chat/completions request (tools reach the template) and response (message.tool_calls). contracts/crux-C-11-v1.yaml and contracts/crux-C-03-v1.yaml are present at head. gap_effect none." + }, + "properties": { + "grounding": "measured", + "command": [ + "ls", + "contracts/crux-C-11-v1.yaml", + "contracts/crux-C-03-v1.yaml" + ], + "exit_code": 0, + "stdout_sha256": "89602ee4d43dbf2334a2da7a2254260d0c94cdd4ae913d4f1f8626c6a902322f", + "failure_scenario": "a surface with no covering contract would be crux_coverage none", + "precision_class": "advisory" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "cargo-mutants" + } + }, + "invocations": [ + { + "executionSuccessful": true, + "toolExecutionNotifications": [ + { + "level": "note", + "message": { + "text": "NOT re-run here: host policy runs cargo and cargo-mutants on another host only. The earlier figures (18 in-diff mutants, 11 caught, 7 missed at f2338256ee) are not carried as measured: they predate this base. The 2nd commit targets the survivors Ok(vec![0]) and Ok(vec![1]) in tokenize_chat_prompt; the author reports a mutant proof, which this run did not execute. grammar/tool.rs, chat_template_official.rs and mod_create_demo.rs are include!d and were never enumerated." + } + } + ] + } + ], + "results": [ + { + "ruleId": "mutants_not_rerun", + "level": "warning", + "message": { + "text": "No cargo test and no cargo-mutants ran in this review (host policy). The 4650 tests and the new test are therefore unexecuted by this reviewer; the other survivors recorded for #4711 (try_gpu_backend, try_cached_backend, a delete-! and two format_chat_messages_for_state_thinking replacements) are unconfirmed either way." + }, + "properties": { + "grounding": "asserted", + "rationale": "host policy forbids cargo on this host; Rust mutation is advisory under the skill", + "failure_scenario": "a surviving mutant in the tools-to-template path is a regression no test would catch", + "precision_class": "advisory" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "antigravity" + } + }, + "invocations": [ + { + "executionSuccessful": false, + "toolExecutionNotifications": [ + { + "level": "error", + "message": { + "text": "Consultation could not be performed. The disposable review tree step, git archive 06a71e72ad | tar -x, was refused by the lane hook subagent-lock.sh (a review lane runs read-only git only). agy itself resolves and runs (agy --version = 1.3.0, rc 0), but no review was attempted and the hook was not routed around. Verdict DEGRADED." + }, + "properties": { + "grounding": "measured", + "command": [ + "agy", + "--version" + ], + "exit_code": 0, + "stdout_sha256": "64d23f858ef51b0f996e4966d4e27c0371b437e2d2787890b1f7ad22d4ec5663" + } + } + ] + } + ], + "results": [] + } + ] +} diff --git a/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/receipt.intoto.jsonl b/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/receipt.intoto.jsonl new file mode 100644 index 0000000000..c26603d4c3 --- /dev/null +++ b/evidence/pr-review/4866/06a71e72adcf90d438b4f7e10ba4143da4b712d7/receipt.intoto.jsonl @@ -0,0 +1 @@ +{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"git+https://github.com/paiml/aprender","digest":{"sha1":"06a71e72adcf90d438b4f7e10ba4143da4b712d7"}}],"predicateType":"https://paiml.dev/attestations/pr-review/v2","predicate":{"skill_version":"2.1.0","attestation_level":"L1-self","pr":4866,"base_sha":"dee8802d9af97732752621a2be834fb97e05537d","head_sha":"06a71e72adcf90d438b4f7e10ba4143da4b712d7","author_actor":{"kind":"agent","id":"agent:claude-opus-5-5/aprender-w4650"},"reviewer_actor":{"kind":"agent","id":"agent:claude-sonnet-5-5/w4650-review"},"affected_crates":["aprender-serve","apr-cli"],"verdict":"DEGRADED","degraded_reason":"antigravity unreachable: the lane hook refused git archive for the disposable review tree, so agy did not run (agy 1.3.0 resolves; hook not routed around); mutation and tests NOT re-run here (host policy: cargo runs on another host only), so the new test tokenize_chat_prompt_4711 is judged by reading, and the author mutant proof is the author claim only; the cuda-docs-reviewer agent was not dispatched, nvidia-cuda-docs was queried inline once (no authority found); pmat duplication, complexity, tdg and satd were measured at head. All 15 files of c3061520d1 equal the closed #4711 head blob for blob, so the prior 25f354a206 verdict (DEGRADED, no blocking class) applies to the same code. No blocking class (B1-B6) fires.","consultations":{"pmat":{"status":"consulted","transport":"cli","transport_unavailable":["mcp: not attempted this run (cli answered)"],"index_commit":"06a71e72adcf90d438b4f7e10ba4143da4b712d7","index_is_ancestor":true,"index_worktree_dirty":false,"complexity_delta":[{"file":"crates/aprender-serve/src/api/openai_handlers.rs","function":"build_tool_calling_message","base_cyclomatic":3,"head_cyclomatic":4,"base_cognitive":6,"head_cognitive":7},{"file":"crates/aprender-serve/src/api/mod_create_demo.rs","function":"to_grammar","base_cyclomatic":1,"head_cyclomatic":3,"base_cognitive":0,"head_cognitive":5},{"file":"crates/aprender-serve/src/api/mod_create_demo.rs","function":"extract_tool_parameters","base_cyclomatic":3,"head_cyclomatic":5,"base_cognitive":2,"head_cognitive":6}],"tdg_delta":[{"file":"crates/aprender-serve/src/api/openai_handlers.rs","base":"A-","head":"A-","base_total":87.52,"head_total":86.35},{"file":"crates/aprender-serve/src/api/cuda_chat_backend.rs","base":"B-","head":"B-","base_total":71.12,"head_total":71.02},{"file":"crates/aprender-serve/src/grammar/tool.rs","base":"A","head":"A","base_total":93.5,"head_total":91},{"file":"crates/aprender-serve/src/chat_template_official.rs","base":"A","head":"A+","base_total":94.45,"head_total":98.25}],"satd_introduced":[],"cache_hits":0,"analysis_note":"Index built at 06a71e72ad in this worktree (118 s, 2055 docs). pmat analyze complexity, tdg and satd re-run over base and head copies of the 9 changed non-test .rs files under crates/ at this head; nothing reused.","duplication_hits":[{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":209,"method":"lexical"},{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":217,"method":"lexical"},{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":232,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/fix/4650-qwen35-tools-template","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/keep/4711-ae9d0ee6c5","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/keep/fix/4650-qwen35-tools-template","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/pr/4711","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"}],"duplication_coverage":{"rust":"semantic","shell":"lexical","python":"lexical","config":"lexical","docs":"lexical","other":"lexical","sibling_branches":"lexical","merge_base_to_main":"lexical"},"duplication_horizon":["head=06a71e72ad","siblings=refs/remotes/origin/* unmerged into origin/main","merge_base_to_main=dee8802d9a..refs/remotes/origin/main"],"horizon_branches_total":1529,"horizon_branches_scanned":1529,"merge_base_to_main_files":6,"symbols_searched":31,"queries":[{"q":"tokenize chat prompt encoding of rendered prompt","command":"pmat query \"tokenize chat prompt encoding of rendered prompt\" --limit 10","cwd":"worktree at HEAD","results":10,"note":"hits are the existing tokenize_chat_prompt callers; no prior implementation of the added test surfaced"}],"duplication_note":"7 hits: 3 are the generic name args_of in aprender-contracts; 4 are the filename PMAT-4650.yaml on origin branches of the same ticket (#4650, closed #4711). None is prior art for the added test. 1529 of 1529 sibling branches swept in 179 s."},"cuda":{"status":"consulted","trigger_reason":"S3.B path trigger fires on crates/aprender-serve/src/api/cuda_chat_backend.rs (path contains cuda). The diff adds no device-behaviour claim: the extract-fn refactor moves host dispatch code, and the 2nd commit is a host-only test. The cuda-docs-reviewer agent was not dispatched from this lane; one inline query.","queries":[{"q":"CUDA context is bound to the calling host thread; running kernels from a spawned blocking thread requires cuCtxSetCurrent","source_uri":"nvidia-cuda-docs MCP (search_cuda_docs)","result":"no-authority-found","claim_checked":"The refactor keeps dispatch_cuda_stream inside the same blocking task as before, so the thread-current CUDA context is unchanged. The query returned only the threadMigration driver-API sample (cuCtxPushCurrent per host thread) and nothing on spawn_blocking, so no authority was found for or against the claim."}]},"crux":{"status":"consulted","surfaces":["POST /v1/chat/completions request field tools","POST /v1/chat/completions response message.tool_calls and finish_reason"],"contracts":["contracts/crux-C-11-v1.yaml","contracts/crux-C-03-v1.yaml"],"gap_effect":"none","crux_coverage":"covered","comparative_claims":[]},"mutation":{"status":"unreachable","scope":"in-diff","attempted":0,"killed":0,"survivors":[],"trigger_reason":"cargo-mutants was not run: host policy runs cargo only on another host. The author reports a proof on that host (not re-run here)."},"antigravity":{"status":"unreachable","trigger_reason":"the lane hook subagent-lock.sh refused git archive, the S3.E step 3 disposable-tree command (a review lane runs read-only git only); agy resolves and runs (agy --version = 1.3.0, rc 0) but no review was run, and the refusal was not routed around"}},"findings_ref":{"path":"findings.sarif","sha256":"9b4c5ab100db7a1251ef090678901fea860a9750025f9bb708c7d70ff5fc10b6"},"cost":{"input_tokens":330000,"output_tokens":30000,"wall_seconds":1500},"diff_patch_id":"ddeb684e1daac73dd18d4655a2c5c28b84cbdefc","diff_patch_id_algo":"git-patch-id-verbatim/pinned-diff-v1"}} From 2d35b7eca00d6c368f120059cf4c885dc6d5806c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 11:27:29 +0200 Subject: [PATCH 4/8] fix(serve): tojson takes indent as json.dumps does; the key-order claim is corrected (#4866 C129 F1/F2) F1: Llama 3.x templates call `tojson(indent=4)`. The one-argument override returned TooManyArguments, so with `tools` now defined the whole render failed and fell back to apr's built-in template. py_tojson now takes indent positionally or as a keyword and writes json.dumps(x, indent=N); any other kwarg is still an error. F2: keys come out sorted (minijinja has no preserve_order here), so the test doc no longer says "json.dumps order". preserve_order changes every template's dict iteration and goes to its own PR. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../src/chat_template_official.rs | 43 +++++++++++++++--- .../src/chat_template_official_tests.rs | 45 ++++++++++++++++--- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/crates/aprender-serve/src/chat_template_official.rs b/crates/aprender-serve/src/chat_template_official.rs index ba3b27d148..edf7d44098 100644 --- a/crates/aprender-serve/src/chat_template_official.rs +++ b/crates/aprender-serve/src/chat_template_official.rs @@ -164,16 +164,49 @@ impl serde_json::ser::Formatter for PyJsonFormatter { /// and HTML-escapes `<`, `>`, `&` and `'` to `<`-style escapes, so a tool schema that /// says "a < b" reached the model as text it was never trained on. The `tools` block and /// replayed tool-call arguments are the only places the Qwen templates call it. -fn py_tojson(value: &minijinja::Value) -> Result { +/// +/// `indent` is taken positionally or as a keyword, as HF and minijinja's built-in take it. +/// Llama 3.x templates render each tool with `tojson(indent=4)`; a filter that refused the +/// argument failed the whole render, and the request fell back to apr's built-in template. +/// With an indent the output is `json.dumps(x, indent=N)`: one item per line, `","` +/// between items and `": "` after a key. +fn py_tojson( + value: &minijinja::Value, + indent: Option, + kwargs: minijinja::value::Kwargs, +) -> Result { use serde::Serialize; - let mut out = Vec::new(); - let mut ser = serde_json::Serializer::with_formatter(&mut out, PyJsonFormatter); - value.serialize(&mut ser).map_err(|e| { + let indent = match indent { + Some(i) => Some(i), + None => kwargs.get::>("indent")?, + }; + kwargs.assert_all_used()?; + let bad = |e: serde_json::Error| { minijinja::Error::new( minijinja::ErrorKind::BadSerialization, format!("tojson: {e}"), ) - })?; + }; + let mut out = Vec::new(); + match indent.filter(|i| !i.is_none()) { + None => { + let mut ser = serde_json::Serializer::with_formatter(&mut out, PyJsonFormatter); + value.serialize(&mut ser).map_err(bad)?; + }, + Some(i) => { + let n = i64::try_from(i).map_err(|_| { + minijinja::Error::new( + minijinja::ErrorKind::InvalidOperation, + "tojson: indent must be an integer", + ) + })?; + // json.dumps treats a negative indent as 0: newlines, no padding. + let pad = " ".repeat(usize::try_from(n).unwrap_or(0)); + let fmt = serde_json::ser::PrettyFormatter::with_indent(pad.as_bytes()); + let mut ser = serde_json::Serializer::with_formatter(&mut out, fmt); + value.serialize(&mut ser).map_err(bad)?; + }, + } let s = String::from_utf8(out).map_err(|e| { minijinja::Error::new(minijinja::ErrorKind::BadSerialization, e.to_string()) })?; diff --git a/crates/aprender-serve/src/chat_template_official_tests.rs b/crates/aprender-serve/src/chat_template_official_tests.rs index 482560e49e..50a8ed855e 100644 --- a/crates/aprender-serve/src/chat_template_official_tests.rs +++ b/crates/aprender-serve/src/chat_template_official_tests.rs @@ -250,8 +250,11 @@ mod tools_reach_the_template_4650 { .expect("renders") } - /// The tool, as `json.dumps(tool, ensure_ascii=False)` writes it (HuggingFace's `tojson`): - /// `", "` / `": "` separators and no HTML escaping. Keys are in serde_json's order. + /// The tool with `json.dumps(tool, ensure_ascii=False)`'s separators (`", "` / `": "`) and + /// no HTML escaping. NOT json.dumps byte for byte: minijinja is built without + /// `preserve_order`, so every object's keys come out sorted, where HF keeps the client's + /// order. That is a known gap (see the PR's out-of-scope list), pinned here so a change + /// to it is seen. const TOOL_JSON: &str = r#"{"function": {"description": "Weather for a city, when a < b & c > d", "name": "get_weather", "parameters": {"properties": {"city": {"type": "string"}}, "required": ["city"], "type": "object"}}, "type": "function"}"#; /// must-RED on the pre-#4650 renderer: the tools block is absent. @@ -298,13 +301,43 @@ mod tools_reach_the_template_4650 { } } + fn tojson_in(tpl: &str) -> Result { + let mut env = minijinja::Environment::new(); + env.add_filter("tojson", py_tojson); + let v = serde_json::json!({"a": [1, "x'"], "b": null}); + env.render_str(tpl, minijinja::context! { v => v }) + } + /// `tojson` is `json.dumps`: no `<` escapes, Python separators. minijinja's own /// filter fails both. #[test] fn tojson_writes_what_json_dumps_writes_4650() { - let v = - minijinja::Value::from_serialize(serde_json::json!({"a": [1, "x'"], "b": null})); - let got = py_tojson(&v).expect("serializes"); - assert_eq!(got.as_str(), Some(r#"{"a": [1, "x'"], "b": null}"#)); + let got = tojson_in("{{ v | tojson }}").expect("serializes"); + assert_eq!(got, r#"{"a": [1, "x'"], "b": null}"#); + } + + /// must-RED on a one-argument filter (C129 F1 on #4866): Llama 3.x templates call + /// `tojson(indent=4)`, and a refused argument failed the whole render. With an indent + /// the text is `json.dumps(v, indent=4)`, keyword or positional. + #[test] + fn tojson_takes_indent_as_json_dumps_does_4866() { + let want = "{\n \"a\": [\n 1,\n \"x'\"\n ],\n \"b\": null\n}"; + for tpl in ["{{ v | tojson(indent=4) }}", "{{ v | tojson(4) }}"] { + assert_eq!(tojson_in(tpl).expect("renders"), want, "{tpl}"); + } + assert_eq!( + tojson_in("{{ v | tojson(indent=none) }}").expect("renders"), + r#"{"a": [1, "x'"], "b": null}"# + ); + assert!(tojson_in("{{ v | tojson(sort_keys=true) }}").is_err()); + } + + /// The Llama 3.x shape end to end: `tools` defined, each rendered with `tojson(indent=4)`. + #[test] + fn a_llama3_style_tools_block_renders_4866() { + let tpl = "{%- if tools is not none %}{%- for t in tools %}{{ t | tojson(indent=4) }}\n\n{%- endfor %}{%- endif %}{{ messages[0].content }}"; + let got = render(tpl, Some(&tools())); + assert!(got.contains("\n \"type\": \"function\"\n}"), "{got}"); + assert!(got.contains("What is the weather in Paris?"), "{got}"); } } From ab53111dfc1279a51938cc48d3c2f9cd6afdfffb Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 12:36:01 +0200 Subject: [PATCH 5/8] evidence(pr-review): #4866 receipt at 2d35b7eca0 (reviewer claude-sonnet-5-5, DEGRADED, blocking none) diff_patch_id 858f0c7a2bcb43d970ea459105d094a9200fc5e0. F1 (tojson indent) and F2 (key-order claim) closed. Not run: agy, cargo-mutants (the author's mutant proof is cited). One host name was redacted from a SARIF message for privacy, and findings_ref.sha256 was re-bound to the redacted file. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../findings.sarif | 174 ++++++++++++++++++ .../receipt.intoto.jsonl | 1 + 2 files changed, 175 insertions(+) create mode 100644 evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/findings.sarif create mode 100644 evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/receipt.intoto.jsonl diff --git a/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/findings.sarif b/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/findings.sarif new file mode 100644 index 0000000000..50b3b227dd --- /dev/null +++ b/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/findings.sarif @@ -0,0 +1,174 @@ +{ + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "pmat" + } + }, + "results": [ + { + "ruleId": "complexity_py_tojson", + "level": "note", + "message": { + "text": "pmat analyze complexity on chat_template_official.rs at head: py_tojson lines 173-214, cyclomatic 3, cognitive 4, nesting 4; file max cyclomatic 6, max cognitive 12. No function crosses 10." + }, + "properties": { + "grounding": "measured", + "command": [ + "pmat", + "analyze", + "complexity", + "--path", + "crates/aprender-serve/src/chat_template_official.rs", + "--format", + "json" + ], + "exit_code": 0, + "stdout_sha256": "42c2ff01b0e36cac6f4dbf4a5af70fff7999eb2ad53b2af3229dd01e22edde53", + "failure_scenario": "a later branch in py_tojson (string indent, bool indent) pushes cyclomatic past 10", + "precision_class": "advisory" + } + }, + { + "ruleId": "indent_semantics_match_json_dumps", + "level": "note", + "message": { + "text": "Read of py_tojson (chat_template_official.rs:173-214) against Python json.dumps(indent=N): serde PrettyFormatter with N spaces writes newline plus N-space padding, comma at line end, \": \" after a key, and [] and {} for empty containers, which is json.dumps output. indent=none and absent go to the compact PyJsonFormatter path. A negative N pads 0, as Python does (str * negative is empty). Positional and keyword indent are both read; unknown kwargs (sort_keys, ensure_ascii, separators) fail at assert_all_used. Non-ASCII and < > & are not escaped on either path, as HF tojson (ensure_ascii False)." + }, + "properties": { + "grounding": "asserted", + "rationale": "read of the diff and of serde_json PrettyFormatter semantics; no cargo run on this host", + "failure_scenario": "a Python-produced golden for a nested empty container under indent=2 differing from serde output would show a mismatch; none found by reading", + "precision_class": "advisory" + } + }, + { + "ruleId": "new_tests_kill_ignore_indent_mutant", + "level": "note", + "message": { + "text": "tojson_takes_indent_as_json_dumps_does_4866 asserts the exact 4-space text for indent=4 and tojson(4), so a body that ignores indent (author mutant: match indent.filter(|_| false)) returns compact text and fails the assert_eq; a_llama3_style_tools_block_renders_4866 asserts the text contains a newline plus 4 spaces before \"type\", which compact output cannot contain. Both fail on the mutant by reading. The author reports the the author's test host run: 4 pass on the real body, 2 new tests fail on their own asserts under the mutant. Not re-run here." + }, + "properties": { + "grounding": "asserted", + "rationale": "read of chat_template_official_tests.rs tests at head; author proof cited, not executed", + "failure_scenario": "a mutant that honours only the keyword form would be killed by the tojson(4) positional case; a mutant that honours only the positional form by the indent=4 case", + "precision_class": "advisory" + } + }, + { + "ruleId": "negative_and_zero_indent_untested", + "level": "warning", + "message": { + "text": "The usize::try_from(n).unwrap_or(0) branch (negative indent) and indent=0 are not exercised by any test. A mutant that panics, errors or pads abs(n) on negative input survives. Code matches Python on reading; the guard is absent. Not blocking: no shipped template passes a negative indent." + }, + "properties": { + "grounding": "asserted", + "rationale": "grep of the new tests: only indent=4, 4 and none are rendered", + "failure_scenario": "a refactor replacing unwrap_or(0) with n.unsigned_abs() would pad |n| and no test would fail", + "precision_class": "advisory" + } + }, + { + "ruleId": "indent_type_edges_not_json_dumps", + "level": "note", + "message": { + "text": "Python json.dumps accepts a str indent (indent=\"\\t\") and a bool (True is 1). py_tojson refuses both through i64::try_from with indent must be an integer (a bool conversion is not confirmed by reading). A refusal falls back to the built-in template as before, so this is a gap, not a regression. aprender-w4578 records the str case as N2; agreed." + }, + "properties": { + "grounding": "asserted", + "rationale": "read of the match arm at chat_template_official.rs:196-205", + "failure_scenario": "a template calling tojson(indent=\"\\t\") still fails to render and falls back", + "precision_class": "advisory" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "nvidia-cuda-docs" + } + }, + "invocations": [ + { + "executionSuccessful": false, + "toolExecutionNotifications": [ + { + "level": "note", + "message": { + "text": "Not queried in this lane. The delta is host-only string serialization; the whole-diff cuda_chat_backend.rs path was judged in the earlier receipt for this PR. Marked unreachable (not performed) rather than consulted." + } + } + ] + } + ], + "results": [] + }, + { + "tool": { + "driver": { + "name": "crux" + } + }, + "invocations": [ + { + "executionSuccessful": false, + "toolExecutionNotifications": [ + { + "level": "note", + "message": { + "text": "Not run in this lane: crux contracts were not re-read for the 94b289cd61..2d35b7eca0 delta (a chat-template filter, no new endpoint or response field). Marked unreachable (not performed)." + } + } + ] + } + ], + "results": [] + }, + { + "tool": { + "driver": { + "name": "cargo-mutants" + } + }, + "invocations": [ + { + "executionSuccessful": false, + "toolExecutionNotifications": [ + { + "level": "note", + "message": { + "text": "NOT run: host policy forbids cargo on this host. The author mutant proof for the indent mutant is cited, not re-run." + } + } + ] + } + ], + "results": [] + }, + { + "tool": { + "driver": { + "name": "antigravity" + } + }, + "invocations": [ + { + "executionSuccessful": false, + "toolExecutionNotifications": [ + { + "level": "note", + "message": { + "text": "agy did not run in this lane. No consultation was attempted; verdict DEGRADED." + } + } + ] + } + ], + "results": [] + } + ] +} diff --git a/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/receipt.intoto.jsonl b/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/receipt.intoto.jsonl new file mode 100644 index 0000000000..3798ada678 --- /dev/null +++ b/evidence/pr-review/4866/2d35b7eca00d6c368f120059cf4c885dc6d5806c/receipt.intoto.jsonl @@ -0,0 +1 @@ +{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"git+https://github.com/paiml/aprender","digest":{"sha1":"2d35b7eca00d6c368f120059cf4c885dc6d5806c"}}],"predicateType":"https://paiml.dev/attestations/pr-review/v2","predicate":{"skill_version":"2.1.0","attestation_level":"L1-self","pr":4866,"base_sha":"dee8802d9af97732752621a2be834fb97e05537d","head_sha":"2d35b7eca00d6c368f120059cf4c885dc6d5806c","author_actor":{"kind":"agent","id":"agent:claude-opus-5-5/aprender-w4650"},"reviewer_actor":{"kind":"agent","id":"agent:claude-sonnet-5-5/w4650-review"},"affected_crates":["aprender-serve","apr-cli"],"verdict":"DEGRADED","degraded_reason":"antigravity (agy) did not run in this lane; cargo test and cargo-mutants NOT run here (host policy), so the two new indent tests and the author mutant proof (indent.filter(|_| false) fails both) are judged by reading and cited, not executed; diff_patch_id is the value supplied by the dispatcher, git patch-id was refused by the lane hook so it was not recomputed here; cuda and crux consultations were not performed for this delta (marked unreachable); pmat complexity was measured and pmat query used for duplication, no complexity/tdg/satd base-vs-head delta computed for the full diff. No blocking class (B1-B6) fires: py_tojson matches json.dumps(indent=N) on reading.","consultations":{"pmat":{"status":"consulted","transport":"cli","transport_unavailable":["mcp: not attempted this run (cli answered)"],"index_commit":"2d35b7eca00d6c368f120059cf4c885dc6d5806c","index_is_ancestor":true,"index_worktree_dirty":false,"complexity_delta":[{"file":"crates/aprender-serve/src/chat_template_official.rs","function":"py_tojson","base_cyclomatic":1,"head_cyclomatic":3,"base_cognitive":0,"head_cognitive":4}],"tdg_delta":[],"satd_introduced":[],"cache_hits":0,"analysis_note":"pmat analyze complexity run on chat_template_official.rs at head (py_tojson cyclomatic 3, cognitive 4). Base cyclomatic 1/cognitive 0 is from reading the one-line base fn, not measured. tdg and satd not re-run for this delta.","duplication_hits":[],"queries":[{"q":"fn py_tojson","command":"pmat query --literal \"fn py_tojson\" --limit 10 --files-with-matches","cwd":"worktree at HEAD","results":1,"note":"single definition, no second implementation of tojson"}],"duplication_note":"pmat query --literal \"fn py_tojson\" matched only crates/aprender-serve/src/chat_template_official.rs; json.dumps matches are only Python and shell scripts. No sibling-branch sweep run in this lane."},"cuda":{"status":"unreachable","trigger_reason":"not performed: no nvidia-cuda-docs query was made in this lane; the delta is a host-only serialization filter and the CUDA-path file in the full diff was covered by the earlier receipt"},"crux":{"status":"unreachable","trigger_reason":"not performed: crux contracts not re-read for this delta"},"mutation":{"status":"unreachable","scope":"in-diff","attempted":0,"killed":0,"survivors":[],"trigger_reason":"cargo-mutants not run: host policy forbids cargo on this host; author mutant proof cited only"},"antigravity":{"status":"unreachable","trigger_reason":"agy did not run in this lane; no consultation attempted"}},"findings_ref":{"path":"findings.sarif","sha256":"ee26a418c791a0aa677dec252e595f432e2e02212ec3d3c338a5055a24ba82c1"},"cost":{"input_tokens":150000,"output_tokens":12000,"wall_seconds":600},"diff_patch_id":"858f0c7a2bcb43d970ea459105d094a9200fc5e0","diff_patch_id_algo":"git-patch-id-verbatim/pinned-diff-v1"}} From 7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 20:35:14 +0200 Subject: [PATCH 6/8] fix(test): f048 had a 1 ms wall-time budget, so a busy runner failed a correctness test (FLAKE-0) f048_rmsnorm_epsilon checks that RmsNorm stays finite on near-zero input. It also set a 1 ms/token budget. RmsNormBrick::run times the call, and when the time is over budget its budget_met assertion fails, so run() returns Err (brick/brick.rs: run, assertions). One preemption on a loaded x86-main runner is enough: #4866 went red on it at head ab53111dfc. The budget is now from_latency(f64::MAX), so is_met holds for any finite measurement. The NaN and finiteness assertions are unchanged. The file is an include!() fragment, so cargo fmt does not format it; the changed line is 99 columns. Refs #4650 Cherry-picked from 97737d5bb8 (authored by aprender-a3) for B7, C317. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- crates/aprender-serve/src/brick/tests_r006_fused.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aprender-serve/src/brick/tests_r006_fused.rs b/crates/aprender-serve/src/brick/tests_r006_fused.rs index 4adacdd1c4..d7727a2904 100644 --- a/crates/aprender-serve/src/brick/tests_r006_fused.rs +++ b/crates/aprender-serve/src/brick/tests_r006_fused.rs @@ -327,9 +327,9 @@ #[test] fn f048_rmsnorm_epsilon() { // With near-zero input, epsilon prevents division by zero - // Use a relaxed budget since this test is about correctness, not performance + // FLAKE-0: correctness test, so no wall-time budget (1 ms flaked on a loaded host) let brick = - RmsNormBrick::new(vec![1.0; 4], 1e-5).with_budget(TokenBudget::from_latency(1000.0)); // 1ms budget for test + RmsNormBrick::new(vec![1.0; 4], 1e-5).with_budget(TokenBudget::from_latency(f64::MAX)); let input = vec![1e-10f32; 4]; // Very small values let result = brick.run(&input).expect("operation failed"); From a7100cd0d826b5ce1c7a5d36554e715f9aa2411f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 20:46:35 +0200 Subject: [PATCH 7/8] evidence(pr-review): #4866 receipt at 7f4fca56c4 (reviewer claude-sonnet-5-5, DEGRADED, blocking none) diff_patch_id 2a00fa4dca32f703758d48bb3e3cc344682438ff. The new delta is the f048 flake fix (cherry-picked from 97737d5bb8). Its NaN and finiteness asserts still fail on non-finite output, and from_latency(f64::MAX) cannot overflow in is_met. Not run: agy, cargo, mutants. The author's pre-push proof is cited: fmt rc 0, brick:: 277/0, aprender-contracts --lib 2290/0, deny advisories rc 0. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../findings.sarif | 59 +++++++++++++++++++ .../receipt.intoto.jsonl | 1 + 2 files changed, 60 insertions(+) create mode 100644 evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif create mode 100644 evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl diff --git a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif new file mode 100644 index 0000000000..a1514a9c16 --- /dev/null +++ b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif @@ -0,0 +1,59 @@ +{ + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { "driver": { "name": "pmat" } }, + "results": [ + { + "ruleId": "f048_still_fails_on_nonfinite_output", + "level": "note", + "message": { "text": "RmsNormBrick::run (crates/aprender-serve/src/brick/brick_impls.rs:58-88, included by mod.rs:439) builds the output, then runs every assertion from assertions(): no_nan, no_inf, budget_met, through AssertionKind::check_f32 (mod.rs:299-338). A NaN or Inf output returns Err(AssertionFailed), so brick.run(..).expect(\"operation failed\") panics in the test; the test's own is_nan and is_finite asserts are unchanged and also fail. The delta removes only the wall-time budget." }, + "properties": { + "grounding": "measured", + "command": ["pmat", "query", "RmsNormBrick run", "--include-source", "--limit", "3"], + "exit_code": 0, + "stdout_sha256": "not_recorded", + "failure_scenario": "an eps change that makes rms zero gives NaN or Inf output; run returns Err and the test fails", + "precision_class": "advisory" + } + }, + { + "ruleId": "removed_budget_hides_nothing_the_test_claims", + "level": "note", + "message": { "text": "The test is named f048_rmsnorm_epsilon and its comment says it checks that epsilon prevents division by zero on near-zero input. That property is the finiteness of the output, still asserted three ways (run's no_nan/no_inf, then the two test asserts). The 1 ms budget was a latency check on a loaded runner, which the name does not claim. Latency stays covered by the other brick tests that keep real budgets, e.g. rmsnorm_brick_runs in tests_f001_brick_f002.rs:143." }, + "properties": { + "grounding": "asserted", + "rationale": "read of the test at tests_r006_fused.rs:327-346 and of RmsNormBrick::run", + "failure_scenario": "none found; a latency regression in RmsNorm is not this test's job", + "precision_class": "advisory" + } + }, + { + "ruleId": "f64_max_budget_no_overflow_or_nan", + "level": "note", + "message": { "text": "TokenBudget::from_latency(f64::MAX) (mod.rs:60) stores us_per_token = f64::MAX and tokens_per_sec = 1_000_000.0 / f64::MAX, about 5.6e-303: a finite positive normal value, no underflow to zero, no NaN. TokenResult::new computes budget.is_met(us_per_token), which is actual <= f64::MAX: always true for a finite actual (elapsed microseconds cast from u128 to f64). No arithmetic on f64::MAX that could overflow occurs on this path; gap_factor is actual / MAX, which is finite and not called here. budget_met is therefore true and the budget_met assertion passes." }, + "properties": { + "grounding": "asserted", + "rationale": "read of from_latency, is_met, gap_factor and TokenResult::new", + "failure_scenario": "none: only NaN actual or a NaN budget would make the comparison false, neither is reachable", + "precision_class": "advisory" + } + }, + { + "ruleId": "delta_scope_one_test_only", + "level": "note", + "message": { "text": "git diff ab53111dfc 7f4fca56c4 touches only crates/aprender-serve/src/brick/tests_r006_fused.rs: one comment line and one expression in f048_rmsnorm_epsilon. No production code, no other test, no workflow or manifest. The comment says FLAKE-0, which matches the fleet rule on flaky tests. Nothing else in the delta." }, + "properties": { + "grounding": "measured", + "command": ["git", "diff", "ab53111dfc", "7f4fca56c4"], + "exit_code": 0, + "stdout_sha256": "not_recorded", + "failure_scenario": "none", + "precision_class": "advisory" + } + } + ] + } + ] +} diff --git a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl new file mode 100644 index 0000000000..fc53dc9d60 --- /dev/null +++ b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl @@ -0,0 +1 @@ +{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"git+https://github.com/paiml/aprender","digest":{"sha1":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac"}}],"predicateType":"https://paiml.dev/attestations/pr-review/v2","predicate":{"skill_version":"2.1.0","attestation_level":"L1-self","pr":4866,"base_sha":"dee8802d9af97732752621a2be834fb97e05537d","head_sha":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","author_actor":{"kind":"agent","id":"agent:claude-opus-5-5/aprender-w4650"},"reviewer_actor":{"kind":"agent","id":"agent:claude-sonnet-5-5/w4650-review"},"affected_crates":["aprender-serve"],"verdict":"DEGRADED","degraded_reason":"antigravity (agy) did not run in this lane; cargo (test, fmt, deny) and cargo-mutants were NOT run here, so the author measured proof at this head is cited and not re-run: cargo fmt --check rc 0; aprender-serve --lib brick:: 277 passed, 0 failed; aprender-contracts --lib 2290 passed, 0 failed; cargo deny advisories rc 0; diff_patch_id is the value supplied by the dispatcher, not recomputed here; cuda and crux consultations not performed (test-only delta). The full diff was reviewed at 2d35b7eca0 (DEGRADED, no blocking class); this receipt covers only the delta ab53111dfc..7f4fca56c4. No blocking class fires: run() still returns Err on NaN or Inf, f64::MAX cannot overflow or NaN in is_met.","consultations":{"pmat":{"status":"consulted","transport":"cli","transport_unavailable":["mcp: not attempted this run (cli answered)"],"index_commit":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","index_is_ancestor":true,"index_worktree_dirty":false,"complexity_delta":[],"tdg_delta":[],"satd_introduced":[],"cache_hits":0,"analysis_note":"pmat query used to read RmsNormBrick::run and the brick tests; the delta is one test expression, so no complexity, tdg or satd delta applies.","duplication_hits":[],"queries":[{"q":"RmsNormBrick run","command":"pmat query \"RmsNormBrick run\" --include-source --limit 3","cwd":"worktree at HEAD","results":3,"note":"read run(), assertions and the sibling rmsnorm tests"}],"duplication_note":"no production code in the delta; duplication not applicable."},"cuda":{"status":"unreachable","trigger_reason":"not performed: no nvidia-cuda-docs query was made in this lane; the delta is a host-only serialization filter and the CUDA-path file in the full diff was covered by the earlier receipt"},"crux":{"status":"unreachable","trigger_reason":"not performed: crux contracts not re-read for this delta"},"mutation":{"status":"unreachable","scope":"in-diff","attempted":0,"killed":0,"survivors":[],"trigger_reason":"cargo-mutants not run: test-only delta, no production mutation target; host policy forbids cargo in this lane"},"antigravity":{"status":"unreachable","trigger_reason":"agy did not run in this lane; no consultation attempted"}},"findings_ref":{"path":"findings.sarif","sha256":"eae89dd4115dc2aff2db4022b49817a0d7c07f5dc59a8326a3deed1a89956d9f"},"cost":{"input_tokens":60000,"output_tokens":6000,"wall_seconds":300},"diff_patch_id":"2a00fa4dca32f703758d48bb3e3cc344682438ff","diff_patch_id_algo":"git-patch-id-verbatim/pinned-diff-v1"}} From 6941fe55bd8edde910a509f62f75fb4d3c1eb768 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 6 Oct 2026 21:03:16 +0200 Subject: [PATCH 8/8] evidence(pr-review): #4866 receipt records its duplication coverage (present B1 at a7100cd0d8) present rejected the 7f4fca56c4 receipt: "pmat.status is consulted but duplication_coverage is absent". scripts/pr_review_duplication_scan.sh now supplies coverage for all 8 surfaces (lexical) and 1541 of 1541 sibling branches, with 7 hits. The reviewer seat (claude-sonnet-5-5) judged each hit in findings.sarif: - args_of is a test-local helper, unrelated to the aprender-contracts one. - PMAT-4650.yaml is the same ticket's lineage, on the superseded #4711 branches. Locally the guard's only remaining rejection is the minisig, which CI's signer adds. Agent: aprender-w4650 Co-Authored-By: Claude Opus 5.5 --- .../findings.sarif | 80 +++++++++++++++++-- .../receipt.intoto.jsonl | 2 +- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif index a1514a9c16..02a4d1167a 100644 --- a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif +++ b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/findings.sarif @@ -3,15 +3,28 @@ "version": "2.1.0", "runs": [ { - "tool": { "driver": { "name": "pmat" } }, + "tool": { + "driver": { + "name": "pmat" + } + }, "results": [ { "ruleId": "f048_still_fails_on_nonfinite_output", "level": "note", - "message": { "text": "RmsNormBrick::run (crates/aprender-serve/src/brick/brick_impls.rs:58-88, included by mod.rs:439) builds the output, then runs every assertion from assertions(): no_nan, no_inf, budget_met, through AssertionKind::check_f32 (mod.rs:299-338). A NaN or Inf output returns Err(AssertionFailed), so brick.run(..).expect(\"operation failed\") panics in the test; the test's own is_nan and is_finite asserts are unchanged and also fail. The delta removes only the wall-time budget." }, + "message": { + "text": "RmsNormBrick::run (crates/aprender-serve/src/brick/brick_impls.rs:58-88, included by mod.rs:439) builds the output, then runs every assertion from assertions(): no_nan, no_inf, budget_met, through AssertionKind::check_f32 (mod.rs:299-338). A NaN or Inf output returns Err(AssertionFailed), so brick.run(..).expect(\"operation failed\") panics in the test; the test's own is_nan and is_finite asserts are unchanged and also fail. The delta removes only the wall-time budget." + }, "properties": { "grounding": "measured", - "command": ["pmat", "query", "RmsNormBrick run", "--include-source", "--limit", "3"], + "command": [ + "pmat", + "query", + "RmsNormBrick run", + "--include-source", + "--limit", + "3" + ], "exit_code": 0, "stdout_sha256": "not_recorded", "failure_scenario": "an eps change that makes rms zero gives NaN or Inf output; run returns Err and the test fails", @@ -21,7 +34,9 @@ { "ruleId": "removed_budget_hides_nothing_the_test_claims", "level": "note", - "message": { "text": "The test is named f048_rmsnorm_epsilon and its comment says it checks that epsilon prevents division by zero on near-zero input. That property is the finiteness of the output, still asserted three ways (run's no_nan/no_inf, then the two test asserts). The 1 ms budget was a latency check on a loaded runner, which the name does not claim. Latency stays covered by the other brick tests that keep real budgets, e.g. rmsnorm_brick_runs in tests_f001_brick_f002.rs:143." }, + "message": { + "text": "The test is named f048_rmsnorm_epsilon and its comment says it checks that epsilon prevents division by zero on near-zero input. That property is the finiteness of the output, still asserted three ways (run's no_nan/no_inf, then the two test asserts). The 1 ms budget was a latency check on a loaded runner, which the name does not claim. Latency stays covered by the other brick tests that keep real budgets, e.g. rmsnorm_brick_runs in tests_f001_brick_f002.rs:143." + }, "properties": { "grounding": "asserted", "rationale": "read of the test at tests_r006_fused.rs:327-346 and of RmsNormBrick::run", @@ -32,7 +47,9 @@ { "ruleId": "f64_max_budget_no_overflow_or_nan", "level": "note", - "message": { "text": "TokenBudget::from_latency(f64::MAX) (mod.rs:60) stores us_per_token = f64::MAX and tokens_per_sec = 1_000_000.0 / f64::MAX, about 5.6e-303: a finite positive normal value, no underflow to zero, no NaN. TokenResult::new computes budget.is_met(us_per_token), which is actual <= f64::MAX: always true for a finite actual (elapsed microseconds cast from u128 to f64). No arithmetic on f64::MAX that could overflow occurs on this path; gap_factor is actual / MAX, which is finite and not called here. budget_met is therefore true and the budget_met assertion passes." }, + "message": { + "text": "TokenBudget::from_latency(f64::MAX) (mod.rs:60) stores us_per_token = f64::MAX and tokens_per_sec = 1_000_000.0 / f64::MAX, about 5.6e-303: a finite positive normal value, no underflow to zero, no NaN. TokenResult::new computes budget.is_met(us_per_token), which is actual <= f64::MAX: always true for a finite actual (elapsed microseconds cast from u128 to f64). No arithmetic on f64::MAX that could overflow occurs on this path; gap_factor is actual / MAX, which is finite and not called here. budget_met is therefore true and the budget_met assertion passes." + }, "properties": { "grounding": "asserted", "rationale": "read of from_latency, is_met, gap_factor and TokenResult::new", @@ -43,15 +60,64 @@ { "ruleId": "delta_scope_one_test_only", "level": "note", - "message": { "text": "git diff ab53111dfc 7f4fca56c4 touches only crates/aprender-serve/src/brick/tests_r006_fused.rs: one comment line and one expression in f048_rmsnorm_epsilon. No production code, no other test, no workflow or manifest. The comment says FLAKE-0, which matches the fleet rule on flaky tests. Nothing else in the delta." }, + "message": { + "text": "git diff ab53111dfc 7f4fca56c4 touches only crates/aprender-serve/src/brick/tests_r006_fused.rs: one comment line and one expression in f048_rmsnorm_epsilon. No production code, no other test, no workflow or manifest. The comment says FLAKE-0, which matches the fleet rule on flaky tests. Nothing else in the delta." + }, "properties": { "grounding": "measured", - "command": ["git", "diff", "ab53111dfc", "7f4fca56c4"], + "command": [ + "git", + "diff", + "ab53111dfc", + "7f4fca56c4" + ], "exit_code": 0, "stdout_sha256": "not_recorded", "failure_scenario": "none", "precision_class": "advisory" } + }, + { + "ruleId": "dup_args_of_name_collision", + "level": "note", + "message": { + "text": "Three lexical hits for the name args_of at HEAD in crates/aprender-contracts/src/ontology/extract/cli_surface.rs lines 209, 217, 232. This diff defines args_of only as a test-local helper in a test module of crates/aprender-serve/src/api/openai_handlers.rs (a fn over ChatMessage returning the tool-call arguments as JSON) and never calls or imports the contracts function, which belongs to a different crate and domain (CLI surface extraction). Same name, no shared behaviour: not prior art and not a second implementation." + }, + "properties": { + "grounding": "measured", + "command": [ + "scripts/pr_review_duplication_scan.sh", + "--base", + "dee8802d9a", + "--head", + "7f4fca56c4" + ], + "exit_code": 0, + "stdout_sha256": "not_recorded", + "failure_scenario": "none: a rename of either helper cannot affect the other, they share no module", + "precision_class": "advisory" + } + }, + { + "ruleId": "dup_pmat_4650_yaml_superseded_branches", + "level": "note", + "message": { + "text": "Four lexical filename hits for docs/roadmaps/entries/PMAT-4650.yaml on origin/fix/4650-qwen35-tools-template, origin/keep/4711-ae9d0ee6c5, origin/keep/fix/4650-qwen35-tools-template and origin/pr/4711. All are branches of the same ticket, including the superseded #4711 line whose net change this PR rebuilds. They are lineage of the one roadmap entry this PR adds, not a parallel implementation; the old branches are not merge candidates." + }, + "properties": { + "grounding": "measured", + "command": [ + "scripts/pr_review_duplication_scan.sh", + "--base", + "dee8802d9a", + "--head", + "7f4fca56c4" + ], + "exit_code": 0, + "stdout_sha256": "not_recorded", + "failure_scenario": "if a superseded branch were merged as well, two PMAT-4650.yaml files would collide; those branches are parked and not armed", + "precision_class": "advisory" + } } ] } diff --git a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl index fc53dc9d60..eaa0888a70 100644 --- a/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl +++ b/evidence/pr-review/4866/7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac/receipt.intoto.jsonl @@ -1 +1 @@ -{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"git+https://github.com/paiml/aprender","digest":{"sha1":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac"}}],"predicateType":"https://paiml.dev/attestations/pr-review/v2","predicate":{"skill_version":"2.1.0","attestation_level":"L1-self","pr":4866,"base_sha":"dee8802d9af97732752621a2be834fb97e05537d","head_sha":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","author_actor":{"kind":"agent","id":"agent:claude-opus-5-5/aprender-w4650"},"reviewer_actor":{"kind":"agent","id":"agent:claude-sonnet-5-5/w4650-review"},"affected_crates":["aprender-serve"],"verdict":"DEGRADED","degraded_reason":"antigravity (agy) did not run in this lane; cargo (test, fmt, deny) and cargo-mutants were NOT run here, so the author measured proof at this head is cited and not re-run: cargo fmt --check rc 0; aprender-serve --lib brick:: 277 passed, 0 failed; aprender-contracts --lib 2290 passed, 0 failed; cargo deny advisories rc 0; diff_patch_id is the value supplied by the dispatcher, not recomputed here; cuda and crux consultations not performed (test-only delta). The full diff was reviewed at 2d35b7eca0 (DEGRADED, no blocking class); this receipt covers only the delta ab53111dfc..7f4fca56c4. No blocking class fires: run() still returns Err on NaN or Inf, f64::MAX cannot overflow or NaN in is_met.","consultations":{"pmat":{"status":"consulted","transport":"cli","transport_unavailable":["mcp: not attempted this run (cli answered)"],"index_commit":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","index_is_ancestor":true,"index_worktree_dirty":false,"complexity_delta":[],"tdg_delta":[],"satd_introduced":[],"cache_hits":0,"analysis_note":"pmat query used to read RmsNormBrick::run and the brick tests; the delta is one test expression, so no complexity, tdg or satd delta applies.","duplication_hits":[],"queries":[{"q":"RmsNormBrick run","command":"pmat query \"RmsNormBrick run\" --include-source --limit 3","cwd":"worktree at HEAD","results":3,"note":"read run(), assertions and the sibling rmsnorm tests"}],"duplication_note":"no production code in the delta; duplication not applicable."},"cuda":{"status":"unreachable","trigger_reason":"not performed: no nvidia-cuda-docs query was made in this lane; the delta is a host-only serialization filter and the CUDA-path file in the full diff was covered by the earlier receipt"},"crux":{"status":"unreachable","trigger_reason":"not performed: crux contracts not re-read for this delta"},"mutation":{"status":"unreachable","scope":"in-diff","attempted":0,"killed":0,"survivors":[],"trigger_reason":"cargo-mutants not run: test-only delta, no production mutation target; host policy forbids cargo in this lane"},"antigravity":{"status":"unreachable","trigger_reason":"agy did not run in this lane; no consultation attempted"}},"findings_ref":{"path":"findings.sarif","sha256":"eae89dd4115dc2aff2db4022b49817a0d7c07f5dc59a8326a3deed1a89956d9f"},"cost":{"input_tokens":60000,"output_tokens":6000,"wall_seconds":300},"diff_patch_id":"2a00fa4dca32f703758d48bb3e3cc344682438ff","diff_patch_id_algo":"git-patch-id-verbatim/pinned-diff-v1"}} +{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"git+https://github.com/paiml/aprender","digest":{"sha1":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac"}}],"predicateType":"https://paiml.dev/attestations/pr-review/v2","predicate":{"skill_version":"2.1.0","attestation_level":"L1-self","pr":4866,"base_sha":"dee8802d9af97732752621a2be834fb97e05537d","head_sha":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","author_actor":{"kind":"agent","id":"agent:claude-opus-5-5/aprender-w4650"},"reviewer_actor":{"kind":"agent","id":"agent:claude-sonnet-5-5/w4650-review"},"affected_crates":["aprender-serve"],"verdict":"DEGRADED","degraded_reason":"antigravity (agy) did not run in this lane; cargo (test, fmt, deny) and cargo-mutants were NOT run here, so the author measured proof at this head is cited and not re-run: cargo fmt --check rc 0; aprender-serve --lib brick:: 277 passed, 0 failed; aprender-contracts --lib 2290 passed, 0 failed; cargo deny advisories rc 0; diff_patch_id is the value supplied by the dispatcher, not recomputed here; cuda and crux consultations not performed (test-only delta). The full diff was reviewed at 2d35b7eca0 (DEGRADED, no blocking class); this receipt covers only the delta ab53111dfc..7f4fca56c4. No blocking class fires: run() still returns Err on NaN or Inf, f64::MAX cannot overflow or NaN in is_met.","consultations":{"pmat":{"status":"consulted","transport":"cli","transport_unavailable":["mcp: not attempted this run (cli answered)"],"index_commit":"7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","index_is_ancestor":true,"index_worktree_dirty":false,"complexity_delta":[],"tdg_delta":[],"satd_introduced":[],"cache_hits":0,"analysis_note":"pmat query used to read RmsNormBrick::run and the brick tests; the delta is one test expression, so no complexity, tdg or satd delta applies.","duplication_hits":[{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":209,"method":"lexical"},{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":217,"method":"lexical"},{"needle":"args_of","kind":"name","where":"HEAD","ref":"HEAD","path":"crates/aprender-contracts/src/ontology/extract/cli_surface.rs","line":232,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/fix/4650-qwen35-tools-template","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/keep/4711-ae9d0ee6c5","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/keep/fix/4650-qwen35-tools-template","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"},{"needle":"PMAT-4650.yaml","kind":"filename","where":"branch","ref":"origin/pr/4711","path":"docs/roadmaps/entries/PMAT-4650.yaml","line":0,"method":"lexical"}],"queries":[{"q":"RmsNormBrick run","command":"pmat query \"RmsNormBrick run\" --include-source --limit 3","cwd":"worktree at HEAD","results":3,"note":"read run(), assertions and the sibling rmsnorm tests"}],"duplication_note":"no production code in the delta; duplication not applicable.","duplication_coverage":{"rust":"lexical","shell":"lexical","python":"lexical","config":"lexical","docs":"lexical","other":"lexical","sibling_branches":"lexical","merge_base_to_main":"lexical"},"duplication_horizon":["head=7f4fca56c4e07c0c3c23d0a15f3b1ea6c443ceac","siblings=refs/remotes/origin/* unmerged into origin/main","merge_base_to_main=dee8802d9af97732752621a2be834fb97e05537d..refs/remotes/origin/main"],"horizon_branches_total":1541,"horizon_branches_scanned":1541,"horizon_refs":{"local_origin_refs":1584,"remote_heads":1414},"merge_base_to_main_files":42,"symbols_searched":38,"needles":{"symbol":32,"filename":6,"min_length":6,"dropped_ambient":4,"ambient_max_hits":8},"hits_total":7,"hits_recorded":7,"wall_seconds":199},"cuda":{"status":"unreachable","trigger_reason":"not performed: no nvidia-cuda-docs query was made in this lane; the delta is a host-only serialization filter and the CUDA-path file in the full diff was covered by the earlier receipt"},"crux":{"status":"unreachable","trigger_reason":"not performed: crux contracts not re-read for this delta"},"mutation":{"status":"unreachable","scope":"in-diff","attempted":0,"killed":0,"survivors":[],"trigger_reason":"cargo-mutants not run: test-only delta, no production mutation target; host policy forbids cargo in this lane"},"antigravity":{"status":"unreachable","trigger_reason":"agy did not run in this lane; no consultation attempted"}},"findings_ref":{"path":"findings.sarif","sha256":"e6964572e1657f8f4ecd0934edd4e526fdb00beb8bc83389cc3ae9efcd4697d5"},"cost":{"input_tokens":60000,"output_tokens":6000,"wall_seconds":300},"diff_patch_id":"2a00fa4dca32f703758d48bb3e3cc344682438ff","diff_patch_id_algo":"git-patch-id-verbatim/pinned-diff-v1"}}