From d2fe6728a094dbae6e59dc78ca284e911851d939 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:47:53 +0200 Subject: [PATCH 01/39] compute: add explicit owner-bound BF16 native CPU candidate --- .github/workflows/quality.yml | 4 + THIRD_PARTY_LICENSES.md | 23 ++ crates/volparossa/src/compute.rs | 9 + crates/volparossa/src/compute/broker.rs | 1 + crates/volparossa/src/compute/native_cpu.rs | 209 ++++++++++++ .../src/compute/native_cpu/tests.rs | 165 ++++++++++ .../volparossa/src/compute/peer/document.rs | 1 + .../compute/peer/document/graph/planner.rs | 1 + .../volparossa/src/compute/private_serve.rs | 64 ++-- .../src/compute/private_serve/WIRE.md | 23 ++ .../src/compute/private_serve/tests.rs | 43 +++ crates/volparossa/src/compute/private_task.rs | 12 + crates/volparossa/src/compute/sandbox.rs | 50 ++- crates/volparossa/src/compute/supervise.rs | 13 + crates/volparossa/src/compute/train_cycle.rs | 1 + .../src/compute/train_loop/aggregate.rs | 1 + .../src/compute/train_loop/peer_evaluation.rs | 1 + .../src/compute/train_loop/validation.rs | 1 + docs/IMPLEMENTATION_STATUS.md | 46 +++ workers/volparossa-ml/build_llama_cpu.py | 200 ++++++++++++ workers/volparossa-ml/convert_llama_cpu.py | 265 ++++++++++++++++ workers/volparossa-ml/llama_cpu.py | 271 ++++++++++++++++ .../volparossa-ml/native-cpu/CMakeLists.txt | 54 ++++ workers/volparossa-ml/native-cpu/README.md | 105 ++++++ .../volparossa-ml/native-cpu/abi-smoke.cpp | 32 ++ workers/volparossa-ml/native-cpu/adapter.cpp | 142 +++++++++ workers/volparossa-ml/native-cpu/adapter.h | 24 ++ .../volparossa-ml/native-cpu/bf16-tensors.cpp | 34 ++ workers/volparossa-ml/provision.py | 46 +++ workers/volparossa-ml/test_llama_cpu.py | 299 ++++++++++++++++++ workers/volparossa-ml/worker.py | 18 +- 31 files changed, 2134 insertions(+), 24 deletions(-) create mode 100644 crates/volparossa/src/compute/native_cpu.rs create mode 100644 crates/volparossa/src/compute/native_cpu/tests.rs create mode 100644 workers/volparossa-ml/build_llama_cpu.py create mode 100644 workers/volparossa-ml/convert_llama_cpu.py create mode 100644 workers/volparossa-ml/llama_cpu.py create mode 100644 workers/volparossa-ml/native-cpu/CMakeLists.txt create mode 100644 workers/volparossa-ml/native-cpu/README.md create mode 100644 workers/volparossa-ml/native-cpu/abi-smoke.cpp create mode 100644 workers/volparossa-ml/native-cpu/adapter.cpp create mode 100644 workers/volparossa-ml/native-cpu/adapter.h create mode 100644 workers/volparossa-ml/native-cpu/bf16-tensors.cpp create mode 100644 workers/volparossa-ml/test_llama_cpu.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 41974f70c..53685960e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -57,6 +57,10 @@ jobs: cargo fmt --all -- --check ./scripts/check-shell.sh + - name: Check native CPU contracts without loading models + working-directory: workers/volparossa-ml + run: python3 -B -m unittest test_llama_cpu + - name: Install pinned dependency-audit tools run: | tool_root="${RUNNER_TEMP}/cargo-tools" diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 7d7abc8c7..262906062 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,6 +4,29 @@ Original VOLPAROSSA source in this repository is licensed under GPL-3.0-only. De vendored components retain their own licenses. This file is a provenance record, not a substitute for the license text shipped by each upstream project. +## Explicit llama.cpp CPU inference candidate + +The opt-in CPU backend uses [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp) +at exact commit `7fe450e19305b828c199d602c23a8337aaa1f03b` (resolved release tag +`v0.5.0`; MIT). The tag is unsigned. No prebuilt upstream executable, server, +GPU backend or implicit artifact download is used. Original code is unpatched; +the GPL-3.0-only adapter exposes a fixed owned C ABI and source-build flags. +The builder preserves tracked upstream licenses/notices, including ggml and +the Mozilla tinyBLAS notice, unchanged in the generated artifact. +Static GCC runtime archives retain measured hashes and the original Debian GCC +copyright/runtime-exception notice; compiler versions are recorded, not silently +treated as upstream llama.cpp source or reproducible-build proof. + +The separate offline conversion candidate starts only from the existing pinned +Apache-2.0 `Qwen/Qwen3-4B-Instruct-2507` revision +`cdbee75f17c01a7cc42f958dc650907174af0554`. It compares every original BF16 tensor +value, allowing only exact F32 promotion for norm/one-dimensional tensors; it +does not quantize weights. Original shard identity and the measured GGUF digest +are distinct. The original Qwen license accompanies converted artifacts. +See [the native CPU contract](workers/volparossa-ml/native-cpu/README.md) for +source/build/conversion provenance, instruction requirements and remaining proof. +Source and inert tests are not claims of successful guest inference or coding. + ## Explicit native editor integration trial The `agent-native-editor` guest trial uses VOLPAROSSA Code commit diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index bbe534a5c..b7d6b03f5 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -4,6 +4,7 @@ mod broker; mod device_capacity; mod document_plan; mod inference_output; +mod native_cpu; mod owner_control; mod peer; mod policy_assessment; @@ -111,6 +112,8 @@ pub(crate) struct Options { /// Explicit pinned inference/planning profile; the default also supports training/adapters. #[arg(long, default_value_t = ModelProfile::default())] model_profile: ModelProfile, + #[command(flatten)] + native_backend: native_cpu::Options, /// Explicit verified adapter directory; cache storage alone never activates an adapter. #[arg(long)] adapter_root: Option, @@ -139,6 +142,8 @@ pub(crate) struct Options { #[derive(Serialize)] struct WorkerRequest { + #[serde(flatten)] + native_backend: native_cpu::Request, version: u8, id: String, mode: Mode, @@ -247,6 +252,7 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result, + /// Explicit owner-authorized SHA-256 of backend.json, including executable identity. + #[arg(long, requires = "native_backend_root")] + pub native_backend_sha256: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Artifact { + path: String, + sha256: String, + bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Verification { + tensor_count: u32, + tensor_values_equal: bool, + tokenizer_equal: bool, + chat_template_sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + version: u8, + kind: String, + abi_version: u8, + source_commit: String, + model_profile: ModelProfile, + source_weights_sha256: String, + source_weights_bytes: u64, + library: Artifact, + gguf: Artifact, + verification: Verification, + build_manifest_sha256: String, +} + +impl Manifest { + fn validate(&self) -> Result<()> { + ensure!( + self.version == 1 + && self.kind == KIND + && self.abi_version == 1 + && self.source_commit == SOURCE + && self.model_profile == ModelProfile::Qwen4bInstruct2507 + && self.source_weights_sha256 == WEIGHTS + && self.source_weights_bytes == 8_044_982_000, + "compute_native_backend_identity" + ); + ensure!( + self.library.path == "libvolparossa_llama_cpu.so" + && (1..=128 * 1024 * 1024).contains(&self.library.bytes) + && self.gguf.path == "model.gguf" + && (1..=9 * 1024 * 1024 * 1024).contains(&self.gguf.bytes) + && [ + &self.library.sha256, + &self.gguf.sha256, + &self.build_manifest_sha256, + &self.verification.chat_template_sha256 + ] + .iter() + .all(|hash| super::is_hex(hash, 64)), + "compute_native_backend_artifacts" + ); + ensure!( + self.verification.tensor_count == 398 + && self.verification.tensor_values_equal + && self.verification.tokenizer_equal, + "compute_native_backend_conversion" + ); + Ok(()) + } + + fn report(&self, hash: &str) -> Value { + json!({"kind":KIND,"abi_version":1,"source_commit":SOURCE, + "manifest_sha256":hash,"library_sha256":self.library.sha256, + "gguf_sha256":self.gguf.sha256,"gguf_bytes":self.gguf.bytes, + "source_weights_sha256":WEIGHTS}) + } +} + +impl Options { + pub(super) fn enabled(&self) -> bool { + self.native_backend_root.is_some() || self.native_backend_sha256.is_some() + } + + pub(super) fn validate(&self, profile: ModelProfile) -> Result<()> { + self.request(profile).map(|_| ()) + } + + pub(super) fn validate_input( + &self, + mode: Mode, + raw: &[u8], + profile: ModelProfile, + ) -> Result<()> { + if self.enabled() { + ensure!( + mode == Mode::PrivateConversation && profile == ModelProfile::Qwen4bInstruct2507, + "compute_native_backend_scope" + ); + let input = super::private_conversation::Input::decode_profile(raw, profile)?; + ensure!( + input.requires_generation_policy_handshake(), + "compute_native_backend_policy" + ); + } + Ok(()) + } + + pub(super) fn request(&self, profile: ModelProfile) -> Result { + let (root, hash) = match (&self.native_backend_root, &self.native_backend_sha256) { + (None, None) => return Ok(Request::default()), + (Some(root), Some(hash)) => (root, hash), + _ => anyhow::bail!("compute_native_backend_authorization"), + }; + ensure!( + profile == ModelProfile::Qwen4bInstruct2507 && super::is_hex(hash, 64), + "compute_native_backend_authorization" + ); + super::private_directory(root)?; + let raw = super::read_file(&root.join("backend.json"), 16 * 1024)?; + ensure!( + hex::encode(Sha256::digest(&raw)) == *hash, + "compute_native_backend_manifest_hash" + ); + let manifest: Manifest = serde_json::from_slice(&raw) + .map_err(|_| anyhow::anyhow!("compute_native_backend_manifest_schema"))?; + manifest.validate()?; + for artifact in [&manifest.library, &manifest.gguf] { + let info = fs::symlink_metadata(root.join(&artifact.path))?; + ensure!( + info.is_file() + && info.uid() == nix::unistd::geteuid().as_raw() + && info.nlink() == 1 + && info.mode() & 0o077 == 0 + && info.len() == artifact.bytes, + "compute_native_backend_file" + ); + } + // The fixed isolated worker verifies both complete artifact hashes and + // build provenance before dlopen. No native library is loaded by the core. + Ok(Request { + inference_backend: Some(KIND), + native_backend_root: Some("/native-backend"), + native_backend_sha256: Some(hash.clone()), + expected_report: Some(manifest.report(hash)), + }) + } +} + +#[derive(Default, Serialize)] +pub(super) struct Request { + #[serde(skip_serializing_if = "Option::is_none")] + inference_backend: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + native_backend_root: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + native_backend_sha256: Option, + #[serde(skip)] + expected_report: Option, +} + +impl Request { + pub(super) fn enabled(&self) -> bool { + self.inference_backend.is_some() + } + + pub(super) fn check_report(&self, value: &Value) -> Result<()> { + ensure!( + value.get("inference_backend") == self.expected_report.as_ref(), + "compute_native_backend_result_binding" + ); + if self.enabled() { + ensure!( + value["model_parameter_dtype"] == "bf16_with_exact_f32_norms" + && value["model_attention_backend"] == "llama_cpp_cpu", + "compute_native_backend_result_precision" + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs new file mode 100644 index 000000000..0064c2fec --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -0,0 +1,165 @@ +//! Inert admission/binding fixtures only: no executable library or model is loaded. +use std::os::unix::fs::{PermissionsExt as _, symlink}; + +use super::*; + +fn manifest() -> Value { + json!({"version":1,"kind":KIND,"abi_version":1,"source_commit":SOURCE, + "model_profile":"qwen3-4b-instruct-2507-v1","source_weights_sha256":WEIGHTS, + "source_weights_bytes":8_044_982_000_u64, + "library":{"path":"libvolparossa_llama_cpu.so","sha256":"a".repeat(64),"bytes":1}, + "gguf":{"path":"model.gguf","sha256":"b".repeat(64),"bytes":1}, + "verification":{"tensor_count":398,"tensor_values_equal":true,"tokenizer_equal":true, + "chat_template_sha256":"c".repeat(64)},"build_manifest_sha256":"d".repeat(64)}) +} + +fn fixture() -> (tempfile::TempDir, Options) { + let root = tempfile::tempdir().unwrap(); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let raw = serde_json::to_vec(&manifest()).unwrap(); + fs::write(root.path().join("backend.json"), &raw).unwrap(); + for name in ["libvolparossa_llama_cpu.so", "model.gguf"] { + let path = root.path().join(name); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let options = Options { + native_backend_root: Some(root.path().to_owned()), + native_backend_sha256: Some(hex::encode(Sha256::digest(raw))), + }; + (root, options) +} + +#[test] +fn legacy_request_shape_is_unchanged_and_no_backend_report_is_accepted() { + let request = Options::default().request(ModelProfile::default()).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), json!({})); + assert!(!request.enabled()); + assert!(request.check_report(&json!({})).is_ok()); + assert!( + request + .check_report(&json!({"inference_backend":null})) + .is_err() + ); +} + +#[test] +fn manifest_requires_exact_source_profile_conversion_and_fixed_artifacts() { + let good = manifest(); + assert!( + serde_json::from_value::(good.clone()) + .unwrap() + .validate() + .is_ok() + ); + for (pointer, replacement) in [ + ("/kind", json!("peer_selected")), + ("/abi_version", json!(2)), + ("/source_commit", json!("0".repeat(40))), + ("/source_weights_sha256", json!("0".repeat(64))), + ("/source_weights_bytes", json!(1)), + ("/model_profile", json!("smollm2-360m-instruct-v1")), + ("/library/path", json!("../arbitrary.so")), + ("/gguf/path", json!("/other/model.gguf")), + ("/library/sha256", json!("A".repeat(64))), + ("/gguf/bytes", json!(10_u64 * 1024 * 1024 * 1024)), + ("/verification/tensor_count", json!(397)), + ("/verification/tensor_values_equal", json!(false)), + ("/verification/tokenizer_equal", json!(false)), + ] { + let mut bad = good.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!( + serde_json::from_value::(bad) + .and_then(|value| value.validate().map_err(serde::de::Error::custom)) + .is_err(), + "{pointer}" + ); + } + let mut extra = good; + extra["command"] = json!("arbitrary"); + assert!(serde_json::from_value::(extra).is_err()); +} + +#[test] +fn owner_authorization_binds_manifest_and_never_serializes_host_paths() { + let (_root, mut options) = fixture(); + let request = options.request(ModelProfile::Qwen4bInstruct2507).unwrap(); + let fields = serde_json::to_value(&request).unwrap(); + assert_eq!(fields.as_object().unwrap().len(), 3); + assert_eq!(fields["inference_backend"], KIND); + assert_eq!(fields["native_backend_root"], "/native-backend"); + assert!(options.request(ModelProfile::Qwen600).is_err()); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + options.native_backend_sha256 = None; + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); +} + +#[test] +fn executable_symlink_shared_file_and_changed_length_are_rejected() { + let (root, options) = fixture(); + let path = root.path().join("libvolparossa_llama_cpu.so"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(&path, b"changed").unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + fs::remove_file(&path).unwrap(); + symlink(root.path().join("model.gguf"), path).unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); +} + +#[test] +fn result_must_bind_authorized_native_identity_and_honest_precision() { + let (_root, options) = fixture(); + let request = options.request(ModelProfile::Qwen4bInstruct2507).unwrap(); + let good = json!({"inference_backend":request.expected_report, + "model_parameter_dtype":"bf16_with_exact_f32_norms","model_attention_backend":"llama_cpp_cpu"}); + assert!(request.check_report(&good).is_ok()); + for (pointer, replacement) in [ + ("/inference_backend/manifest_sha256", json!("0".repeat(64))), + ("/inference_backend/library_sha256", json!("0".repeat(64))), + ("/inference_backend/gguf_sha256", json!("0".repeat(64))), + ("/model_parameter_dtype", json!("bfloat16")), + ("/model_attention_backend", json!("sdpa")), + ] { + let mut bad = good.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!(request.check_report(&bad).is_err()); + } +} + +#[test] +fn native_backend_requires_explicit_greedy_conversation_only() { + let (_root, options) = fixture(); + let mut input = json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Hello"}],"tools":[]}); + let profile = ModelProfile::Qwen4bInstruct2507; + assert!( + options + .validate_input( + Mode::PrivateConversation, + &serde_json::to_vec(&input).unwrap(), + profile + ) + .is_err() + ); + input["generation_policy"] = json!("greedy_v1"); + let bytes = serde_json::to_vec(&input).unwrap(); + assert!( + options + .validate_input(Mode::PrivateConversation, &bytes, profile) + .is_ok() + ); + assert!( + options + .validate_input(Mode::PublicCodeProposal, &bytes, profile) + .is_err() + ); + assert!( + options + .validate_input(Mode::PrivateConversation, &bytes, ModelProfile::Qwen600) + .is_err() + ); +} diff --git a/crates/volparossa/src/compute/peer/document.rs b/crates/volparossa/src/compute/peer/document.rs index 4402e2fa1..96945b640 100644 --- a/crates/volparossa/src/compute/peer/document.rs +++ b/crates/volparossa/src/compute/peer/document.rs @@ -689,6 +689,7 @@ async fn tokenize( "compute_document_cancelled_before_planning" ); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: Mode::PlanDocument, model_profile: input.model_profile, runtime_root: args diff --git a/crates/volparossa/src/compute/peer/document/graph/planner.rs b/crates/volparossa/src/compute/peer/document/graph/planner.rs index 374a21fdf..e36844d10 100644 --- a/crates/volparossa/src/compute/peer/document/graph/planner.rs +++ b/crates/volparossa/src/compute/peer/document/graph/planner.rs @@ -196,6 +196,7 @@ pub(super) async fn prepare( task::write_bytes(&args.directory.join("planner-input.json"), &bytes, false)?; let output = args.directory.join("model-planner"); let options = compute::Options { + native_backend: compute::native_cpu::Options::default(), mode: compute::Mode::PlanTasks, model_profile: args.model_profile, runtime_root: args diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index ba115901e..961bad5ac 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -40,6 +40,8 @@ pub(crate) struct Options { work_parent: PathBuf, #[arg(long, default_value_t = ModelProfile::Smol360)] model_profile: ModelProfile, + #[command(flatten)] + native_backend: super::native_cpu::Options, #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u16).range(1..=2))] threads: u16, #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u16).range(1..=600))] @@ -59,6 +61,7 @@ impl Options { model_root: self.model_root.clone(), work_parent: self.work_parent.clone(), model_profile: self.model_profile, + native_backend: self.native_backend.clone(), threads: self.threads, max_seconds: self.max_seconds, } @@ -249,6 +252,36 @@ struct Handshakes { execution_error: Option, } +fn conversation_capabilities( + config: &private_task::ExecutionConfig, + gate: &Semaphore, + handshakes: &Handshakes, +) -> Value { + let mut caps = super::private_conversation::capabilities(config.model_profile); + caps["execution_slots"] = 1.into(); + caps["max_seconds"] = config.max_seconds.into(); + caps["max_request_bytes"] = + super::private_conversation::request_frame(config.model_profile).into(); + caps["max_response_bytes"] = wire::MAX_RESPONSE_BYTES.into(); + caps["quarantined"] = gate.is_closed().into(); + if handshakes.execution_error == Some(1) { + caps["execution_error_version"] = 1.into(); + } + if handshakes.generation_policy == Some(1) { + caps["generation_policy_version"] = 1.into(); + caps["generation_policies"] = if config.model_profile.is_native_conversation() { + json!(["greedy_v1"]) + } else { + json!([]) + }; + } + if config.native_backend.enabled() { + caps["inference_backend"] = super::native_cpu::KIND.into(); + caps["required_generation_policy"] = "greedy_v1".into(); + } + caps +} + fn respond( request: wire::Request, config: &Arc, @@ -261,6 +294,10 @@ fn respond( handshakes.qa = true; let mut response = wire::response(&request.id, "capabilities"); response["capabilities"] = capabilities(config, gate); + if config.native_backend.enabled() { + response["capabilities"]["qa_supported"] = false.into(); + response["capabilities"]["inference_backend"] = super::native_cpu::KIND.into(); + } response } wire::Operation::ConversationCapabilities { @@ -271,26 +308,7 @@ fn respond( handshakes.generation_policy = generation_policy_version; handshakes.execution_error = execution_error_version; let mut response = wire::response(&request.id, "conversation_capabilities"); - response["capabilities"] = - super::private_conversation::capabilities(config.model_profile); - response["capabilities"]["execution_slots"] = 1.into(); - response["capabilities"]["max_seconds"] = config.max_seconds.into(); - response["capabilities"]["max_request_bytes"] = - super::private_conversation::request_frame(config.model_profile).into(); - response["capabilities"]["max_response_bytes"] = wire::MAX_RESPONSE_BYTES.into(); - response["capabilities"]["quarantined"] = gate.is_closed().into(); - if handshakes.execution_error == Some(1) { - response["capabilities"]["execution_error_version"] = 1.into(); - } - if handshakes.generation_policy == Some(1) { - response["capabilities"]["generation_policy_version"] = 1.into(); - response["capabilities"]["generation_policies"] = - if config.model_profile.is_native_conversation() { - json!(["greedy_v1"]) - } else { - json!([]) - }; - } + response["capabilities"] = conversation_capabilities(config, gate, handshakes); response } wire::Operation::SubmitConversation { conversation } => { @@ -303,6 +321,10 @@ fn respond( wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { wire::error(Some(&request.id), "busy") + } else if config.native_backend.enabled() + && !conversation.requires_generation_policy_handshake() + { + wire::error(Some(&request.id), "invalid_request") } else if let Some(slot) = ExecutionSlot::admit(gate) { let input = conversation .bytes_profile(config.model_profile) @@ -326,6 +348,8 @@ fn respond( wire::Operation::Submit { question, context } => { if !handshakes.qa { wire::error(Some(&request.id), "handshake_required") + } else if config.native_backend.enabled() { + wire::error(Some(&request.id), "invalid_request") } else if gate.is_closed() { wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { diff --git a/crates/volparossa/src/compute/private_serve/WIRE.md b/crates/volparossa/src/compute/private_serve/WIRE.md index 97a762751..18a850aa7 100644 --- a/crates/volparossa/src/compute/private_serve/WIRE.md +++ b/crates/volparossa/src/compute/private_serve/WIRE.md @@ -83,6 +83,29 @@ Unconfirmed cleanup quarantines further admission. Temporary input/output files exist only under the owner's private work directory and follow the existing private-task cleanup contract; this is **not** a RAM-only or secure-erasure claim. +## Owner-selected native CPU backend + +The owner may explicitly supply both `--native-backend-root` and +`--native-backend-sha256` at service startup. The latter is the SHA-256 of the +private `backend.json` manifest binding the pinned library and converted model. +An IPC request cannot supply these paths or authorize executable code. The +backend directory is mounted read-only inside the existing isolated worker; +the worker verifies the complete artifact hashes before loading the library. + +This candidate supports only `qwen3-4b-instruct-2507-v1` conversations. Its +conversation capabilities add `inference_backend: "llama_cpp_bf16_v1"` and +`required_generation_policy: "greedy_v1"`; both fields are absent for the +unchanged default backend. Clients must negotiate `generation_policy_version: 1` +and explicitly submit `greedy_v1`. Missing policy is rejected before admission. +The existing Qwen4B wire validation rejects Q&A capabilities and submission; +the native backend does not enable a Q&A service. A conversation capability +response is not evidence that a model has run successfully. The public result +schema remains unchanged; the core binds +the worker's native artifact identity and reported precision internally. + +This is still same-owner local execution, not confidential execution on peers. +Actual conversion, full generation and application trials remain required. + ## Browser boundary Only privileged, owner-authorized browser code may use the socket. Web page scripts diff --git a/crates/volparossa/src/compute/private_serve/tests.rs b/crates/volparossa/src/compute/private_serve/tests.rs index 9c74c0cd1..271024005 100644 --- a/crates/volparossa/src/compute/private_serve/tests.rs +++ b/crates/volparossa/src/compute/private_serve/tests.rs @@ -8,6 +8,48 @@ const FIRST: &str = "01010101010101010101010101010101"; const SECOND: &str = "02020202020202020202020202020202"; const THIRD: &str = "03030303030303030303030303030303"; +#[test] +fn native_backend_advertises_restriction_and_rejects_unselected_policy_before_start() { + let root = tempfile::tempdir().unwrap(); + let mut config = config(root.path()); + config.model_profile = ModelProfile::Qwen4bInstruct2507; + // Admission-only fixture: no executable backend or model exists here. + config.native_backend.native_backend_root = Some(root.path().join("unused-native")); + config.native_backend.native_backend_sha256 = Some("a".repeat(64)); + let config = Arc::new(config); + let gate = Arc::new(Semaphore::new(1)); + let mut active = None; + let mut handshakes = Handshakes::default(); + for (operation, field, expected) in [ + (json!({"type":"capabilities"}), "qa_supported", json!(false)), + ( + json!({"type":"conversation_capabilities","generation_policy_version":1}), + "required_generation_policy", + json!("greedy_v1"), + ), + ] { + let request = serde_json::from_value(request(FIRST, operation)).unwrap(); + let reply = respond(request, &config, &gate, &mut active, &mut handshakes); + assert_eq!(reply["capabilities"][field], expected); + assert_eq!( + reply["capabilities"]["inference_backend"], + "llama_cpp_bf16_v1" + ); + } + for operation in [ + json!({"type":"submit","question":"Hello?","context":"Context"}), + json!({"type":"submit_conversation","conversation":{"version":1, + "visibility":"private_local","instructions":"Reply.","history":[ + {"type":"message","role":"user","text":"Hello"}],"tools":[]}}), + ] { + let request = serde_json::from_value(request(SECOND, operation)).unwrap(); + let reply = respond(request, &config, &gate, &mut active, &mut handshakes); + assert_eq!(reply, wire::error(Some(SECOND), "invalid_request")); + assert!(active.is_none()); + assert_eq!(gate.available_permits(), 1); + } +} + #[test] fn execution_error_negotiation_rejects_unknown_null_and_duplicate_versions() { for version in [ @@ -325,6 +367,7 @@ fn request(id: &str, operation: Value) -> Value { fn config(root: &std::path::Path) -> private_task::ExecutionConfig { private_task::ExecutionConfig { + native_backend: crate::compute::native_cpu::Options::default(), runtime_root: root.join("runtime"), model_root: root.join("model"), work_parent: root.join("work"), diff --git a/crates/volparossa/src/compute/private_task.rs b/crates/volparossa/src/compute/private_task.rs index d19be7702..c9a7dde15 100644 --- a/crates/volparossa/src/compute/private_task.rs +++ b/crates/volparossa/src/compute/private_task.rs @@ -49,6 +49,7 @@ pub(super) struct ExecutionConfig { pub model_root: PathBuf, pub work_parent: PathBuf, pub model_profile: ModelProfile, + pub native_backend: super::native_cpu::Options, pub threads: u16, pub max_seconds: u16, } @@ -60,6 +61,7 @@ impl From<&Options> for ExecutionConfig { model_root: args.model_root.clone(), work_parent: args.work_parent.clone(), model_profile: args.model_profile, + native_backend: super::native_cpu::Options::default(), threads: args.threads, max_seconds: args.max_seconds, } @@ -75,6 +77,13 @@ impl ExecutionConfig { super::private_directory(&self.runtime_root)?; super::private_directory(&self.model_root)?; super::private_directory(&self.work_parent)?; + self.native_backend.validate(self.model_profile)?; + if let Some(root) = &self.native_backend.native_backend_root { + ensure!( + !self.work_parent.starts_with(root) && !root.starts_with(&self.work_parent), + "compute_native_backend_work_overlap" + ); + } Ok(()) } } @@ -181,6 +190,8 @@ impl Staged { } fn from_mode(args: &ExecutionConfig, input: &[u8], mode: Mode) -> Result { + args.native_backend + .validate_input(mode, input, args.model_profile)?; super::private_directory(&args.work_parent)?; let directory = tempfile::Builder::new() .prefix("private-task-") @@ -200,6 +211,7 @@ impl Staged { runtime_root: args.runtime_root.clone(), model_root: args.model_root.clone(), model_profile: args.model_profile, + native_backend: args.native_backend.clone(), adapter_root: None, dataset, output: directory.path().join("output"), diff --git a/crates/volparossa/src/compute/sandbox.rs b/crates/volparossa/src/compute/sandbox.rs index c9909f115..db4d45870 100644 --- a/crates/volparossa/src/compute/sandbox.rs +++ b/crates/volparossa/src/compute/sandbox.rs @@ -48,6 +48,9 @@ fn worker_source() -> String { let qwen = serde_json::json!(include_str!( "../../../../workers/volparossa-ml/qwen_conversation.py" )); + let native_cpu = serde_json::json!(include_str!( + "../../../../workers/volparossa-ml/llama_cpu.py" + )); format!( "import sys as _vp_sys, types as _vp_types\n\ _vp_decoder = _vp_types.ModuleType('volparossa_task_graph_decoder')\n\ @@ -61,7 +64,10 @@ fn worker_source() -> String { _vp_sys.modules['volparossa_conversation'] = _vp_conversation\n\ _vp_qwen = _vp_types.ModuleType('volparossa_qwen_conversation')\n\ exec({qwen}, _vp_qwen.__dict__)\n\ - _vp_sys.modules['volparossa_qwen_conversation'] = _vp_qwen\n{}", + _vp_sys.modules['volparossa_qwen_conversation'] = _vp_qwen\n\ + _vp_native_cpu = _vp_types.ModuleType('volparossa_llama_cpu')\n\ + _vp_sys.modules['volparossa_llama_cpu'] = _vp_native_cpu\n\ + exec({native_cpu}, _vp_native_cpu.__dict__)\n{}", include_str!("../../../../workers/volparossa-ml/worker.py") ) } @@ -70,6 +76,9 @@ fn worker_source() -> String { #[allow(clippy::too_many_lines)] pub(super) fn command(options: &Options) -> Command { let mut command = Command::new("/usr/bin/bwrap"); + if let Some(root) = &options.native_backend.native_backend_root { + command.arg("--ro-bind").arg(root).arg("/native-backend"); + } if let Some(adapter) = &options.adapter_root { command.arg("--ro-bind").arg(adapter).arg("/adapter"); } @@ -189,11 +198,50 @@ pub(super) fn command(options: &Options) -> Command { mod tests { use super::{SOURCE_ARGUMENT_BYTES, WORKER_BOOTSTRAP, source_arguments, worker_source}; + #[test] + fn native_backend_has_only_the_explicit_readonly_mount_and_fixed_bundled_code() { + let mut options = super::Options { + mode: crate::compute::Mode::PrivateConversation, + runtime_root: "/owner/runtime".into(), + model_root: "/owner/model".into(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: crate::compute::native_cpu::Options::default(), + adapter_root: None, + dataset: "/owner/input".into(), + output: "/owner/output".into(), + steps: 1, + threads: 2, + max_seconds: 600, + spare_capacity: true, + execute: true, + }; + let arguments = |options: &super::Options| -> Vec { + super::command(options) + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + }; + let legacy = arguments(&options); + assert!(!legacy.iter().any(|arg| arg == "/native-backend")); + options.native_backend.native_backend_root = Some("/owner/native".into()); + options.native_backend.native_backend_sha256 = Some("a".repeat(64)); + let native = arguments(&options); + assert_eq!( + &native[..3], + ["--ro-bind", "/owner/native", "/native-backend"] + ); + assert_eq!(&native[3..], legacy); + assert!(worker_source().contains("ModuleType('volparossa_llama_cpu')")); + assert!(native.iter().any(|arg| arg == "--unshare-net")); + } + #[test] fn kernel_cpu_limit_covers_only_the_authorized_wall_window_and_worker_threads() { for threads in [1, 2] { for seconds in [1, 600] { let options = super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: crate::compute::Mode::PrivateConversation, runtime_root: "/runtime-fixture".into(), model_root: "/model-fixture".into(), diff --git a/crates/volparossa/src/compute/supervise.rs b/crates/volparossa/src/compute/supervise.rs index fe9df1b6f..7a74235a5 100644 --- a/crates/volparossa/src/compute/supervise.rs +++ b/crates/volparossa/src/compute/supervise.rs @@ -401,6 +401,15 @@ fn check_public_contract(value: &Value, options: &Options) -> Result<()> { } fn check_model_backend(value: &Value, request: &WorkerRequest) -> Result<()> { + request.native_backend.check_report(value)?; + if request.native_backend.enabled() { + ensure!( + request.model_profile == super::ModelProfile::Qwen4bInstruct2507 + && request.mode == Mode::PrivateConversation, + "compute_native_backend_scope" + ); + return Ok(()); + } if matches!( request.model_profile, super::ModelProfile::Smol1700 @@ -1175,6 +1184,7 @@ mod tests { } let options = Options { mode: Mode::AggregateAdapter, + native_backend: super::super::native_cpu::Options::default(), model_profile: crate::compute::ModelProfile::default(), runtime_root: root.path().join("unused-runtime"), model_root: root.path().join("unused-model"), @@ -1208,6 +1218,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let options = Options { mode: Mode::PlanTasks, + native_backend: super::super::native_cpu::Options::default(), model_profile: crate::compute::ModelProfile::default(), runtime_root: directory.path().join("unused-runtime"), model_root: directory.path().join("unused-model"), @@ -1253,6 +1264,7 @@ mod tests { // Inert result-contract inputs, not model execution or answer-quality evidence. for mode in [Mode::Infer, Mode::Train] { let request = WorkerRequest { + native_backend: super::super::native_cpu::Request::default(), version: 1, id: "abc".into(), mode, @@ -1290,6 +1302,7 @@ mod tests { fn larger_profile_requires_actual_precision_and_distinguishes_tokenizer_only_work() { // Inert protocol evidence only; this does not instantiate a model. let mut request = WorkerRequest { + native_backend: super::super::native_cpu::Request::default(), version: 1, id: "abc".into(), mode: Mode::Infer, diff --git a/crates/volparossa/src/compute/train_cycle.rs b/crates/volparossa/src/compute/train_cycle.rs index 1bb65878d..a2bf7e05c 100644 --- a/crates/volparossa/src/compute/train_cycle.rs +++ b/crates/volparossa/src/compute/train_cycle.rs @@ -674,6 +674,7 @@ fn worker_options(args: &Options, expires: u64, time: u64) -> Result 0) .context("train_cycle_source_expired")?; Ok(super::Options { + native_backend: super::native_cpu::Options::default(), model_profile: super::ModelProfile::default(), mode: Mode::Train, runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/aggregate.rs b/crates/volparossa/src/compute/train_loop/aggregate.rs index 833a8a28c..74af9c2d6 100644 --- a/crates/volparossa/src/compute/train_loop/aggregate.rs +++ b/crates/volparossa/src/compute/train_loop/aggregate.rs @@ -970,6 +970,7 @@ async fn execute_selected( .min(u64::from(args.max_seconds)), )?; let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: super::super::Mode::AggregateAdapter, model_profile: super::super::ModelProfile::default(), runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/peer_evaluation.rs b/crates/volparossa/src/compute/train_loop/peer_evaluation.rs index f6636e153..6cab0735f 100644 --- a/crates/volparossa/src/compute/train_loop/peer_evaluation.rs +++ b/crates/volparossa/src/compute/train_loop/peer_evaluation.rs @@ -250,6 +250,7 @@ async fn execute_stage( .context("peer_comparison_source_expired")? .min(u64::from(args.max_seconds)); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), model_profile: super::super::ModelProfile::default(), mode: super::super::Mode::Infer, runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/validation.rs b/crates/volparossa/src/compute/train_loop/validation.rs index beb749372..004c74ee9 100644 --- a/crates/volparossa/src/compute/train_loop/validation.rs +++ b/crates/volparossa/src/compute/train_loop/validation.rs @@ -314,6 +314,7 @@ async fn run_stage( .context("train_validation_source_expired")? .min(u64::from(args.max_seconds)); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), model_profile: super::super::ModelProfile::default(), mode: super::super::Mode::Infer, runtime_root: args.runtime_root.clone(), diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 05568e889..adb502272 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -15,6 +15,52 @@ Last updated: 2026-10-04 ## Current integration and active work +### Explicit native CPU inference candidate + +- [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the + existing isolated private-conversation worker. The source-only build of pinned + llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. This is **not yet + an actual model-conversion, generation-speed or successful OpenCode trial**. + The original timed-out 4B result remains failed in the evidence below. + This first backend accepts only the original Qwen3-4B-Instruct-2507 profile and + explicitly negotiated `greedy_v1`; public jobs, training and other profiles + retain their existing backend. It does not establish confidential peer execution. + +The fixed owned native ABI receives the complete original tokenizer-rendered +prompt in bounded prefill batches, not a shortened prompt or smaller/quantized +model. Conversion must compare every original tensor value; BF16 matrices stay +BF16, while required norm tensors are exactly promoted to F32 and reported as +`bf16_with_exact_f32_norms`. AVX2, FMA and F16C are explicit CPU requirements. +An owner-supplied manifest digest binds the native library and converted model; +peer data cannot choose executable code. Artifact verification precedes loading +inside the unchanged network-isolated sandbox. Deadlines, owner-priority controls, +cleanup confirmation and original model-result checks remain required. + +Six inert Rust authorization/result-binding checks, fifteen private-service tests +and five sandbox tests pass. They cover absent legacy fields, restricted native +admission and cancellation/cleanup contracts, not real model performance. Actual +conversion, complete inference and the unchanged native OpenCode coding scenario +must pass before this path is described as working end to end. + +The definitive normal source-only build records library SHA-256 +`4af8d584349d184049c5f6ffea3e5618839c69536676a6bb5c12ef33b03755a7` +and build-manifest SHA-256 +`d51c06798cae305622cf91089692c323cc31c1af8ff63420b270aaf44830d27c`. +Nineteen native Python contract checks pass, along with the separately targeted +existing worker/provision/conversation checks. These tests load no actual model. + +Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke +passes, but the unchanged upstream all-type tensor test **fails** under UBSan on +an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is +outside this BF16-only candidate, but the full test is not waived or relabeled. +Its original log SHA-256 is +`37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08`. +A separate ASan/UBSan target reuses unchanged upstream F32/BF16 test functions +and tolerances; its 18 F32 vector cases and three BF16 checks pass (log SHA-256 +`6fac1d1773ae750583092ed00014e40983d319811a67afea385cc014fe514e46`). +This is scoped kernel evidence, not a passing complete upstream suite or model +inference. Sanitized build artifacts cannot be provisioned as production models. + ### Bank and Transaction layer research The 2026-10-04 user extension adds VOLPAROSSA Bank and a reusable Transaction-layer. diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py new file mode 100644 index 000000000..f2167ba0e --- /dev/null +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Explicit source-only builder. No downloads, package installs or model execution. + +Input must be the clean original commit; outputs are always a new directory. +The resulting build.json is provenance, not independent installation authority. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import time + +SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" +ORIGIN = "https://github.com/ggml-org/llama.cpp.git" +KIND = "llama_cpp_bf16_v1" +HERE = Path(__file__).resolve().parent +LIBRARY = "libvolparossa_llama_cpu.so" +ALLOWED_NEEDED = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "ld-linux-x86-64.so.2"} +CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-bf16-tensors", "vp-upstream-tensor-tests") + + +def require(condition, code): + if not condition: + raise ValueError(code) + + +def digest(path): + meta = path.lstat() + require(stat.S_ISREG(meta.st_mode) and meta.st_nlink == 1, "NATIVE_REGULAR_FILE_REQUIRED") + result = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + result.update(block) + return {"bytes": meta.st_size, "sha256": result.hexdigest()} + + +def command(args, cwd=None, timeout=30): + return subprocess.run(args, cwd=cwd, check=True, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout).stdout + + +def verify_source(source): + require(source.is_absolute() and not source.is_symlink() and source.is_dir(), "NATIVE_SOURCE_PATH") + require(command(["git", "rev-parse", "HEAD"], source).decode().strip() == SOURCE, "NATIVE_SOURCE_PIN") + require(command(["git", "remote", "get-url", "origin"], source).decode().strip() == ORIGIN, + "NATIVE_SOURCE_ORIGIN") + require(not command(["git", "status", "--porcelain", "--untracked-files=all"], source), + "NATIVE_SOURCE_DIRTY") + return command(["git", "rev-parse", "HEAD^{tree}"], source).decode().strip() + + +def dependencies(raw): + require(not re.search(r"\((?:RPATH|RUNPATH)\)", raw), "NATIVE_DYNAMIC_SEARCH_PATH") + names = re.findall(r"\(NEEDED\).*?\[([^\]]+)\]", raw) + require(names and len(names) == len(set(names)) and set(names) <= ALLOWED_NEEDED, + "NATIVE_UNAPPROVED_DYNAMIC_DEPENDENCY") + return sorted(names) + + +def write_json(path, value): + with path.open("x", encoding="ascii") as stream: + json.dump(value, stream, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + + +def run_build(args, log, env, timeout): + process = subprocess.Popen(["nice", "-n", "19", *args], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, env=env, start_new_session=True) + try: + require(process.wait(timeout=timeout) == 0, "NATIVE_SOURCE_BUILD_FAILED") + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def build(source, output, execute=False, checks=False, sanitize=False): + tree = verify_source(source) + require(output.is_absolute() and not output.exists() and not output.is_symlink(), "NATIVE_FRESH_OUTPUT") + require(not output.is_relative_to(source) and not source.is_relative_to(output), "NATIVE_SOURCE_OUTPUT_OVERLAP") + for tool in ("cmake", "c++", "cc", "readelf", "nice"): + require(shutil.which(tool), "NATIVE_BUILD_TOOL_MISSING") + require(not sanitize or checks, "NATIVE_SANITIZER_REQUIRES_CHECKS") + plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, + "sanitizers": sanitize, "provisionable": not sanitize, + "jobs": 2, "models_loaded": False, "downloads": False, "install": False} + if not execute: + return plan + output.mkdir(mode=0o700) + started = time.monotonic() + env = dict(os.environ) + # Avoid inherited compiler/linker/pkgconfig overrides or arbitrary preload code. + for key in list(env): + if key.startswith(("LD_", "CMAKE_")) or key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "CPATH", "LIBRARY_PATH", "PKG_CONFIG_PATH"): + env.pop(key) + epoch = command(["git", "show", "-s", "--format=%ct", SOURCE], source).decode().strip() + require(re.fullmatch(r"[0-9]{10}", epoch), "NATIVE_SOURCE_TIMESTAMP") + env.update(SOURCE_DATE_EPOCH=epoch, CMAKE_BUILD_PARALLEL_LEVEL="2") + commands = [ + ["cmake", "-S", str(HERE / "native-cpu"), "-B", str(output / "build"), + "-DVP_LLAMA_SOURCE=" + str(source), "-DCMAKE_BUILD_TYPE=Release", + "-DVP_NATIVE_TESTS=" + ("ON" if checks else "OFF"), + "-DVP_NATIVE_SANITIZE=" + ("ON" if sanitize else "OFF")], + ["cmake", "--build", str(output / "build"), "--target", "volparossa_llama_cpu", + *(CHECK_TARGETS if checks else []), "--parallel", "2"], + ] + with (output / "build.log").open("xb") as log: + for args in commands: + remaining = 1800 - (time.monotonic() - started) + require(remaining > 0, "NATIVE_BUILD_DEADLINE") + run_build(args, log, env, remaining) + require(verify_source(source) == tree, "NATIVE_SOURCE_CHANGED") + candidates = list((output / "build").rglob(LIBRARY)) + require(len(candidates) == 1, "NATIVE_LIBRARY_OUTPUT") + library = output / LIBRARY + shutil.copyfile(candidates[0], library) + library.chmod(0o444) + dynamic = command(["readelf", "--dynamic", str(library)]).decode("ascii") + if sanitize: + require(not re.search(r"\((?:RPATH|RUNPATH)\)", dynamic), "NATIVE_DYNAMIC_SEARCH_PATH") + needed = sorted(re.findall(r"\(NEEDED\).*?\[([^\]]+)\]", dynamic)) + require(set(needed) <= ALLOWED_NEEDED | {"libasan.so.8", "libubsan.so.1"}, "NATIVE_SANITIZER_DEPENDENCIES") + else: + needed = dependencies(dynamic) + if checks: + check_env = dict(env, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1", UBSAN_OPTIONS="halt_on_error=1") + with (output / "checks.log").open("xb") as log: + for target in CHECK_TARGETS: + matches = list((output / "build").rglob(target)) + require(len(matches) == 1 and matches[0].is_file(), "NATIVE_CHECK_TARGET") + run_build([str(matches[0])], log, check_env, min(120, 1800 - (time.monotonic() - started))) + licenses = output / "licenses" + licenses.mkdir(mode=0o700) + # Preserve all tracked notices, not only the top-level MIT declaration. + tracked = command(["git", "ls-files", "-z"], source).decode().split("\0") + for name in tracked: + if name and ("LICENSE" in Path(name).name.upper() or "COPYING" in Path(name).name.upper() + or "NOTICE" in Path(name).name.upper()): + original = source / name + if original.is_file() and not original.is_symlink(): + target = licenses / name + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + shutil.copyfile(original, target) + target.chmod(0o444) + runtime_archives = {} + for compiler, name in (("c++", "libstdc++.a"), ("cc", "libgcc.a"), ("cc", "libgcc_eh.a")): + path = Path(command([compiler, "-print-file-name=" + name]).decode().strip()).resolve(strict=True) + runtime_archives[name] = digest(path) + compiler_major = command(["c++", "-dumpversion"]).decode().strip() + require(re.fullmatch(r"[0-9]{1,2}", compiler_major), "NATIVE_TOOLCHAIN_NOTICE") + runtime_notice = Path("/usr/share/doc") / ("gcc-" + compiler_major + "-base") / "copyright" + require(runtime_notice.is_file(), "NATIVE_TOOLCHAIN_NOTICE") + shutil.copyfile(runtime_notice, licenses / "GCC-runtime-copyright") + (licenses / "GCC-runtime-copyright").chmod(0o444) + report = dict(plan, version=1, abi_version=1, library={"path": LIBRARY, **digest(library)}, + dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, + checks=list(CHECK_TARGETS) if checks else [], + known_upstream_sanitizer_failure={ + "scope": "all_type_synthetic_tensor_test", "kind": "ubsan_misaligned_load", + "source": "ggml/src/ggml-cpu/arch/x86/quants.c:590", + "function": "ggml_vec_dot_q1_0_q8_0", "original_log_bytes": 412, + "original_log_sha256": "37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08"}, + static_runtime_archives=runtime_archives, runtime_notice=digest(runtime_notice), + wrapper_sources={name: digest(HERE / "native-cpu" / name) + for name in ("CMakeLists.txt", "adapter.h", "adapter.cpp")}, + tool_versions={tool: command([tool, "--version"]).decode().splitlines()[0] + for tool in ("cmake", "c++", "cc")}) + write_json(output / "build.json", report) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--checks", action="store_true") + parser.add_argument("--sanitize", action="store_true") + args = parser.parse_args() + try: + print(json.dumps(build(args.source, args.output, args.execute, args.checks, args.sanitize), sort_keys=True)) + except (ValueError, OSError, subprocess.SubprocessError): + parser.exit(1, "NATIVE_CPU_BUILD_FAILED\n") + + +if __name__ == "__main__": + main() diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py new file mode 100644 index 000000000..9ab35d12b --- /dev/null +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Offline, disposable-guest BF16 conversion with full original-value comparison. + +No model inference, network fetch, quantization, renamed weights digest or host +installation. The original pinned runtime, source checkout and source-built +adapter must already exist. A fresh owner-authorized artifact is produced only +after every original tensor and the tokenizer/template binding have been checked. +""" +import argparse +import hashlib +import importlib.metadata +import json +import math +import os +from pathlib import Path +import re +import shutil +import struct +import sys +import time + +import build_llama_cpu as builder +import llama_cpu as native +import provision + +HERE = Path(__file__).resolve().parent + + +def tensor_name(name): + exact = {"model.embed_tokens.weight": "token_embd.weight", "model.norm.weight": "output_norm.weight"} + if name in exact: + return exact[name] + match = re.fullmatch(r"model\.layers\.([0-9]+)\.(.+)\.weight", name) + builder.require(match is not None and 0 <= int(match[1]) < 36, "NATIVE_TENSOR_NAME") + suffixes = {"input_layernorm": "attn_norm", "post_attention_layernorm": "ffn_norm", + "self_attn.q_proj": "attn_q", "self_attn.k_proj": "attn_k", "self_attn.v_proj": "attn_v", + "self_attn.o_proj": "attn_output", "self_attn.q_norm": "attn_q_norm", + "self_attn.k_norm": "attn_k_norm", "mlp.gate_proj": "ffn_gate", + "mlp.up_proj": "ffn_up", "mlp.down_proj": "ffn_down"} + builder.require(match[2] in suffixes, "NATIVE_TENSOR_NAME") + return f"blk.{int(match[1])}.{suffixes[match[2]]}.weight" + + +def header(path): + with path.open("rb") as stream: + raw = stream.read(8) + builder.require(len(raw) == 8, "NATIVE_SAFETENSORS_HEADER") + length = struct.unpack("= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") + root.mkdir(mode=0o700) + output = root / "model.gguf" + env = dict(os.environ, HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", HF_DATASETS_OFFLINE="1", + HF_HUB_DISABLE_TELEMETRY="1", OMP_NUM_THREADS="2", MKL_NUM_THREADS="2", + TOKENIZERS_PARALLELISM="false") + for key in list(env): + if key.startswith("LD_") or key in ("PYTHONPATH", "PYTHONHOME"): + env.pop(key) + with (root / "conversion.log").open("xb") as log: + builder.run_build([sys.executable, str(args.source / "convert_hf_to_gguf.py"), str(model), + "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic()) + check() + sys.path.insert(0, str(args.source / "gguf-py")) + import gguf + from transformers import AutoTokenizer + tokenizer = AutoTokenizer.from_pretrained(str(model), local_files_only=True, trust_remote_code=False, use_fast=True) + verified = verify_conversion(gguf.GGUFReader(str(output)), model, tokenizer, check) + check() + provision.verify_weight_set(pins, model, deadline) + shutil.copyfile(library, root / native.LIBRARY) + shutil.copyfile(build_root / "build.json", root / "build.json") + shutil.copytree(build_root / "licenses", root / "licenses", symlinks=False) + shutil.copyfile(model / "LICENSE", root / "licenses" / "Qwen-APACHE-2.0.txt") + manifest = {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": builder.SOURCE, + "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, + "source_weights_bytes": native.WEIGHTS_BYTES, + "library": {"path": native.LIBRARY, **builder.digest(root / native.LIBRARY)}, + "gguf": {"path": "model.gguf", **builder.digest(output)}, "verification": verified, + "build_manifest_sha256": hashlib.sha256(build_raw).hexdigest()} + native.validate_manifest(manifest, builder.require) + retained = sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) + builder.require(retained + 65536 <= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") + check() + builder.write_json(root / "backend.json", manifest) + private_bundle_modes(root) + return {"version": 1, "kind": native.KIND, "manifest": builder.digest(root / "backend.json"), + "model_inference": False, "downloads": False} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--build", type=Path, required=True) + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--root", required=True) + parser.add_argument("--budget-bytes", type=int, required=True) + parser.add_argument("--timeout-seconds", type=int, default=1800) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--yes", action="store_true") + parser.add_argument("--disposable-guest", action="store_true") + args = parser.parse_args() + if not args.execute: + print(json.dumps({"kind": native.KIND, "source_commit": builder.SOURCE, "model_profile": native.PROFILE, + "execute": False, "downloads": False, "model_inference": False})) + return + try: + print(json.dumps(execute(args), sort_keys=True)) + except Exception: + parser.exit(1, "NATIVE_CONVERSION_FAILED\n") + + +if __name__ == "__main__": + main() diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py new file mode 100644 index 000000000..3583d03b4 --- /dev/null +++ b/workers/volparossa-ml/llama_cpu.py @@ -0,0 +1,271 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Opt-in, core-owned BF16 CPU inference; no HTTP, downloads, tools or training. + +ctypes sees only our scalar/opaque ABI. Upstream structs stay inside the compiled +adapter. Artifact hashes are checked against the owner's separately supplied +backend.json digest before loading any native code. Conversion metadata is not +peer executable authority. The supervisor still supplies read-only mounts and +hard limits, and confirms process cleanup independently. +""" +import ctypes +import hashlib +import json +from pathlib import Path +import re +import stat +import threading + +KIND = "llama_cpp_bf16_v1" +SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" +PROFILE = "qwen3-4b-instruct-2507-v1" +LIBRARY = "libvolparossa_llama_cpu.so" +WEIGHTS_SHA = "79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf" +WEIGHTS_BYTES = 8044982000 +HEX = re.compile(r"[0-9a-f]{64}\Z") +ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) + + +def validate_manifest(value, require): + require(type(value) is dict and value.keys() == {"version", "kind", "abi_version", "source_commit", + "model_profile", "source_weights_sha256", "source_weights_bytes", "library", "gguf", + "verification", "build_manifest_sha256"}, "NATIVE_MANIFEST_FIELDS") + require(type(value["version"]) is int and value["version"] == 1 + and type(value["abi_version"]) is int and value["abi_version"] == 1 + and value["kind"] == KIND and value["source_commit"] == SOURCE + and value["model_profile"] == PROFILE and value["source_weights_sha256"] == WEIGHTS_SHA + and type(value["source_weights_bytes"]) is int and value["source_weights_bytes"] == WEIGHTS_BYTES, + "NATIVE_MANIFEST_BINDING") + for field, name, maximum in (("library", LIBRARY, 128 * 1024 * 1024), + ("gguf", "model.gguf", 9 * 1024 ** 3)): + item = value[field] + require(type(item) is dict and item.keys() == {"path", "sha256", "bytes"} + and item["path"] == name and type(item["sha256"]) is str and HEX.fullmatch(item["sha256"]) + and type(item["bytes"]) is int and 0 < item["bytes"] <= maximum, + "NATIVE_ARTIFACT_BINDING") + checked = value["verification"] + require(type(checked) is dict and checked.keys() == {"tensor_count", "tensor_values_equal", "tokenizer_equal", + "chat_template_sha256"} + and type(checked["tensor_count"]) is int and checked["tensor_count"] == 398 + and checked["tensor_values_equal"] is True and checked["tokenizer_equal"] is True + and type(checked["chat_template_sha256"]) is str and HEX.fullmatch(checked["chat_template_sha256"]) + and type(value["build_manifest_sha256"]) is str and HEX.fullmatch(value["build_manifest_sha256"]), + "NATIVE_CONVERSION_BINDING") + return value + + +def verified_bundle(request, worker, tokenizer): + require = worker.require + require(request.get("inference_backend") == KIND and request.get("model_profile") == PROFILE + and request["mode"] == "private_conversation", "NATIVE_BACKEND_SCOPE") + root, meta = worker.plain_path(request["native_backend_root"], True) + require(not stat.S_IMODE(meta.st_mode) & 0o022, "NATIVE_BACKEND_WRITABLE") + expected = request["native_backend_sha256"] + require(type(expected) is str and HEX.fullmatch(expected), "NATIVE_MANIFEST_DIGEST") + raw = worker.read_bounded(root / "backend.json", 16384) + require(hashlib.sha256(raw).hexdigest() == expected, "NATIVE_MANIFEST_DIGEST") + value = validate_manifest(worker.parse_json(raw), require) + for field in ("library", "gguf"): + item = value[field] + require(worker.file_hash(root / item["path"], expected_size=item["bytes"])["sha256"] == item["sha256"], + "NATIVE_ARTIFACT_DIGEST") + build = worker.read_bounded(root / "build.json", 65536) + require(hashlib.sha256(build).hexdigest() == value["build_manifest_sha256"], "NATIVE_BUILD_DIGEST") + provenance = worker.parse_json(build) + require(type(provenance) is dict and provenance.get("version") == 1 + and provenance.get("kind") == KIND and provenance.get("source_commit") == SOURCE + and provenance.get("abi_version") == 1 and provenance.get("cpu") == "avx2_fma_f16c" + and provenance.get("provisionable") is True and provenance.get("sanitizers") is False + and provenance.get("quantization") is False and provenance.get("library") == value["library"], + "NATIVE_BUILD_BINDING") + require(type(tokenizer.chat_template) is str and hashlib.sha256(tokenizer.chat_template.encode()).hexdigest() + == value["verification"]["chat_template_sha256"], "NATIVE_TEMPLATE_BINDING") + identity = dict(kind=KIND, abi_version=1, source_commit=SOURCE, manifest_sha256=expected, + library_sha256=value["library"]["sha256"], gguf_sha256=value["gguf"]["sha256"], + gguf_bytes=value["gguf"]["bytes"], source_weights_sha256=WEIGHTS_SHA) + return root, value, identity + + +class OwnerProbe: + """No exception/ACK crosses C. Pause waits for the current bounded decode. + + Frames retain their exact bytes/order until the native operation has joined. + Then Session.check processes them and acknowledges stopped execution normally. + Cancellation and deadline can abort native work; that handle is never resumed. + """ + def __init__(self, session, worker): + self.session, self.worker = session, worker + self.pending = [] + self.error = None + self.lock = threading.Lock() + self.expected = session.sequence + self.callback = ABORT(self.poll) + + def poll(self, _opaque=None): + with self.lock: + if self.error is not None: + return 1 + try: + self.session.budget() + frames = self.session.frames + while frames is not None: + raw = frames.control(0) + if raw is None: + break + value = self.worker.parse_json(raw) + self.worker.require(type(value) is dict and value.keys() == {"version", "id", "sequence", "action"} + and type(value["version"]) is int and value["version"] == 1 + and value["id"] == self.session.request["id"] + and type(value["sequence"]) is int and value["sequence"] == self.expected + 1 + and value["sequence"] <= self.worker.MAX_CONTROLS + and type(value["action"]) is str + and value["action"] in ("pause", "resume", "cancel"), "INVALID_NATIVE_CONTROL") + self.expected = value["sequence"] + self.worker.require(value["action"] != "cancel", "JOB_CANCELLED") + self.pending.append(raw) + self.session.budget() + except BaseException as error: + # BaseException too: ctypes must never swallow a Python exception + # and accidentally return an unspecified value to native code. + self.error = (error if isinstance(error, self.worker.JobError) + else self.worker.JobError("NATIVE_OWNER_CONTROL_FAILED")) + return 1 + return 0 + + def joined(self): + if self.error is not None: + raise self.error + if self.pending: + self.session.frames.pending[:0] = b"".join(self.pending) + self.pending.clear() + self.session.check() + self.expected = self.session.sequence + + +class NativeModel: + def __init__(self, root, capacity, threads, probe, require): + self.handle = ctypes.c_void_p() + self.require, self.probe = require, probe + # The caller checked owner-authorized digest before this executable load. + self.lib = ctypes.CDLL(str(root / LIBRARY), mode=0) + signatures = { + "vp_llama_abi_v1": (ctypes.c_uint32, []), + "vp_llama_source_v1": (ctypes.c_char_p, []), + "vp_llama_open_v1": (ctypes.c_int32, [ctypes.c_char_p, ctypes.c_uint32, ctypes.c_uint32, + ABORT, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]), + "vp_llama_decode_v1": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32), ctypes.c_uint32]), + "vp_llama_sample_v1": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32)]), + "vp_llama_close_v1": (None, [ctypes.c_void_p]), + } + for name, (result, arguments) in signatures.items(): + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + require(self.lib.vp_llama_abi_v1() == 1 and self.lib.vp_llama_source_v1() == SOURCE.encode(), + "NATIVE_ABI_MISMATCH") + try: + status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, + probe.callback, None, ctypes.byref(self.handle)) + probe.joined() + require(status == 0 and self.handle.value is not None, "NATIVE_MODEL_LOAD_FAILED") + except BaseException: + self.close() + raise + + def decode(self, tokens): + self.require(type(tokens) is list and 1 <= len(tokens) <= 128 + and all(type(token) is int and 0 <= token < 151936 for token in tokens), "NATIVE_TOKEN_BOUND") + values = (ctypes.c_int32 * len(tokens))(*tokens) + status = self.lib.vp_llama_decode_v1(self.handle, values, len(tokens)) + self.probe.joined() + self.require(status == 0, "NATIVE_DECODE_FAILED") + + def sample(self): + token = ctypes.c_int32(-1) + status = self.lib.vp_llama_sample_v1(self.handle, ctypes.byref(token)) + self.probe.joined() + self.require(status == 0 and 0 <= token.value < 151936, "NATIVE_SAMPLE_FAILED") + return token.value + + def close(self): + if self.handle.value is not None: + self.lib.vp_llama_close_v1(self.handle) + self.handle = ctypes.c_void_p() + + +def generate(model, prompt, tokenizer, profile, session, worker, observation=None): + worker.require(type(prompt) is list and 1 <= len(prompt) <= profile["prompt_tokens"] + and all(type(token) is int and 0 <= token < 151936 for token in prompt), "NATIVE_PROMPT_BOUND") + if observation is not None: + observation.forward_begin(None, None) + # This is the complete original HF-rendered prompt in bounded prefill batches, + # not truncation, sliding context, a second prompt or a hidden warmup. + for start in range(0, len(prompt), 128): + session.check() + model.decode(prompt[start:start + 128]) + if observation is not None: + observation.forward_complete(None, None, None) + generated = [] + while len(generated) < profile["new_tokens"]: + session.check() + token = model.sample() + generated.append(token) + if observation is not None: + observation.tokens(len(generated)) + if token == tokenizer.eos_token_id: + break + if len(generated) < profile["new_tokens"]: + model.decode([token]) + metadata = worker.generation_metadata(generated, tokenizer.eos_token_id, PROFILE) + visible = generated[:-1] if metadata["stop_reason"] == "eos" else generated + raw = tokenizer.decode(visible, skip_special_tokens=False) + text = raw[:profile["wire_bytes"]] + while len(json.dumps(text, ensure_ascii=True).encode("ascii")) > profile["wire_bytes"]: + text = text[:-1] + if observation is not None: + observation.complete() + return {"sample_index": 0, "text": text, "generated_tokens": len(generated), + "text_truncated": text != raw, "generation": metadata} + + +def execute(request, session, tokenizer, torch, versions, model_root, output_root, + dataset, data_identity, model_files, worker): + worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", + "NATIVE_GREEDY_POLICY_REQUIRED") + profile = worker.model_profile(PROFILE) + root, manifest, identity = verified_bundle(request, worker, tokenizer) + session.private_progress("prompt_encode", "begin") + prompt = worker.conversation_module().encode(tokenizer, dataset, profile) + session.private_progress("prompt_encode", "complete") + session.check() + probe = OwnerProbe(session, worker) + session.private_progress("model_load", "begin") + model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require) + try: + session.private_progress("model_load", "complete") + session.progress("baseline") + session.private_progress("generation", "begin") + observation = (worker.PrivateGenerationObservation(session, torch, len(prompt)) + if request.get("private_generation_diagnostics") is True else None) + output = generate(model, prompt, tokenizer, profile, session, worker, observation) + session.private_progress("generation", "complete") + finally: + model.close() + session.private_progress("verify_after", "begin") + weight_identity = worker.verify_sharded_weights(model_root, profile) + worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"])["sha256"] + == manifest["gguf"]["sha256"], "NATIVE_GGUF_CHANGED") + session.private_progress("verify_after", "complete") + result = {"version": 1, "id": request["id"], "kind": "result", "status": "ok", "mode": request["mode"], + "backend_versions": versions, "device": "cpu", "threads": request["threads"], + "model": {"id": profile["id"], "revision": profile["revision"], "files": model_files, + "weights": weight_identity}, "dataset": data_identity, "outputs": [output], + "updates_completed": 0, "artifacts": [], "model_weights_loaded": True, + "private_data_supported": True, "distributed_execution_claimed": False, + "private_training_claimed": False, "better_answers_claimed": False, "network_policy_changed": False, + "conversation": worker.conversation_module().decode(dataset, output, PROFILE, request["id"]), + "prompt_tokens": len(prompt), "conversation_limits": worker.conversation_module().capabilities(PROFILE, profile), + "generation_policy": "greedy_v1", "model_parameter_dtype": "bf16_with_exact_f32_norms", + "model_attention_backend": "llama_cpp_cpu", "inference_backend": identity} + session.private_progress("result", "begin") + result = worker.finish_result(result, output_root, session) + session.private_progress("result", "complete") + return result diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt new file mode 100644 index 000000000..505a85af7 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: GPL-3.0-only +cmake_minimum_required(VERSION 3.25) +project(volparossa_llama_cpu LANGUAGES C CXX) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux" OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$") + message(FATAL_ERROR "This explicit backend is Linux amd64 only") +endif() +if(NOT DEFINED VP_LLAMA_SOURCE) + message(FATAL_ERROR "A verified local source checkout is required") +endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17) +option(VP_NATIVE_TESTS "Build bounded inert native ABI/upstream tensor checks" OFF) +option(VP_NATIVE_SANITIZE "Instrument source-only check artifacts, never provision them" OFF) +if(VP_NATIVE_SANITIZE) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() +set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +foreach(flag BUILD_SHARED_LIBS LLAMA_BUILD_COMMON LLAMA_BUILD_TESTS LLAMA_BUILD_TOOLS + LLAMA_BUILD_EXAMPLES LLAMA_BUILD_SERVER LLAMA_BUILD_APP LLAMA_BUILD_UI + LLAMA_OPENSSL LLAMA_SUBPROCESS LLAMA_LLGUIDANCE LLAMA_USE_SYSTEM_GGML + GGML_NATIVE GGML_BACKEND_DL GGML_CPU_ALL_VARIANTS GGML_OPENMP GGML_OPENMP_FETCH + GGML_BLAS GGML_CUDA GGML_HIP GGML_METAL GGML_VULKAN GGML_SYCL GGML_RPC + GGML_CPU_KLEIDIAI GGML_CPU_HBM GGML_CPU_REPACK GGML_CCACHE + GGML_SSE42 GGML_BMI2 GGML_AVX_VNNI GGML_AVX512 + GGML_AVX512_VBMI GGML_AVX512_VNNI GGML_AVX512_BF16 GGML_AMX_TILE GGML_AMX_INT8 GGML_AMX_BF16) + set(${flag} OFF CACHE BOOL "Explicit fixed CPU build" FORCE) +endforeach() +set(GGML_AVX ON CACHE BOOL "" FORCE) +set(GGML_AVX2 ON CACHE BOOL "" FORCE) +set(GGML_FMA ON CACHE BOOL "" FORCE) +set(GGML_F16C ON CACHE BOOL "" FORCE) +set(GGML_LLAMAFILE ON CACHE BOOL "" FORCE) +set(GGML_CPU ON CACHE BOOL "" FORCE) +add_subdirectory("${VP_LLAMA_SOURCE}" llama EXCLUDE_FROM_ALL) +add_library(volparossa_llama_cpu SHARED adapter.cpp) +target_link_libraries(volparossa_llama_cpu PRIVATE llama) +target_compile_options(volparossa_llama_cpu PRIVATE -Wall -Wextra -Werror) +target_link_options(volparossa_llama_cpu PRIVATE -static-libstdc++ -static-libgcc + "-Wl,--exclude-libs,ALL" "-Wl,-z,defs" "-Wl,-z,relro,-z,now") +set_target_properties(volparossa_llama_cpu PROPERTIES BUILD_RPATH "" INSTALL_RPATH "") +if(VP_NATIVE_TESTS) + add_executable(vp-native-abi-smoke abi-smoke.cpp) + target_compile_options(vp-native-abi-smoke PRIVATE -UNDEBUG) + target_link_libraries(vp-native-abi-smoke PRIVATE volparossa_llama_cpu) + # Unchanged upstream test, generated toy tensors only. No model files or CLI. + add_executable(vp-upstream-tensor-tests "${VP_LLAMA_SOURCE}/tests/test-quantize-fns.cpp") + target_link_libraries(vp-upstream-tensor-tests PRIVATE ggml ggml-cpu) + # Separate BF16/F32-only proof; never replaces the all-type target above. + add_executable(vp-native-bf16-tensors bf16-tensors.cpp) + target_compile_definitions(vp-native-bf16-tensors PRIVATE + VP_UPSTREAM_TEST_FILE="${VP_LLAMA_SOURCE}/tests/test-quantize-fns.cpp") + target_link_libraries(vp-native-bf16-tensors PRIVATE ggml ggml-cpu) +endif() diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md new file mode 100644 index 000000000..29c9f38d5 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/README.md @@ -0,0 +1,105 @@ +# Explicit native CPU inference candidate + +This is an **opt-in experimental backend**, not a working-model or coding-quality +claim. The legacy PyTorch backend remains unchanged. Only owner-authorized local +private conversations using `qwen3-4b-instruct-2507-v1` and explicit `greedy_v1` +are eligible. This does not enable private execution on arbitrary peers. + +## What is fixed + +- llama.cpp source: `7fe450e19305b828c199d602c23a8337aaa1f03b`, the resolved + `v0.5.0` tag, from `https://github.com/ggml-org/llama.cpp.git` (MIT). The tag is + unsigned; its name is not executable authorization. +- Qwen source: `Qwen/Qwen3-4B-Instruct-2507` at + `cdbee75f17c01a7cc42f958dc650907174af0554` (Apache-2.0). The original three + safetensors shards and their concatenated raw-byte digest remain the model + identity. A GGUF has its **own** measured size and SHA-256. +- CPU only, AVX2 + FMA + F16C explicitly required and checked before backend + initialization. No assumption that AVX2 alone proves the other instructions. + Original upstream fallback builds without FMA/F16C did not compile; no + permissive compiler flags or undocumented source patches were substituted. +- No HTTP server, network downloads, tool execution, GPU offload, quantization, + context shifting, hidden warmup, smaller-model substitution or extra time. + +`adapter.h` is VOLPAROSSA ABI v1. Python passes bounded scalar arrays and opaque +handles, never guessed upstream struct layouts. llama/ggml and the C++ runtime +are statically linked into the owned library; only the checked platform C/math +loader libraries remain dynamic. Arbitrary dynamic search paths are rejected. + +## Source build and conversion + +`../build_llama_cpu.py` consumes a clean local checkout of the exact commit. It +does not acquire source, install dependencies or load a model. Preview is the +default; `--execute` builds at low priority with at most two compiler jobs into +a new output directory. `build.json` retains the source/tree, wrapper hashes, +compiler versions, actual library hash, CPU requirements and dependency list. +Original upstream notices are copied unchanged. +The actual static GCC runtime archives are hashed; their original Debian GCC +copyright/runtime-exception notice is retained alongside upstream notices. + +The optional `--checks --sanitize` build runs an owned inert ABI smoke, a +separately scoped BF16/F32 wrapper, and the unchanged upstream all-type synthetic +tensor test under ASan/UBSan. Such artifacts are marked **not provisionable**. +These are not model or performance tests; the wrapper does not replace or +change the all-type test or its tolerances. + +The original all-type sanitizer check **failed** on a misaligned `uint32_t` +load in upstream `ggml/src/ggml-cpu/arch/x86/quants.c:590`, +`ggml_vec_dot_q1_0_q8_0`. Its retained 412-byte log has SHA-256 +`37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08`. +Q1_0/Q8_0 model tensors are not admitted by this BF16/F32 conversion contract, +but the finding remains unresolved and is recorded in normal build provenance. +The full sanitizer suite must not be described as passing. + +A separate owned wrapper reused the original upstream BF16 conversion, +reference and dot-product functions and tolerances, plus its F32 vector cases. +That narrower ASan/UBSan check passed with no model loaded; its 1,776-byte log +has SHA-256 `6fac1d1773ae750583092ed00014e40983d319811a67afea385cc014fe514e46`. +The original all-type failure and source archives were verified unchanged. + +`../convert_llama_cpu.py` requires the existing explicit disposable guest/CI +guard, original pinned runtime/model files, local pinned source and a matching +normal source build. It uses the upstream offline BF16 converter and then +independently checks all 398 tensor names, shapes and values against the +original shards. Norm/one-dimensional tensors may be promoted from BF16 to +F32 **exactly**, including signed-zero bits; arbitrary F32 matrices, F16, +quantized tensors, changed values or converter fallback are rejected. The +reported precision is `bf16_with_exact_f32_norms`, not a claim that every +stored tensor is BF16. + +The converter also checks model parameters, vocabulary IDs, merges, EOS and the +original chat template. Runtime prompt encoding and output decoding still use +the pinned Hugging Face tokenizer, not an alternate llama.cpp chat template. + +`../provision.py --model-profile qwen3-4b-instruct-2507-v1` accepts the optional +pair `--native-cpu-source ABS --native-cpu-build ABS`. Conversion shares the +**original** provisioning disk budget and deadline; insufficient room or time +is a refusal, not permission to raise limits. The new `native-backend/` artifact +has owner-private directories (`0700`) and read-only files (`0400`). Existing +provisioning without these options retains its original behavior/report shape. + +## Execution and trust + +The owner explicitly supplies `--native-backend-root` and +`--native-backend-sha256`, the hash of the actual `backend.json`. Self-declared +peer metadata cannot authorize executable loading. The core maps the selected +directory read-only; the isolated worker verifies the manifest and complete +library/GGUF/build hashes **before** loading the fixed library. + +All original prompt tokens are supplied in batches of at most 128 tokens; +batching does not truncate or restart the context. The same KV state is retained +across pause/resume. Only actual generated EOS ends a successful generation; +raw model text still passes through the existing strict native tool parser and +owner approvals. A proposal is not permission to edit or run commands. + +Native callbacks buffer bounded original control frames without acknowledging +pause while computation is active. Cancellation/deadline becomes a fixed abort +status; exceptions are propagated only after native execution has joined. Pause +and resume are acknowledged at stopped decode boundaries by the existing owner +protocol. All handles are closed deterministically, and the supervisor retains +its independent hard-budget and process-cleanup authority. + +Actual guest conversion, memory/performance feasibility, real EOS completion and +the unchanged OpenCode read/edit/test scenario still require their own evidence. +Passing a source build, ABI smoke or synthetic tensor test does not establish any +of those outcomes. diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp new file mode 100644 index 000000000..b033a44e1 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +// No model is loaded. Invalid calls and abort-before-file-open exercise our real +// compiled ABI; upstream tensor tests are a separate target, not a model proof. +#include "adapter.h" +#include +#include + +static int32_t abort_immediately(void * opaque) { + ++*static_cast(opaque); + return 1; +} + +int main() { + assert(vp_llama_abi_v1() == 1); + assert(std::strcmp(vp_llama_source_v1(), "7fe450e19305b828c199d602c23a8337aaa1f03b") == 0); + void * handle = nullptr; + int callbacks = 0; + assert(vp_llama_open_v1(nullptr, 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("relative", 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 13313, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 16, 3, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 16, 2, nullptr, nullptr, &handle) == VP_INVALID); + assert(vp_llama_decode_v1(nullptr, nullptr, 0) == VP_INVALID); + assert(vp_llama_sample_v1(nullptr, nullptr) == VP_INVALID); + assert(handle == nullptr && callbacks == 0); + const auto status = vp_llama_open_v1("/never-read-model", 16, 2, abort_immediately, &callbacks, &handle); + // Unsupported instruction set is a legitimate explicit refusal, not a pass + // of model loading. On this capable build host, the callback aborts first. + assert((status == VP_ABORTED && callbacks == 1) || (status == VP_INVALID && callbacks == 0)); + assert(handle == nullptr); + vp_llama_close_v1(nullptr); +} diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp new file mode 100644 index 000000000..9b68aa617 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "adapter.h" +#include "llama.h" +#include +#include +#include +#include + +namespace { +constexpr uint32_t max_context = 12288 + 1024; +constexpr uint32_t max_batch = 128; +constexpr int32_t vocabulary = 151936; +constexpr const char * source = "7fe450e19305b828c199d602c23a8337aaa1f03b"; +struct state { + llama_model * model = nullptr; + llama_context * context = nullptr; + llama_sampler * sampler = nullptr; + vp_abort_v1 abort = nullptr; + void * opaque = nullptr; + uint32_t capacity = 0; + uint32_t used = 0; + bool ready = false; + bool failed = false; + ~state() { + if (context) { llama_synchronize(context); } + if (sampler) { llama_sampler_free(sampler); } + if (context) { llama_free(context); } + if (model) { llama_model_free(model); } + } +}; +bool aborted(void * pointer) { + auto * value = static_cast(pointer); + return value->abort(value->opaque) != 0; +} +bool load_progress(float, void * pointer) { return !aborted(pointer); } +void discard_log(ggml_log_level, const char *, void *) {} +std::once_flag initialized; +} + +extern "C" uint32_t vp_llama_abi_v1(void) { return 1; } +extern "C" const char * vp_llama_source_v1(void) { return source; } + +extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, + uint32_t threads, vp_abort_v1 abort, + void * opaque, void ** handle) { + if (!handle) { return VP_INVALID; } + *handle = nullptr; + if (!path || path[0] != '/' || std::strlen(path) > 4096 || !abort || + capacity < 2 || capacity > max_context || threads < 1 || threads > 2) { + return VP_INVALID; + } +#if defined(__x86_64__) && defined(__GNUC__) + if (!__builtin_cpu_supports("avx2") || !__builtin_cpu_supports("fma") || + !__builtin_cpu_supports("f16c")) { return VP_INVALID; } +#else + return VP_INVALID; +#endif + try { + std::call_once(initialized, [] { + // No private text, paths, tensor names or backend errors on stdout/stderr. + llama_log_set(discard_log, nullptr); + ggml_log_set(discard_log, nullptr); + llama_backend_init(); + }); + auto value = std::make_unique(); + value->abort = abort; + value->opaque = opaque; + value->capacity = capacity; + if (aborted(value.get())) { return VP_ABORTED; } + auto model_params = llama_model_default_params(); + model_params.n_gpu_layers = 0; + model_params.use_extra_bufts = false; + model_params.check_tensors = true; + model_params.progress_callback = load_progress; + model_params.progress_callback_user_data = value.get(); + value->model = llama_model_load_from_file(path, model_params); + if (aborted(value.get())) { return VP_ABORTED; } + if (!value->model) { return VP_BACKEND; } + const auto * vocab = llama_model_get_vocab(value->model); + if (llama_vocab_n_tokens(vocab) != vocabulary || llama_vocab_eos(vocab) != 151645) { + return VP_INVALID; + } + auto context_params = llama_context_default_params(); + context_params.n_ctx = capacity; + context_params.n_batch = max_batch; + context_params.n_ubatch = max_batch; + context_params.n_seq_max = 1; + context_params.n_threads = static_cast(threads); + context_params.n_threads_batch = static_cast(threads); + context_params.type_k = GGML_TYPE_BF16; + context_params.type_v = GGML_TYPE_BF16; + context_params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED; + context_params.offload_kqv = false; + context_params.op_offload = false; + context_params.abort_callback = aborted; + context_params.abort_callback_data = value.get(); + value->context = llama_init_from_model(value->model, context_params); + if (aborted(value.get())) { return VP_ABORTED; } + if (!value->context) { return VP_BACKEND; } + value->sampler = llama_sampler_init_greedy(); + if (!value->sampler) { return VP_BACKEND; } + *handle = value.release(); + return VP_OK; + } catch (...) { return VP_BACKEND; } +} + +extern "C" int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uint32_t count) { + auto * value = static_cast(handle); + if (!value || !tokens || value->failed || !count || count > max_batch || + value->used > value->capacity || count > value->capacity - value->used) { return VP_INVALID; } + for (uint32_t i = 0; i < count; ++i) { + if (tokens[i] < 0 || tokens[i] >= vocabulary) { return VP_INVALID; } + } + try { + value->ready = false; + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + // Upstream's synchronous decode never borrows this array after return. + auto batch = llama_batch_get_one(const_cast(tokens), static_cast(count)); + const auto result = llama_decode(value->context, batch); + llama_synchronize(value->context); + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + if (result != 0) { value->failed = true; return VP_BACKEND; } + value->used += count; + value->ready = true; + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} + +extern "C" int32_t vp_llama_sample_v1(void * handle, int32_t * token) { + auto * value = static_cast(handle); + if (!value || !token || value->failed || !value->ready) { return VP_INVALID; } + try { + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + const auto generated = llama_sampler_sample(value->sampler, value->context, -1); + if (generated < 0 || generated >= vocabulary) { value->failed = true; return VP_BACKEND; } + *token = generated; + value->ready = false; + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} + +extern "C" void vp_llama_close_v1(void * handle) { delete static_cast(handle); } diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h new file mode 100644 index 000000000..99ba07094 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Our versioned ABI, not a copied upstream struct layout. A handle is owned by +// one Python execution thread. Callback exceptions must never cross this ABI. +typedef int32_t (*vp_abort_v1)(void *); +enum vp_status_v1 { VP_OK = 0, VP_INVALID = 1, VP_ABORTED = 2, VP_BACKEND = 3 }; +uint32_t vp_llama_abi_v1(void); +const char * vp_llama_source_v1(void); +int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, + uint32_t threads, vp_abort_v1 abort, void * opaque, + void ** handle); +int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uint32_t count); +int32_t vp_llama_sample_v1(void * handle, int32_t * token); +void vp_llama_close_v1(void * handle); + +#ifdef __cplusplus +} +#endif diff --git a/workers/volparossa-ml/native-cpu/bf16-tensors.cpp b/workers/volparossa-ml/native-cpu/bf16-tensors.cpp new file mode 100644 index 000000000..679e9bcb5 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/bf16-tensors.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +// A deliberately scoped test driver, not a replacement for the unchanged +// all-type upstream test. Reuse its actual functions and tolerances verbatim. +// The original upstream main remains compiled under a different name. +#define main vp_upstream_all_types_main +#include VP_UPSTREAM_TEST_FILE +#undef main + +int main() { + ggml_cpu_init(); + int failed = test_vec_dot_f32(true); + const auto * traits = ggml_get_type_traits(GGML_TYPE_BF16); + const auto * cpu = ggml_get_type_traits_cpu(GGML_TYPE_BF16); + assert(traits->to_float && traits->from_float_ref && cpu->from_float && cpu->vec_dot); + constexpr size_t count = 32 * 128; + std::vector first(count); + std::vector second(count); + generate_data(0.0, count, first.data()); + generate_data(1.0, count, second.data()); + const float errors[] = { + total_quantization_error(traits, cpu, count, first.data()), + reference_quantization_error(traits, cpu, count, first.data()), + dot_product_error(cpu, GGML_TYPE_BF16, count, first.data(), second.data(), nullptr, nullptr, 1), + }; + const float limits[] = {MAX_QUANTIZATION_TOTAL_ERROR, MAX_QUANTIZATION_REFERENCE_ERROR, MAX_DOT_PRODUCT_ERROR}; + for (int index = 0; index < 3; ++index) { + const bool bad = !(errors[index] < limits[index]); + failed += bad; + printf("BF16 original upstream check %d: %s (error=%f limit=%f)\n", + index, RESULT_STR[bad], errors[index], limits[index]); + } + printf("Owned BF16/F32 scope: %d failures; no model; upstream all-type result is separate\n", failed); + return failed > 0; +} diff --git a/workers/volparossa-ml/provision.py b/workers/volparossa-ml/provision.py index cac481503..c692e0418 100644 --- a/workers/volparossa-ml/provision.py +++ b/workers/volparossa-ml/provision.py @@ -388,6 +388,42 @@ def run_checked(command, environment, root, deadline): subprocess.run(command, env=environment, cwd=root, check=True, timeout=remaining) +def native_cpu_options(args): + source = getattr(args, "native_cpu_source", None) + build = getattr(args, "native_cpu_build", None) + if source is None and build is None: + return None + require(source is not None and build is not None and args.model_profile == QWEN4B_MODEL_PROFILE, + "native CPU conversion requires both local source/build paths and the exact 4B profile") + require(all(Path(path).is_absolute() for path in (source, build)), "native CPU input paths must be absolute") + return {"source": str(source), "build": str(build), "kind": "llama_cpp_bf16_v1"} + + +def provision_native_cpu(args, root, python, environment, deadline, used_bytes): + selection = native_cpu_options(args) + if selection is None: + return None + remaining_budget = args.budget_bytes - used_bytes + require(remaining_budget >= QWEN4B_WEIGHTS["bytes"] + 256 * 1024 * 1024, + "same provisioning budget cannot hold additional verified BF16 GGUF") + remaining_seconds = min(1800, int(deadline - time.monotonic())) + require(remaining_seconds >= 1, "native conversion has no remaining original provisioning deadline") + target = root / "native-backend" + # The existing pinned venv executes only the fixed local conversion tool. + # No model inference, package installation or extra download is added here. + run_checked([python, "-B", str(HERE / "convert_llama_cpu.py"), + "--source", selection["source"], "--build", selection["build"], + "--model", str(root / "model"), "--root", str(target), + "--budget-bytes", str(remaining_budget), "--timeout-seconds", str(remaining_seconds), + "--execute", "--yes", "--disposable-guest"], environment, root, deadline) + manifest = target / "backend.json" + info = manifest.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_size <= 65536 and info.st_mode & 0o077 == 0, + "native conversion did not produce private bounded manifest") + return {"kind": selection["kind"], "root": str(target), + "backend_sha256": hashlib.sha256(manifest.read_bytes()).hexdigest()} + + def execute(args, pins): root, guest_kind = execution_root(args) total = download_total(pins) @@ -444,6 +480,8 @@ def execute(args, pins): "print('OFFLINE_CPU_RUNTIME_IMPORT_OK')"], environment, root, deadline) if "task_graph_decoder" in pins: run_checked([python, "-I", "-B", "-c", CHECK_GRAPH_DECODER], environment, root, deadline) + native_backend = provision_native_cpu(args, root, python, environment, deadline, + total + expanded + RESERVE_BYTES) report = { "format_version": 1, "success": True, "guest": guest_kind, "runtime_root": str(runtime), "model_root": str(model), @@ -461,6 +499,8 @@ def execute(args, pins): report["weights"] = pins["weights"] if "task_graph_decoder" in pins: report["task_graph_decoder"] = pins["task_graph_decoder"] + if native_backend is not None: + report["native_backend"] = native_backend (root / "provision-report.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report), flush=True) except BaseException: @@ -483,10 +523,13 @@ def main(argv=None): parser.add_argument("--model-profile", choices=PROFILES, default=DEFAULT_MODEL_PROFILE) parser.add_argument("--task-graph-decoder", action="store_true", help="explicitly add the three pinned optional constrained-graph decoder wheels") + parser.add_argument("--native-cpu-source", help="explicit clean pinned local llama.cpp source for offline BF16 conversion") + parser.add_argument("--native-cpu-build", help="explicit local source-built CPU adapter; no automatic source download") args = parser.parse_args(argv) try: require(1 <= args.timeout_seconds <= 3600, "timeout must be 1..3600 seconds") pins = load_pins(args.model_profile, args.task_graph_decoder) + native_backend = native_cpu_options(args) plan = { "mode": "execute" if args.execute else "preview", "root": args.root, "model_id": pins["model_id"], "revision": pins["revision"], "wheel_count": len(pins["wheels"]), @@ -500,6 +543,9 @@ def main(argv=None): plan["model_profile"] = args.model_profile if args.task_graph_decoder: plan["task_graph_decoder"] = pins["task_graph_decoder"] + if native_backend is not None: + plan["native_backend"] = dict(native_backend, conversion="offline_exact_bf16_values", + shared_original_deadline=True, shared_original_disk_budget=True) print(json.dumps(plan, indent=2), flush=True) if args.execute: execute(args, pins) diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py new file mode 100644 index 000000000..0334aef81 --- /dev/null +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -0,0 +1,299 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert protocol/value-preservation tests: no model/backend download or inference.""" +import copy +import io +import json +import hashlib +import os +from pathlib import Path +import struct +import stat +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import build_llama_cpu as builder +import convert_llama_cpu as conversion +import llama_cpu as native +from test_worker_protocol import WORKER, request, controlled_pipe, control + + +def job(): + return dict(request(), mode="private_conversation", model_profile=native.PROFILE, + steps=1, owner_control=True, inference_backend=native.KIND, + native_backend_root="/native-backend", native_backend_sha256="a" * 64) + + +def manifest(): + return {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": native.SOURCE, + "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, + "source_weights_bytes": native.WEIGHTS_BYTES, + "library": {"path": native.LIBRARY, "sha256": "b" * 64, "bytes": 10}, + "gguf": {"path": "model.gguf", "sha256": "c" * 64, "bytes": 20}, + "verification": {"tensor_count": 398, "tensor_values_equal": True, "tokenizer_equal": True, + "chat_template_sha256": "d" * 64}, "build_manifest_sha256": "e" * 64} + + +class NativeCpuTests(unittest.TestCase): + def test_request_is_atomic_explicit_4b_private_only_and_legacy_unchanged(self): + self.assertNotIn("inference_backend", WORKER.validate_request(request())) + WORKER.validate_request(job()) + for key in ("inference_backend", "native_backend_root", "native_backend_sha256"): + value = job(); del value[key] + with self.assertRaises(WORKER.JobError): + WORKER.validate_request(value) + for delta in ({"mode": "public_code_proposal"}, {"model_profile": WORKER.QWEN_MODEL_PROFILE}, + {"mode": "train"}, {"native_backend_sha256": "not a digest"}, + {"inference_backend": "peer-command"}, {"native_backend_root": "relative"}): + with self.subTest(delta=delta), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(job(), **delta)) + + def test_manifest_closed_shape_digest_scope_and_exact_proof_flags(self): + native.validate_manifest(manifest(), WORKER.require) + for field, replacement in (("abi_version", True), ("source_commit", "0" * 40), + ("source_weights_sha256", "c" * 64), ("model_profile", WORKER.QWEN_MODEL_PROFILE)): + value = manifest(); value[field] = replacement + with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) + + def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + value = manifest() + template = "original template" + for field, data in (("library", b"inert-not-an-executable"), ("gguf", b"inert-not-a-model")): + (root / value[field]["path"]).write_bytes(data) + value[field].update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest()) + provenance = {"version": 1, "abi_version": 1, "kind": native.KIND, "source_commit": native.SOURCE, + "cpu": "avx2_fma_f16c", "library": value["library"], "provisionable": True, + "sanitizers": False, "quantization": False} + build_raw = json.dumps(provenance).encode() + (root / "build.json").write_bytes(build_raw) + value["build_manifest_sha256"] = hashlib.sha256(build_raw).hexdigest() + value["verification"]["chat_template_sha256"] = hashlib.sha256(template.encode()).hexdigest() + raw = json.dumps(value).encode() + (root / "backend.json").write_bytes(raw) + selected = dict(job(), native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) + with mock.patch.object(native.ctypes, "CDLL", side_effect=AssertionError("validation must not execute library")): + _, _, identity = native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template)) + self.assertEqual(identity["gguf_sha256"], value["gguf"]["sha256"]) + with self.assertRaisesRegex(WORKER.JobError, "MANIFEST_DIGEST"): + native.verified_bundle(dict(selected, native_backend_sha256="0" * 64), WORKER, + SimpleNamespace(chat_template=template)) + (root / native.LIBRARY).write_bytes(b"x" * value["library"]["bytes"]) + with self.assertRaisesRegex(WORKER.JobError, "ARTIFACT_DIGEST"): + native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template)) + for field in ("tensor_values_equal", "tokenizer_equal"): + value = manifest(); value["verification"][field] = 1 + with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) + for name in ("../library.so", "/library.so", "peer.so"): + value = manifest(); value["library"]["path"] = name + with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) + + def test_pause_and_resume_are_not_acknowledged_until_native_has_joined(self): + with controlled_pipe() as (session, writer, wire): + os.write(writer, control(1)); session.check() + probe = native.OwnerProbe(session, WORKER) + before = wire.getvalue() + os.write(writer, control(2, "pause") + control(3, "resume")) + self.assertEqual(probe.callback(None), 0) + self.assertEqual(wire.getvalue(), before) + self.assertEqual(session.sequence, 1) + self.assertEqual(len(probe.pending), 2) + probe.joined() + records = [json.loads(line) for line in wire.getvalue().splitlines()] + self.assertEqual([row["phase"] for row in records], ["resumed", "paused", "resumed"]) + self.assertEqual(session.sequence, 3) + self.assertEqual(probe.pending, []) + + def test_cancel_deadline_and_bad_frames_cross_abi_as_status_not_exception(self): + for raw, reason in ((control(2, "cancel"), "JOB_CANCELLED"), + (control(3, "pause"), "INVALID_NATIVE_CONTROL"), + (control(2, id="b" * 32), "INVALID_NATIVE_CONTROL"), + (b'{"private-canary":true}\n', "INVALID_NATIVE_CONTROL")): + with self.subTest(reason=reason), controlled_pipe() as (session, writer, wire): + os.write(writer, control(1)); session.check() + probe = native.OwnerProbe(session, WORKER) + before = wire.getvalue() + os.write(writer, raw) + self.assertEqual(probe.callback(None), 1) + self.assertEqual(wire.getvalue(), before) + with self.assertRaisesRegex(WORKER.JobError, reason): probe.joined() + with controlled_pipe() as (session, writer, _): + os.write(writer, control(1)); session.check() + probe = native.OwnerProbe(session, WORKER) + with mock.patch.object(session, "budget", side_effect=WORKER.JobError("JOB_DEADLINE_EXCEEDED")): + self.assertEqual(probe.callback(None), 1) + with self.assertRaisesRegex(WORKER.JobError, "JOB_DEADLINE_EXCEEDED"): probe.joined() + + def test_partial_owner_frame_is_not_acknowledged_or_discarded(self): + with controlled_pipe() as (session, writer, wire): + os.write(writer, control(1)); session.check() + probe = native.OwnerProbe(session, WORKER) + raw = control(2, "resume") + os.write(writer, raw[:20]) + self.assertEqual(probe.callback(None), 0) + self.assertEqual(session.sequence, 1) + os.write(writer, raw[20:]) + self.assertEqual(probe.callback(None), 0) + probe.joined() + self.assertEqual(session.sequence, 2) + + def test_full_4572_prompt_eos_raw_text_and_no_hidden_warmup(self): + prompt = list(range(4572)) + model = mock.Mock() + model.sample.side_effect = [77, 78, 151645] + tokenizer = SimpleNamespace(eos_token_id=151645, decode=mock.Mock(return_value="raw")) + session, observation = mock.Mock(), mock.Mock() + profile = WORKER.model_profile(native.PROFILE) + result = native.generate(model, prompt, tokenizer, profile, session, WORKER, observation) + batches = [entry.args[0] for entry in model.decode.call_args_list] + self.assertEqual(sum(batches[:36], []), prompt) + self.assertEqual(batches[36:], [[77], [78]]) + self.assertTrue(all(1 <= len(batch) <= 128 for batch in batches)) + self.assertEqual(result["generation"]["stop_reason"], "eos") + self.assertEqual(result["text"], "raw") + tokenizer.decode.assert_called_once_with([77, 78], skip_special_tokens=False) + observation.forward_complete.assert_called_once() + observation.complete.assert_called_once() + + def test_no_invented_eos_or_silent_prompt_truncation(self): + model = mock.Mock(); model.sample.return_value = 77 + tokenizer = SimpleNamespace(eos_token_id=151645, decode=mock.Mock(return_value="raw")) + profile = WORKER.model_profile(native.PROFILE) + result = native.generate(model, [1], tokenizer, profile, mock.Mock(), WORKER) + self.assertEqual(result["generation"]["stop_reason"], "token_limit") + self.assertEqual(result["generated_tokens"], 1024) + self.assertEqual(model.sample.call_count, 1024) + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_PROMPT_BOUND"): + native.generate(model, [1] * 12289, tokenizer, profile, mock.Mock(), WORKER) + + def test_failed_decode_does_not_sample_or_decode_text(self): + model = mock.Mock(); model.decode.side_effect = WORKER.JobError("NATIVE_DECODE_FAILED") + tokenizer = mock.Mock() + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_DECODE_FAILED"): + native.generate(model, [1], tokenizer, WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER) + model.sample.assert_not_called(); tokenizer.decode.assert_not_called() + + def test_raw_output_wire_bound_stays_original(self): + model = mock.Mock(); model.sample.return_value = 151645 + tokenizer = SimpleNamespace(eos_token_id=151645, decode=mock.Mock(return_value="漢" * 2000)) + result = native.generate(model, [1], tokenizer, WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER) + self.assertTrue(result["text_truncated"]) + self.assertLessEqual(len(json.dumps(result["text"], ensure_ascii=True).encode()), 4096) + + def test_c_abi_handle_closes_after_joined_python_error_and_only_once(self): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + def allocate(_path, _capacity, _threads, _callback, _opaque, out): + out._obj.value = 123 + return 0 + library.vp_llama_open_v1.side_effect = allocate + owner = mock.Mock() + owner.joined.side_effect = WORKER.JobError("JOB_CANCELLED") + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "JOB_CANCELLED"): + native.NativeModel(Path("/native-backend"), 5600, 2, owner, WORKER.require) + library.vp_llama_close_v1.assert_called_once() + owner.joined.side_effect = None + library.vp_llama_close_v1.reset_mock() + with mock.patch.object(native.ctypes, "CDLL", return_value=library): + model = native.NativeModel(Path("/native-backend"), 5600, 2, owner, WORKER.require) + model.close(); model.close() + library.vp_llama_close_v1.assert_called_once() + + def test_native_execute_rejects_implicit_sampling_before_any_library_or_model_load(self): + bridge = mock.Mock() + bridge.generation_policy.return_value = None + with mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ + mock.patch.object(native, "NativeModel", side_effect=AssertionError("must not load")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_GREEDY_POLICY_REQUIRED"): + native.execute(job(), mock.Mock(), mock.Mock(), mock.Mock(), {}, None, None, {}, {}, {}, WORKER) + + def test_tensor_mapping_is_complete_for_exact_qwen_layers_not_arbitrary_names(self): + self.assertEqual(conversion.tensor_name("model.layers.35.self_attn.q_norm.weight"), "blk.35.attn_q_norm.weight") + self.assertEqual(conversion.tensor_name("model.embed_tokens.weight"), "token_embd.weight") + for name in ("model.layers.36.mlp.up_proj.weight", "model.layers.0.injected.weight", "lm_head.weight"): + with self.assertRaises(ValueError): conversion.tensor_name(name) + + def test_bf16_tensor_values_exact_and_norm_promotions_exact_including_signed_zero(self): + raw = struct.pack("<4H", 0x3f80, 0x8000, 0xbf00, 0x4000) + source = {"dtype": "BF16", "shape": [2, 2], "data_offsets": [0, 8]} + target = SimpleNamespace(name="blk.0.attn_q.weight", shape=[2, 2], n_elements=4, tensor_type=30, data=bytearray(raw)) + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + target.data[0] ^= 1 + with self.assertRaisesRegex(ValueError, "VALUE_CHANGED"): + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + source["shape"] = [4] + target.shape, target.name, target.tensor_type = [4], "output_norm.weight", 0 + target.data = bytearray(struct.pack("<4I", *(value << 16 for value in struct.unpack("<4H", raw)))) + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + # Positive zero may be numerically equal but must not replace negative zero. + target.data[7] = 0 + with self.assertRaisesRegex(ValueError, "VALUE_CHANGED"): + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + + def test_wrong_tensor_dtype_shape_and_f32_matrix_are_rejected(self): + raw = b"\0" * 8 + source = {"dtype": "BF16", "shape": [2, 2], "data_offsets": [0, 8]} + for dtype, shape in ((1, [2, 2]), (8, [2, 2]), (0, [2, 2]), (30, [4])): + target = SimpleNamespace(name="blk.0.attn_q.weight", shape=shape, n_elements=4, tensor_type=dtype, data=bytearray(raw)) + with self.assertRaises(ValueError): conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + + def test_shared_library_dependency_policy_has_no_side_loading(self): + self.assertEqual(builder.dependencies("0 (NEEDED) Shared library: [libc.so.6]\n"), ["libc.so.6"]) + for value in ("0 (NEEDED) [libpeer.so]\n", "0 (NEEDED) [libc.so.6]\n0 (RUNPATH) [/peer]\n", ""): + with self.assertRaises(ValueError): builder.dependencies(value) + + def test_converted_bundle_files_are_owner_private_and_not_shared_0444(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "licenses").mkdir() + for path in (root / "backend.json", root / native.LIBRARY, root / "model.gguf", root / "licenses/MIT"): + path.write_bytes(b"inert") + path.chmod(0o444) + conversion.private_bundle_modes(root) + self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o700) + for path in root.rglob("*"): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700 if path.is_dir() else 0o400) + + def test_provision_native_selection_preserves_default_and_original_budget(self): + provision = conversion.provision + args = SimpleNamespace(model_profile=native.PROFILE, native_cpu_source=None, native_cpu_build=None) + self.assertIsNone(provision.native_cpu_options(args)) + args.native_cpu_source = "/pinned/source" + with self.assertRaises(provision.ProvisionError): provision.native_cpu_options(args) + args.native_cpu_build = "/pinned/build" + self.assertEqual(provision.native_cpu_options(args)["kind"], native.KIND) + args.model_profile = WORKER.QWEN_MODEL_PROFILE + with self.assertRaises(provision.ProvisionError): provision.native_cpu_options(args) + args.model_profile = native.PROFILE + args.budget_bytes = 8 * 1024 ** 3 + with mock.patch.object(provision, "run_checked", side_effect=AssertionError("budget refusal must precede execution")), \ + self.assertRaisesRegex(provision.ProvisionError, "same provisioning budget"): + provision.provision_native_cpu(args, Path("/new"), "/runtime/python", {}, 1000, 2 * 1024 ** 3) + + def test_conversion_subwindow_caps_at_1800_without_rejecting_original_3600_deadline(self): + provision = conversion.provision + args = SimpleNamespace(model_profile=native.PROFILE, native_cpu_source="/pinned/source", + native_cpu_build="/pinned/build", budget_bytes=20 * 1024 ** 3) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def completed(command, environment, supplied_root, supplied_deadline): + self.assertEqual(supplied_deadline, 4000) + self.assertEqual(command[command.index("--timeout-seconds") + 1], "1800") + self.assertEqual(command[command.index("--budget-bytes") + 1], str(10 * 1024 ** 3)) + target = root / "native-backend" + target.mkdir(mode=0o700) + (target / "backend.json").write_bytes(b"{}") + (target / "backend.json").chmod(0o400) + with mock.patch.object(provision.time, "monotonic", return_value=1000), \ + mock.patch.object(provision, "run_checked", side_effect=completed) as invoked: + result = provision.provision_native_cpu(args, root, "/runtime/python", {}, 4000, 10 * 1024 ** 3) + self.assertEqual(result["backend_sha256"], hashlib.sha256(b"{}").hexdigest()) + invoked.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index 33450d6ae..7b4955ded 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -25,6 +25,7 @@ import struct import sys import time +from types import SimpleNamespace VERSION = 1 MAX_REQUEST = 65536 @@ -258,7 +259,7 @@ def bounded_integer(value, low, high): def validate_request(value): required = {"version", "id", "mode", "model_root", "dataset_path", "output_root"} optional = {"steps", "threads", "max_seconds", "adapter_root", "owner_control", "model_profile", - "private_generation_diagnostics"} + "private_generation_diagnostics", "inference_backend", "native_backend_root", "native_backend_sha256"} require(type(value) is dict and required <= value.keys() and value.keys() <= required | optional, "INVALID_REQUEST_FIELDS") require(type(value["version"]) is int and value["version"] == VERSION, "UNSUPPORTED_VERSION") @@ -266,6 +267,12 @@ def validate_request(value): require(value["mode"] in ("infer", "train", "plan_document", "plan_tasks", "private_infer", "private_conversation", "public_code_proposal", "aggregate_adapter"), "INVALID_JOB_MODE") profile_name = value.get("model_profile", DEFAULT_MODEL_PROFILE) model_profile(profile_name) + native_fields = {"inference_backend", "native_backend_root", "native_backend_sha256"} + if native_fields & value.keys(): + require(native_fields <= value.keys() and value["inference_backend"] == "llama_cpp_bf16_v1" + and profile_name == QWEN4B_MODEL_PROFILE and value["mode"] == "private_conversation" + and type(value["native_backend_sha256"]) is str + and re.fullmatch(r"[0-9a-f]{64}", value["native_backend_sha256"]), "INVALID_NATIVE_BACKEND_SCOPE") require("private_generation_diagnostics" not in value or (type(value["private_generation_diagnostics"]) is bool and (not value["private_generation_diagnostics"] or @@ -291,8 +298,8 @@ def validate_request(value): "AGGREGATION_EXECUTION_SCOPE") require("owner_control" not in value or type(value["owner_control"]) is bool, "INVALID_OWNER_CONTROL") - for field in ("model_root", "dataset_path", "output_root", "adapter_root"): - if field == "adapter_root" and field not in value: + for field in ("model_root", "dataset_path", "output_root", "adapter_root", "native_backend_root"): + if field in ("adapter_root", "native_backend_root") and field not in value: continue path = value[field] require(type(path) is str and 1 < len(path.encode("utf-8")) <= 4096 @@ -2322,6 +2329,11 @@ def execute_job(request, session): "MODEL_TOKENIZER_MISMATCH") session.private_progress("tokenizer_load", "complete") session.check() + if request.get("inference_backend") == "llama_cpp_bf16_v1": + native_backend = sys.modules.get("volparossa_llama_cpu") + require(native_backend is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") + return native_backend.execute(request, session, tokenizer, torch, versions, model_root, output_root, + dataset, data_identity, model_files, SimpleNamespace(**globals())) if request["mode"] == "plan_document": plan = plan_document(tokenizer, dataset, session, profile_name) raw = json.dumps(plan, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")).encode("ascii") From f7e2c3b9c2abd710682a2ec51d4b5dfbc46b9d8f Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:13:27 +0200 Subject: [PATCH 02/39] compute: pin native converter dependency and preserve owned process cleanup --- THIRD_PARTY_LICENSES.md | 15 ++ crates/volparossa/src/compute/native_cpu.rs | 2 +- docs/IMPLEMENTATION_STATUS.md | 19 +- workers/volparossa-ml/convert_llama_cpu.py | 71 +++++- .../volparossa-ml/native-converter-pins.json | 61 ++++++ .../native-converter-requirements.lock | 2 + workers/volparossa-ml/native-cpu/README.md | 22 ++ .../native-cpu/sentencepiece-LICENSE | 202 +++++++++++++++++ .../native-cpu/sentencepiece-absl-LICENSE | 204 ++++++++++++++++++ .../sentencepiece-darts_clone-LICENSE | 10 + .../native-cpu/sentencepiece-esaxx-LICENSE | 24 +++ .../sentencepiece-protobuf-lite-LICENSE | 32 +++ workers/volparossa-ml/provision.py | 67 +++++- workers/volparossa-ml/test_llama_cpu.py | 158 ++++++++++++++ 14 files changed, 878 insertions(+), 11 deletions(-) create mode 100644 workers/volparossa-ml/native-converter-pins.json create mode 100644 workers/volparossa-ml/native-converter-requirements.lock create mode 100644 workers/volparossa-ml/native-cpu/sentencepiece-LICENSE create mode 100644 workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE create mode 100644 workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE create mode 100644 workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE create mode 100644 workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 262906062..59361e85e 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -27,6 +27,21 @@ See [the native CPU contract](workers/volparossa-ml/native-cpu/README.md) for source/build/conversion provenance, instruction requirements and remaining proof. Source and inert tests are not claims of successful guest inference or coding. +The explicit native conversion, not the baseline 38-wheel runtime, adds the +official [SentencePiece 0.2.1 wheel](https://pypi.org/project/sentencepiece/0.2.1/) +for CPython 3.13 / manylinux 2.27+ x86-64: 1,387,882 bytes, SHA-256 +`c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47`. +Exact origin, metadata hash and optional lock are retained in +`workers/volparossa-ml/native-converter-pins.json`; it is a pinned published +binary, not a claim of a locally reproduced source build. Its Apache-2.0 release +source is [google/sentencepiece at 31646a467d2051eb904e0b45de3a73e91fe1c1e3](https://github.com/google/sentencepiece/tree/31646a467d2051eb904e0b45de3a73e91fe1c1e3) +(`v0.2.1`). The wheel contains no license files. Original release LICENSE and +the original bundled absl, darts_clone, esaxx and protobuf-lite licenses from +that revision are therefore retained unchanged under +`workers/volparossa-ml/native-cpu/sentencepiece*-LICENSE`, with exact source +URLs, sizes and hashes in the optional pins. Converted artifacts include all +five notices; no notice is replaced by this summary. + ## Explicit native editor integration trial The `agent-native-editor` guest trial uses VOLPAROSSA Code commit diff --git a/crates/volparossa/src/compute/native_cpu.rs b/crates/volparossa/src/compute/native_cpu.rs index 1714f71ce..781c81eee 100644 --- a/crates/volparossa/src/compute/native_cpu.rs +++ b/crates/volparossa/src/compute/native_cpu.rs @@ -156,7 +156,7 @@ impl Options { info.is_file() && info.uid() == nix::unistd::geteuid().as_raw() && info.nlink() == 1 - && info.mode() & 0o077 == 0 + && info.mode().trailing_zeros() >= 6 && info.len() == artifact.bytes, "compute_native_backend_file" ); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index adb502272..c36e3d506 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -46,8 +46,23 @@ The definitive normal source-only build records library SHA-256 `4af8d584349d184049c5f6ffea3e5618839c69536676a6bb5c12ef33b03755a7` and build-manifest SHA-256 `d51c06798cae305622cf91089692c323cc31c1af8ff63420b270aaf44830d27c`. -Nineteen native Python contract checks pass, along with the separately targeted -existing worker/provision/conversation checks. These tests load no actual model. +Twenty-five native Python contract checks and thirteen targeted provision/retry +checks pass. They include the explicitly selected, exact SentencePiece converter +dependency without changing the original 38-wheel runtime; its native extension +adds one wheel. Real harmless subprocess tests cover timeout, cancellation, +cancellation racing a successful wait, and outer SIGKILL followed by the owned +process-group cleanup. No converter child detaches into a new group. These are +not actual model-conversion or inference tests. + +The first [candidate quality run `37233543544`](https://github.com/VOLPAROSSA/volparossa/actions/runs/37233543544) +on `d2fe6728` remains failed: strict Clippy rejected the permission-mask spelling +(`verbose_bit_mask`), after formatting and the nineteen original native Python +checks passed. The equivalent `trailing_zeros() >= 6` spelling preserves the +requirement for zero group/other permission bits. All six native Rust tests pass +again; no assertion or privacy boundary was removed. Retained decoded job +`111528060483` log SHA-256: +`80dbe57d6b939c97c41785c4b7473fd2cc218daa197f017b4157b92501399685`. +Skipped later steps are not passing workspace or datapath evidence. Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 9ab35d12b..012368246 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -16,7 +16,9 @@ from pathlib import Path import re import shutil +import signal import struct +import subprocess import sys import time @@ -27,6 +29,64 @@ HERE = Path(__file__).resolve().parent +class ConversionCancelled(Exception): + """A fixed cancellation reason; no private subprocess command is exported.""" + + +def run_converter(command, log, environment, timeout): + """Join our direct child; never detach it from the owner's provision group. + + TERM/INT/HUP mark cancellation instead of interrupting Popen construction. + Cleanup signals only this known child, never the shared parent group. If an + outside timeout SIGKILLs this wrapper, the existing fixture's process-group + join still includes the child. The pinned converter starts no subprocesses; + native thread cleanup is part of joining its one Python process. + """ + builder.require(timeout > 0, "NATIVE_CONVERSION_DEADLINE") + deadline, process, cancelled = time.monotonic() + timeout, None, None + previous = {} + def stop(signum, _frame): + nonlocal cancelled + cancelled = signum + try: + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + previous[signum] = signal.signal(signum, stop) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + process = subprocess.Popen(["nice", "-n", "19", *command], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, env=environment, + start_new_session=False) + builder.require(os.getpgid(process.pid) == os.getpgrp(), "NATIVE_CONVERTER_GROUP") + while True: + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("native converter", timeout) + try: + result = process.wait(timeout=min(.1, remaining)) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + builder.require(result == 0, "NATIVE_CONVERSION_PROCESS_FAILED") + return + except subprocess.TimeoutExpired: + pass + finally: + try: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + finally: + for signum, handler in previous.items(): + signal.signal(signum, handler) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + + def tensor_name(name): exact = {"model.embed_tokens.weight": "token_embd.weight", "model.norm.weight": "output_norm.weight"} if name in exact: @@ -167,12 +227,13 @@ def execute(args): root, _ = provision.execution_root(args) builder.verify_source(args.source) builder.require(1 <= args.timeout_seconds <= 1800, "NATIVE_CONVERSION_DEADLINE") - pins = provision.load_pins(provision.QWEN4B_MODEL_PROFILE) + pins = provision.load_pins(provision.QWEN4B_MODEL_PROFILE, native_cpu_converter=True) started = time.monotonic() deadline = started + args.timeout_seconds def check(): builder.require(time.monotonic() < deadline, "NATIVE_CONVERSION_DEADLINE") - for name, expected in (("torch", "2.14.0+cpu"), ("transformers", "5.16.1"), ("peft", "0.20.0")): + for name, expected in (("torch", "2.14.0+cpu"), ("transformers", "5.16.1"), ("peft", "0.20.0"), + ("sentencepiece", "0.2.1")): builder.require(importlib.metadata.version(name) == expected, "NATIVE_CONVERTER_RUNTIME_PIN") model = args.model builder.require(model.is_absolute() and model == model.resolve() and model.is_dir(), "NATIVE_MODEL_PATH") @@ -209,8 +270,8 @@ def check(): if key.startswith("LD_") or key in ("PYTHONPATH", "PYTHONHOME"): env.pop(key) with (root / "conversion.log").open("xb") as log: - builder.run_build([sys.executable, str(args.source / "convert_hf_to_gguf.py"), str(model), - "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic()) + run_converter([sys.executable, str(args.source / "convert_hf_to_gguf.py"), str(model), + "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic()) check() sys.path.insert(0, str(args.source / "gguf-py")) import gguf @@ -223,6 +284,8 @@ def check(): shutil.copyfile(build_root / "build.json", root / "build.json") shutil.copytree(build_root / "licenses", root / "licenses", symlinks=False) shutil.copyfile(model / "LICENSE", root / "licenses" / "Qwen-APACHE-2.0.txt") + for notice in provision.native_converter_pins()["notices"]: + shutil.copyfile(HERE / notice["path"], root / "licenses" / Path(notice["path"]).name) manifest = {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": builder.SOURCE, "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, "source_weights_bytes": native.WEIGHTS_BYTES, diff --git a/workers/volparossa-ml/native-converter-pins.json b/workers/volparossa-ml/native-converter-pins.json new file mode 100644 index 000000000..9b7824409 --- /dev/null +++ b/workers/volparossa-ml/native-converter-pins.json @@ -0,0 +1,61 @@ +{ + "format_version": 1, + "platform": "cpython-3.13-linux-x86_64", + "converter": { + "implementation": "llama_cpp_bf16_v1", + "dependencies": { + "sentencepiece": "0.2.1" + } + }, + "source_revision": "31646a467d2051eb904e0b45de3a73e91fe1c1e3", + "wheel_source_build_claimed": false, + "wheels": [ + { + "name": "sentencepiece", + "version": "0.2.1", + "path": "sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "bytes": 1387882, + "sha256": "c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", + "url": "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "license": "Apache-2.0", + "metadata_sha256": "ce0a2e9c599cd060a7bc3dffb18be5cfc3d5f293533e3ee4e870eaef0a5500fd" + } + ], + "notices": [ + { + "path": "native-cpu/sentencepiece-LICENSE", + "source": "LICENSE", + "bytes": 11358, + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/LICENSE" + }, + { + "source": "third_party/absl/LICENSE", + "bytes": 11365, + "sha256": "d3e2f59e1d71176dfdb555ece6a41f7a5aa0f52ff21211010ace314f57695f6b", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/absl/LICENSE", + "path": "native-cpu/sentencepiece-absl-LICENSE" + }, + { + "source": "third_party/darts_clone/LICENSE", + "bytes": 1481, + "sha256": "155f59997298ee336602c49f9c1110f268ac394ca2197eb02647a3555935ad52", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/darts_clone/LICENSE", + "path": "native-cpu/sentencepiece-darts_clone-LICENSE" + }, + { + "source": "third_party/esaxx/LICENSE", + "bytes": 1112, + "sha256": "7c28553d1d3312d65fe309f76a22ebaf33a3d76c8c1e3b98a88ee4654ecb53db", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/esaxx/LICENSE", + "path": "native-cpu/sentencepiece-esaxx-LICENSE" + }, + { + "source": "third_party/protobuf-lite/LICENSE", + "bytes": 1732, + "sha256": "6e5e117324afd944dcf67f36cf329843bc1a92229a8cd9bb573d7a83130fea7d", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/protobuf-lite/LICENSE", + "path": "native-cpu/sentencepiece-protobuf-lite-LICENSE" + } + ] +} diff --git a/workers/volparossa-ml/native-converter-requirements.lock b/workers/volparossa-ml/native-converter-requirements.lock new file mode 100644 index 000000000..5b7e1eac5 --- /dev/null +++ b/workers/volparossa-ml/native-converter-requirements.lock @@ -0,0 +1,2 @@ +# Explicit BF16 converter only; baseline requirements.lock is unchanged. +sentencepiece==0.2.1 --hash=sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47 diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md index 29c9f38d5..febb65d8a 100644 --- a/workers/volparossa-ml/native-cpu/README.md +++ b/workers/volparossa-ml/native-cpu/README.md @@ -78,6 +78,28 @@ is a refusal, not permission to raise limits. The new `native-backend/` artifact has owner-private directories (`0700`) and read-only files (`0400`). Existing provisioning without these options retains its original behavior/report shape. +Only this explicit conversion adds the official CPython 3.13/Linux x86-64 +`sentencepiece==0.2.1` wheel, making 39 wheels instead of the unchanged baseline +38. `native-converter-pins.json` and `native-converter-requirements.lock` bind +its exact URL, 1,387,882-byte size and SHA-256; provisioning retains the combined +pins/lock and includes the extra bytes in the same budget. This dependency is +required even for Qwen's non-SentencePiece vocabulary: the pinned upstream +converter imports SentencePiece before checking for the absent `tokenizer.model` +and taking its normal vocabulary fallback. No upstream patch is substituted. +The wheel's metadata was inspected without installing/importing it on the host; +its version/import check runs only in the existing disposable provision runtime. +The published wheel is not claimed to be a reproducible local source build. +Original release and bundled dependency licenses are retained unchanged and +copied into converted artifacts; the wheel itself contains no license files. + +The converter child remains in the existing owned provisioning process group. +On a local timeout or TERM/INT/HUP, its wrapper joins that exact child before +returning, without signalling the shared parent group. If an outer timeout +SIGKILLs the wrapper, its handler cannot run: the child still belongs to the +original group, which the fixture must terminate and join. Harmless real-process +tests cover both cases, including a SIGTERM-ignoring child and an unrelated +surviving sibling. This does not replace the fixture's final group-cleanup check. + ## Execution and trust The owner explicitly supplies `--native-backend-root` and diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE new file mode 100644 index 000000000..fef7d9678 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + \ No newline at end of file diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE new file mode 100644 index 000000000..28203b3b2 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2008-2011, Susumu Yata +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE new file mode 100644 index 000000000..07394df7c --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE @@ -0,0 +1,24 @@ +This is the esaxx copyright. + +Copyright (c) 2010 Daisuke Okanohara All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE new file mode 100644 index 000000000..19b305b00 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE @@ -0,0 +1,32 @@ +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. diff --git a/workers/volparossa-ml/provision.py b/workers/volparossa-ml/provision.py index c692e0418..aaae918e6 100644 --- a/workers/volparossa-ml/provision.py +++ b/workers/volparossa-ml/provision.py @@ -51,6 +51,7 @@ GRAPH_DECODER = {"implementation": "lm-format-enforcer", "version": "0.11.3", "adapter_version": 1, "schema_version": 3, "dependencies": {"interegular": "0.3.3", "pydantic": "1.10.24"}} +NATIVE_CONVERTER = {"implementation": "llama_cpp_bf16_v1", "dependencies": {"sentencepiece": "0.2.1"}} RESERVE_BYTES = 64 * 1024 * 1024 ALLOWED_HOSTS = frozenset({ "files.pythonhosted.org", "download.pytorch.org", "download-r2.pytorch.org", @@ -83,8 +84,10 @@ def redirect_request(self, request, fp, code, msg, headers, newurl): return super().redirect_request(request, fp, code, msg, headers, newurl) -def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False): +def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False, native_cpu_converter=False): require(model_profile in PROFILES, "unsupported model profile") + require(not native_cpu_converter or (model_profile == QWEN4B_MODEL_PROFILE and not task_graph_decoder), + "native converter requires the explicit 4B profile without graph decoder") model_id, revision = PROFILES[model_profile] pins = json.loads((HERE / "model-pins.json").read_text()) if model_profile in PROFILE_PINS: @@ -144,6 +147,8 @@ def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False): require(actual == expected, "requirements.lock does not exactly match artifact pins") if task_graph_decoder: add_graph_decoder(pins) + if native_cpu_converter: + add_native_converter(pins) return pins @@ -180,15 +185,60 @@ def add_graph_decoder(pins): pins["task_graph_decoder"] = extra["decoder"] +def native_converter_pins(): + extra = json.loads((HERE / "native-converter-pins.json").read_text()) + require(extra["format_version"] == 1 and extra["platform"] == "cpython-3.13-linux-x86_64" + and extra["converter"] == NATIVE_CONVERTER and len(extra["wheels"]) == 1 + and extra["source_revision"] == "31646a467d2051eb904e0b45de3a73e91fe1c1e3" + and extra["wheel_source_build_claimed"] is False, "unsupported native converter pins") + item = extra["wheels"][0] + name = "sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" + require(item["name"] == "sentencepiece" and item["version"] == "0.2.1" and item["path"] == name + and item["bytes"] == 1387882 + and item["sha256"] == "c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47" + and item["metadata_sha256"] == "ce0a2e9c599cd060a7bc3dffb18be5cfc3d5f293533e3ee4e870eaef0a5500fd" + and item["license"] == "Apache-2.0", "native converter wheel identity differs") + expected_url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/" + name + require(official_url(item["url"]) == expected_url, "native converter wheel origin differs") + lock = (HERE / "native-converter-requirements.lock").read_text() + require([line for line in lock.splitlines() if line and not line.startswith("#")] + == [f"{item['name']}=={item['version']} --hash=sha256:{item['sha256']}"], + "native converter lock differs from pins") + expected_notices = {"LICENSE", "third_party/absl/LICENSE", "third_party/darts_clone/LICENSE", + "third_party/esaxx/LICENSE", "third_party/protobuf-lite/LICENSE"} + require(len(extra["notices"]) == 5 and {notice["source"] for notice in extra["notices"]} == expected_notices, + "native converter original notices differ") + for notice in extra["notices"]: + local = "native-cpu/sentencepiece" + ("" if notice["source"] == "LICENSE" else "-" + notice["source"].split("/")[1]) + "-LICENSE" + require(notice["path"] == local and notice["url"] == "https://raw.githubusercontent.com/google/sentencepiece/" + + extra["source_revision"] + "/" + notice["source"], "native converter notice provenance differs") + raw = (HERE / local).read_bytes() + require(len(raw) == notice["bytes"] and hashlib.sha256(raw).hexdigest() == notice["sha256"], + "native converter original notice bytes differ") + return extra + + +def add_native_converter(pins): + extra = native_converter_pins() + item = extra["wheels"][0] + require(not any(row["name"] == item["name"] or row["path"] == item["path"] for row in pins["wheels"]), + "native converter cannot override a pinned distribution") + pins["wheels"] += extra["wheels"] + pins["native_cpu_converter"] = extra["converter"] + + def retained_pin_files(pins): """Return exact retained bytes, including the original baseline without the opt-in.""" - enabled = "task_graph_decoder" in pins + enabled = "task_graph_decoder" in pins or "native_cpu_converter" in pins pin_bytes = ((HERE / "model-pins.json").read_bytes() if pins["model_id"] == MODEL_ID and not enabled else (json.dumps(pins, indent=2) + "\n").encode()) requirements = (HERE / "requirements.lock").read_bytes() - if enabled: + if "task_graph_decoder" in pins: requirements += (b"" if requirements.endswith(b"\n") else b"\n") requirements += (HERE / "graph-decoder-requirements.lock").read_bytes() + if "native_cpu_converter" in pins: + requirements += (b"" if requirements.endswith(b"\n") else b"\n") + requirements += (HERE / "native-converter-requirements.lock").read_bytes() return pin_bytes, requirements @@ -425,6 +475,8 @@ def provision_native_cpu(args, root, python, environment, deadline, used_bytes): def execute(args, pins): + require(("native_cpu_converter" in pins) == (native_cpu_options(args) is not None), + "native converter dependencies and explicit source/build selection differ") root, guest_kind = execution_root(args) total = download_total(pins) require(args.budget_bytes >= total + RESERVE_BYTES, "budget cannot hold pinned downloads") @@ -480,6 +532,11 @@ def execute(args, pins): "print('OFFLINE_CPU_RUNTIME_IMPORT_OK')"], environment, root, deadline) if "task_graph_decoder" in pins: run_checked([python, "-I", "-B", "-c", CHECK_GRAPH_DECODER], environment, root, deadline) + if "native_cpu_converter" in pins: + run_checked([python, "-I", "-B", "-c", "import sentencepiece;" + "assert sentencepiece.__version__=='0.2.1';" + "assert callable(sentencepiece.SentencePieceProcessor);" + "print('OFFLINE_NATIVE_CONVERTER_IMPORT_OK')"], environment, root, deadline) native_backend = provision_native_cpu(args, root, python, environment, deadline, total + expanded + RESERVE_BYTES) report = { @@ -499,6 +556,8 @@ def execute(args, pins): report["weights"] = pins["weights"] if "task_graph_decoder" in pins: report["task_graph_decoder"] = pins["task_graph_decoder"] + if "native_cpu_converter" in pins: + report["native_cpu_converter"] = pins["native_cpu_converter"] if native_backend is not None: report["native_backend"] = native_backend (root / "provision-report.json").write_text(json.dumps(report, indent=2) + "\n") @@ -528,8 +587,8 @@ def main(argv=None): args = parser.parse_args(argv) try: require(1 <= args.timeout_seconds <= 3600, "timeout must be 1..3600 seconds") - pins = load_pins(args.model_profile, args.task_graph_decoder) native_backend = native_cpu_options(args) + pins = load_pins(args.model_profile, args.task_graph_decoder, native_backend is not None) plan = { "mode": "execute" if args.execute else "preview", "root": args.root, "model_id": pins["model_id"], "revision": pins["revision"], "wheel_count": len(pins["wheels"]), diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py index 0334aef81..0656b5997 100644 --- a/workers/volparossa-ml/test_llama_cpu.py +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -6,9 +6,13 @@ import hashlib import os from pathlib import Path +import signal import struct import stat +import subprocess +import sys import tempfile +import time from types import SimpleNamespace import unittest from unittest import mock @@ -36,6 +40,160 @@ def manifest(): class NativeCpuTests(unittest.TestCase): + def test_converter_dependency_is_explicit_exact_and_baseline_lock_unchanged(self): + provision = conversion.provision + baseline = provision.load_pins(native.PROFILE) + original = copy.deepcopy(baseline) + pins = provision.load_pins(native.PROFILE, native_cpu_converter=True) + self.assertEqual(len(baseline["wheels"]), 38) + self.assertEqual(len(pins["wheels"]), 39) + self.assertEqual(pins["wheels"][:-1], original["wheels"]) + self.assertEqual(pins["files"], original["files"]) + self.assertEqual(pins["weights"], original["weights"]) + self.assertEqual(provision.download_total(pins) - provision.download_total(baseline), 1387882) + pin_bytes, lock = provision.retained_pin_files(pins) + self.assertEqual(json.loads(pin_bytes)["native_cpu_converter"], provision.NATIVE_CONVERTER) + self.assertEqual(lock, (provision.HERE / "requirements.lock").read_bytes() + + (provision.HERE / "native-converter-requirements.lock").read_bytes()) + self.assertEqual(provision.retained_pin_files(baseline)[1], (provision.HERE / "requirements.lock").read_bytes()) + self.assertEqual(provision.load_pins(native.PROFILE), original) + for profile, graph in ((WORKER.QWEN_MODEL_PROFILE, False), (native.PROFILE, True)): + with self.assertRaises(provision.ProvisionError): + provision.load_pins(profile, graph, native_cpu_converter=True) + + def test_converter_pin_cannot_override_existing_distribution_or_license_bytes(self): + provision = conversion.provision + pins = provision.load_pins(native.PROFILE, native_cpu_converter=True) + with self.assertRaisesRegex(provision.ProvisionError, "override"): + provision.add_native_converter(pins) + real_read = Path.read_bytes + def changed(path): + raw = real_read(path) + return raw + b"changed" if path.name == "sentencepiece-LICENSE" else raw + with mock.patch.object(Path, "read_bytes", changed), self.assertRaisesRegex(provision.ProvisionError, "notice bytes"): + provision.native_converter_pins() + + def test_converter_cancellation_during_successful_wait_or_final_join_is_not_swallowed(self): + before = {signum: signal.getsignal(signum) for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} + def cancelled_success(*_args, **_kwargs): + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + return 0 + for checkpoint in ("wait", "poll"): + with self.subTest(checkpoint=checkpoint): + process = mock.Mock(pid=os.getpid()) + process.wait.return_value = process.poll.return_value = 0 + getattr(process, checkpoint).side_effect = cancelled_success + with mock.patch.object(conversion.subprocess, "Popen", return_value=process), \ + self.assertRaisesRegex(conversion.ConversionCancelled, "NATIVE_CONVERSION_CANCELLED"): + conversion.run_converter([sys.executable, "-c", "pass"], io.BytesIO(), dict(os.environ), 1) + process.terminate.assert_not_called() + process.kill.assert_not_called() + self.assertEqual({signum: signal.getsignal(signum) for signum in before}, before) + + @staticmethod + def child_source(ignore_term=False): + return ("import json,os,signal,time;" + + ("signal.signal(signal.SIGTERM,signal.SIG_IGN);" if ignore_term else "") + + "print(json.dumps({'pid':os.getpid(),'parent':os.getppid(),'group':os.getpgrp()}),flush=True);time.sleep(60)") + + @staticmethod + def wait_child(log): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + raw = log.read_text() + if raw.endswith("\n"): + return json.loads(raw.splitlines()[0]) + time.sleep(.02) + raise AssertionError("harmless child did not report readiness") + + @staticmethod + def finish_group(process): + # Only the fresh group created by this test. Never the test runner's group. + assert process.pid != os.getpgrp() + for signum in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, signum) + except ProcessLookupError: + break + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + continue + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + time.sleep(.02) + raise AssertionError("owned process group did not disappear") + + def test_converter_timeout_joins_sigterm_ignoring_child_without_killing_sibling(self): + sibling = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(60)"], start_new_session=True) + try: + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "child.log" + with target.open("xb") as log, mock.patch.object(os, "killpg", side_effect=AssertionError("parent group must not be signaled")): + with self.assertRaises(subprocess.TimeoutExpired): + conversion.run_converter([sys.executable, "-u", "-c", self.child_source(True)], log, dict(os.environ), .5) + child = self.wait_child(target) + self.assertEqual(child["group"], os.getpgrp()) + self.assertFalse(Path(f"/proc/{child['pid']}").exists()) + self.assertIsNone(sibling.poll()) + finally: + self.finish_group(sibling) + + def test_converter_cancellation_joins_child_and_restores_signal_handlers(self): + harness = ("import convert_llama_cpu as c,os,sys;\ntry:\n" + " c.run_converter([sys.executable,'-u','-c',sys.argv[1]],sys.stdout,dict(os.environ),60)\n" + "except c.ConversionCancelled:\n sys.exit(77)\n") + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + with self.subTest(signal=signum), tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "child.log" + with target.open("xb") as log: + process = subprocess.Popen([sys.executable, "-B", "-u", "-c", harness, self.child_source()], + cwd=conversion.HERE, stdout=log, stderr=log, start_new_session=True) + try: + child = self.wait_child(target) + self.assertEqual(child["group"], process.pid) + process.send_signal(signum) + self.assertEqual(process.wait(timeout=5), 77) + self.assertFalse(Path(f"/proc/{child['pid']}").exists()) + finally: + self.finish_group(process) + before = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} + with tempfile.TemporaryFile() as log: + conversion.run_converter([sys.executable, "-c", "pass"], log, dict(os.environ), 5) + self.assertEqual(before, {sig: signal.getsignal(sig) for sig in before}) + + def test_outer_subprocess_timeout_cannot_detach_converter_child_from_fixture_join(self): + harness = ("import convert_llama_cpu as c,os,sys;" + "c.run_converter([sys.executable,'-u','-c',sys.argv[1]],sys.stdout,dict(os.environ),60)") + real_popen, captured = subprocess.Popen, [] + def started(*args, **kwargs): + process = real_popen(*args, **kwargs) + captured.append(process) + return process + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "child.log" + try: + with target.open("xb") as log, mock.patch.object(subprocess, "Popen", side_effect=started): + # This is the actual subprocess.run timeout/SIGKILL behavior + # used by provision.run_checked, not a simulated cancellation. + with self.assertRaises(subprocess.TimeoutExpired): + subprocess.run([sys.executable, "-B", "-u", "-c", harness, self.child_source()], + cwd=conversion.HERE, stdout=log, stderr=log, + start_new_session=True, timeout=.5, check=True) + child = self.wait_child(target) + self.assertEqual(captured[0].returncode, -signal.SIGKILL) + self.assertEqual(child["group"], captured[0].pid) + self.assertEqual(os.getpgid(child["pid"]), captured[0].pid) + self.finish_group(captured[0]) + self.assertFalse(Path(f"/proc/{child['pid']}").exists()) + finally: + if captured: + self.finish_group(captured[0]) + def test_request_is_atomic_explicit_4b_private_only_and_legacy_unchanged(self): self.assertNotIn("inference_backend", WORKER.validate_request(request())) WORKER.validate_request(job()) From 7308371b20ced0504662178beb0e46586cfc9d2d Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:45:26 +0200 Subject: [PATCH 03/39] fix(compute): give native CPU options a unique CLI group --- crates/volparossa/src/compute/native_cpu.rs | 1 + .../src/compute/native_cpu/tests.rs | 111 ++++++++++++++++++ docs/IMPLEMENTATION_STATUS.md | 19 +++ 3 files changed, 131 insertions(+) diff --git a/crates/volparossa/src/compute/native_cpu.rs b/crates/volparossa/src/compute/native_cpu.rs index 781c81eee..e4e341722 100644 --- a/crates/volparossa/src/compute/native_cpu.rs +++ b/crates/volparossa/src/compute/native_cpu.rs @@ -16,6 +16,7 @@ const SOURCE: &str = "7fe450e19305b828c199d602c23a8337aaa1f03b"; const WEIGHTS: &str = "79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf"; #[derive(Clone, Debug, Default, Args)] +#[group(id = "NativeCpuBackendOptions")] pub(super) struct Options { /// Private owner-provisioned CPU backend; never a path supplied by a peer. #[arg(long, requires = "native_backend_sha256")] diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs index 0064c2fec..834336969 100644 --- a/crates/volparossa/src/compute/native_cpu/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -1,8 +1,119 @@ //! Inert admission/binding fixtures only: no executable library or model is loaded. use std::os::unix::fs::{PermissionsExt as _, symlink}; +use clap::{CommandFactory as _, error::ErrorKind}; + use super::*; +#[test] +fn native_flattened_options_keep_full_cli_argument_groups_unique() { + crate::Cli::command().debug_assert(); +} + +fn cli_arguments(operation: &str) -> Vec<&str> { + let mut arguments = vec![ + "volparossa", + "compute", + operation, + "--runtime-root", + "/runtime", + "--model-root", + "/model", + "--model-profile", + "qwen3-4b-instruct-2507-v1", + ]; + arguments.extend(match operation { + "run" => vec![ + "--mode", + "infer", + "--dataset", + "/dataset", + "--output", + "/output", + ], + "private-serve" => vec!["--work-parent", "/work", "--socket", "/private.sock"], + "private-task" => vec!["--input", "/input", "--work-parent", "/work"], + _ => panic!("unexpected inert CLI fixture"), + }); + arguments +} + +#[test] +fn native_cli_pair_requires_both_owner_flags_without_implicit_execution() { + // Parsing only: no path validation, library loading or model admission. + let hash = "a".repeat(64); + for operation in ["run", "private-serve"] { + let original = cli_arguments(operation); + for enabled in [false, true] { + let mut arguments = original.clone(); + if enabled { + arguments.extend([ + "--native-backend-root", + "/native", + "--native-backend-sha256", + &hash, + ]); + } + let parsed = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap(); + let options = parsed + .subcommand_matches("compute") + .unwrap() + .subcommand_matches(operation) + .unwrap(); + assert!(!options.get_flag("execute")); + assert_eq!( + options.get_one::("native_backend_root"), + enabled.then(|| PathBuf::from("/native")).as_ref() + ); + assert_eq!( + options.get_one::("native_backend_sha256"), + enabled.then_some(&hash) + ); + } + for half in [ + ["--native-backend-root", "/native"], + ["--native-backend-sha256", &hash], + ] { + let mut arguments = original.clone(); + arguments.extend(half); + let error = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } + } +} + +#[test] +fn private_task_keeps_native_backend_flags_unavailable() { + let original = cli_arguments("private-task"); + assert!( + crate::Cli::command() + .try_get_matches_from(original.clone()) + .is_ok() + ); + let hash = "a".repeat(64); + for added in [ + vec!["--native-backend-root", "/native"], + vec!["--native-backend-sha256", &hash], + vec![ + "--native-backend-root", + "/native", + "--native-backend-sha256", + &hash, + ], + ] { + let mut arguments = original.clone(); + arguments.extend(added); + let error = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::UnknownArgument); + } +} + fn manifest() -> Value { json!({"version":1,"kind":KIND,"abi_version":1,"source_commit":SOURCE, "model_profile":"qwen3-4b-instruct-2507-v1","source_weights_sha256":WEIGHTS, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c36e3d506..c1fc9d0d2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -64,6 +64,25 @@ again; no assertion or privacy boundary was removed. Retained decoded job `80dbe57d6b939c97c41785c4b7473fd2cc218daa197f017b4157b92501399685`. Skipped later steps are not passing workspace or datapath evidence. +The subsequent [quality run `37235233676`](https://github.com/VOLPAROSSA/volparossa/actions/runs/37235233676) +on `f7e2c3b9` passes strict Clippy, but **fails** four full-command-tree tests: +the flattened native argument structure and its parent both use Clap's default +`Options` group name. The other 578 binary tests pass; later namespace/integration +steps are skipped. All four CodeQL analyses pass separately. Original job +`111532896805` log SHA-256: +`227fc42e300b3515506ebed4f3abab453222bb70659dadef9ef53772b9e35126`. +No native model trial was dispatched on that failed candidate. Correcting the +argument-group identity must retain the paired owner options and all existing +admission rules; source-only CLI checks are not model-execution evidence. + +The follow-up gives only the flattened native options an explicit, unique Clap +group identity. Nine targeted native tests pass, including full-command-tree +validation, both-or-neither owner flags on `run` and `private-serve`, unchanged +explicit execution, and rejection of native flags on `private-task`. The four +original failing command-tree tests also pass locally. The new command-tree test +was observed failing before the correction. These checks do not replace the +next exact-source CI run or the still-pending disposable native model trial. + Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is From 41c2f66437140360f09e706fb684d6f3046ac514 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:10:35 +0200 Subject: [PATCH 04/39] compute: retain closed native conversion failure stages --- .github/workflows/quality.yml | 2 +- docs/IMPLEMENTATION_STATUS.md | 32 ++- workers/volparossa-ml/convert_llama_cpu.py | 70 ++++++- .../test_conversion_diagnostic.py | 184 ++++++++++++++++++ 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 workers/volparossa-ml/test_conversion_diagnostic.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 53685960e..c191adadf 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -59,7 +59,7 @@ jobs: - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml - run: python3 -B -m unittest test_llama_cpu + run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic - name: Install pinned dependency-audit tools run: | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c1fc9d0d2..1348a5e0d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -11,7 +11,7 @@ detailed evidence ledger, including historical failures—not an installation gu [Original v1 scorecard](#fixed-alpha-v1-scorecard) · [Completion requirements](#definition-of-done) -Last updated: 2026-10-04 +Last updated: 2026-10-07 ## Current integration and active work @@ -81,7 +81,35 @@ validation, both-or-neither owner flags on `run` and `private-serve`, unchanged explicit execution, and rejection of native flags on `private-task`. The four original failing command-tree tests also pass locally. The new command-tree test was observed failing before the correction. These checks do not replace the -next exact-source CI run or the still-pending disposable native model trial. +exact-source CI or a disposable native model trial. + +Core `7308371b20ced0504662178beb0e46586cfc9d2d` subsequently passed its +source checks. The first native Code trial `37239050033` stopped during source +build without retained resource/manifest evidence. Its bounded ready/release +follow-up on Code `01c5c591b16ed34226341e04fe673a35a7a169de` and the same core +reached a completed source build in +[trial37614132814](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37614132814). +The live helper's verified library and manifest were retained before successful +exit; observed build peak was 1,031,565,312 bytes, with zero swap/OOM. + +That original attempt remains **failed** at model provisioning with the closed +category `native_conversion` and process status 1. Wheel-graph/runtime-import +checks passed first, but the catch-all conversion marker does not identify which +conversion check or subprocess failed. Build resource counters do not describe +the later conversion. No model provisioning, inference or coding-task success +is claimed. Reported guest units/process-group/private-data and outer QEMU/scratch +cleanup passed; the scoped routes/DNS comparison remained unchanged. +Original artifact ZIP SHA-256: +`d4b32d66e90d4c76568b2fa989cbab5e49bbb736283868a03a81f9ae0303a254`; +job `112768290137` log SHA-256: +`6e561f75a15560a56f3d98c1a49e518c52ad543646ea1463c7f475e5ec2d0ce6`. +The follow-up retains a closed conversion stage, failure category and bounded +child exit status without exception text, paths or raw converter output. The +legacy failure marker remains. Twenty-nine native/conversion fixture tests pass, +independently repeated by root, including inert injection at all thirteen stages +and child failure, timeout and cancellation. Quality runs the new tests as part +of the existing native contract step. This is diagnostic coverage, not a model +conversion fix or permission to rerun the same failed attempt. Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 012368246..545aecabf 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -27,13 +27,52 @@ import provision HERE = Path(__file__).resolve().parent +DIAGNOSTIC_PREFIX = "NATIVE_CONVERSION_DIAGNOSTIC " +DIAGNOSTIC_STAGES = frozenset({"guard", "source", "runtime", "model", "build", "budget", + "convert", "tokenizer", "verify", "recheck", "bundle", "manifest", "permissions", "unknown"}) + + +def child_status(value): + return value if type(value) is int and -128 <= value <= 255 else None + + +def failure_diagnostic(progress, error): + """Closed metadata only; exception text, paths and converter output stay private.""" + if isinstance(error, ConversionCancelled): + category = "cancelled" + elif isinstance(error, (TimeoutError, subprocess.TimeoutExpired)): + category = "timeout" + elif isinstance(error, FileNotFoundError): + category = "missing_file" + elif isinstance(error, PermissionError): + category = "permission" + elif isinstance(error, MemoryError): + category = "memory" + elif isinstance(error, ImportError): + category = "import" + elif isinstance(error, subprocess.SubprocessError): + category = "subprocess" + elif isinstance(error, OSError): + category = "os_error" + elif isinstance(error, (KeyError, TypeError, AttributeError)): + category = "invalid_shape" + elif isinstance(error, (ValueError, provision.ProvisionError)): + category = {"NATIVE_CONVERSION_DEADLINE": "timeout", + "NATIVE_CONVERSION_PROCESS_FAILED": "child_failed", + "NATIVE_CONVERSION_DISK_BUDGET": "disk_budget"}.get(str(error), "contract") + else: + category = "unknown" + return dict(version=1, kind="native-conversion-failure", + stage=progress.get("stage") if type(progress.get("stage")) is str + and progress.get("stage") in DIAGNOSTIC_STAGES else "unknown", + failure=category, child_exit_status=child_status(progress.get("child_exit_status"))) class ConversionCancelled(Exception): """A fixed cancellation reason; no private subprocess command is exported.""" -def run_converter(command, log, environment, timeout): +def run_converter(command, log, environment, timeout, progress=None): """Join our direct child; never detach it from the owner's provision group. TERM/INT/HUP mark cancellation instead of interrupting Popen construction. @@ -65,6 +104,8 @@ def stop(signum, _frame): raise subprocess.TimeoutExpired("native converter", timeout) try: result = process.wait(timeout=min(.1, remaining)) + if progress is not None: + progress["child_exit_status"] = child_status(result) if cancelled is not None: raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") builder.require(result == 0, "NATIVE_CONVERSION_PROCESS_FAILED") @@ -81,6 +122,8 @@ def stop(signum, _frame): process.kill() process.wait(timeout=5) finally: + if progress is not None and process is not None: + progress["child_exit_status"] = child_status(process.poll()) for signum, handler in previous.items(): signal.signal(signum, handler) if cancelled is not None: @@ -221,10 +264,13 @@ def private_bundle_modes(root): raise ValueError("NATIVE_BUNDLE_FILE_TYPE") -def execute(args): +def execute(args, progress=None): # Reuse the existing explicit disposable VM/CI/CPython3.13/fresh-private-root # guard. This tool never relaxes provision's host-safety requirements. + progress = {} if progress is None else progress + progress.update(stage="guard", child_exit_status=None) root, _ = provision.execution_root(args) + progress["stage"] = "source" builder.verify_source(args.source) builder.require(1 <= args.timeout_seconds <= 1800, "NATIVE_CONVERSION_DEADLINE") pins = provision.load_pins(provision.QWEN4B_MODEL_PROFILE, native_cpu_converter=True) @@ -232,9 +278,11 @@ def execute(args): deadline = started + args.timeout_seconds def check(): builder.require(time.monotonic() < deadline, "NATIVE_CONVERSION_DEADLINE") + progress["stage"] = "runtime" for name, expected in (("torch", "2.14.0+cpu"), ("transformers", "5.16.1"), ("peft", "0.20.0"), ("sentencepiece", "0.2.1")): builder.require(importlib.metadata.version(name) == expected, "NATIVE_CONVERTER_RUNTIME_PIN") + progress["stage"] = "model" model = args.model builder.require(model.is_absolute() and model == model.resolve() and model.is_dir(), "NATIVE_MODEL_PATH") builder.require({path.name for path in model.iterdir()} == {item["path"] for item in pins["files"]}, "NATIVE_MODEL_FILES") @@ -243,6 +291,7 @@ def check(): builder.require(builder.digest(model / item["path"]) == {key: item[key] for key in ("sha256", "bytes")}, "NATIVE_ORIGINAL_MODEL_PIN") provision.verify_weight_set(pins, model, deadline) + progress["stage"] = "build" build_root = args.build build_raw = (build_root / "build.json").read_bytes() builder.require(len(build_raw) <= 65536, "NATIVE_BUILD_MANIFEST_BOUND") @@ -258,6 +307,7 @@ def check(): library = build_root / native.LIBRARY builder.require(builder.digest(library) == {key: build["library"][key] for key in ("bytes", "sha256")}, "NATIVE_LIBRARY_PIN") + progress["stage"] = "budget" required = native.WEIGHTS_BYTES + 128 * 1024 ** 2 + build["library"]["bytes"] builder.require(type(args.budget_bytes) is int and required <= args.budget_bytes and shutil.disk_usage(root.parent).free >= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") @@ -269,23 +319,29 @@ def check(): for key in list(env): if key.startswith("LD_") or key in ("PYTHONPATH", "PYTHONHOME"): env.pop(key) + progress["stage"] = "convert" with (root / "conversion.log").open("xb") as log: run_converter([sys.executable, str(args.source / "convert_hf_to_gguf.py"), str(model), - "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic()) + "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic(), progress) check() + progress["stage"] = "tokenizer" sys.path.insert(0, str(args.source / "gguf-py")) import gguf from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(str(model), local_files_only=True, trust_remote_code=False, use_fast=True) + progress["stage"] = "verify" verified = verify_conversion(gguf.GGUFReader(str(output)), model, tokenizer, check) check() + progress["stage"] = "recheck" provision.verify_weight_set(pins, model, deadline) + progress["stage"] = "bundle" shutil.copyfile(library, root / native.LIBRARY) shutil.copyfile(build_root / "build.json", root / "build.json") shutil.copytree(build_root / "licenses", root / "licenses", symlinks=False) shutil.copyfile(model / "LICENSE", root / "licenses" / "Qwen-APACHE-2.0.txt") for notice in provision.native_converter_pins()["notices"]: shutil.copyfile(HERE / notice["path"], root / "licenses" / Path(notice["path"]).name) + progress["stage"] = "manifest" manifest = {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": builder.SOURCE, "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, "source_weights_bytes": native.WEIGHTS_BYTES, @@ -297,6 +353,7 @@ def check(): builder.require(retained + 65536 <= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") check() builder.write_json(root / "backend.json", manifest) + progress["stage"] = "permissions" private_bundle_modes(root) return {"version": 1, "kind": native.KIND, "manifest": builder.digest(root / "backend.json"), "model_inference": False, "downloads": False} @@ -318,9 +375,12 @@ def main(): print(json.dumps({"kind": native.KIND, "source_commit": builder.SOURCE, "model_profile": native.PROFILE, "execute": False, "downloads": False, "model_inference": False})) return + progress = {"stage": "guard", "child_exit_status": None} try: - print(json.dumps(execute(args), sort_keys=True)) - except Exception: + print(json.dumps(execute(args, progress), sort_keys=True)) + except Exception as error: + print(DIAGNOSTIC_PREFIX + json.dumps(failure_diagnostic(progress, error), sort_keys=True), + file=sys.stderr, flush=True) parser.exit(1, "NATIVE_CONVERSION_FAILED\n") diff --git a/workers/volparossa-ml/test_conversion_diagnostic.py b/workers/volparossa-ml/test_conversion_diagnostic.py new file mode 100644 index 000000000..f91061f47 --- /dev/null +++ b/workers/volparossa-ml/test_conversion_diagnostic.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tiny inert diagnostic fixtures; no converter, model, source fetch or host service.""" +from contextlib import ExitStack, redirect_stderr, redirect_stdout +import hashlib +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import convert_llama_cpu as conversion + + +class ConversionDiagnosticTests(unittest.TestCase): + def exercise_stage(self, rejected): + with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: + base = Path(directory) + model, build, source = (base / name for name in ('model', 'build', 'source')) + for path in (model, build, source, build / 'licenses'): + path.mkdir(mode=0o700) + for path in (model / 'weights', model / 'LICENSE', build / conversion.native.LIBRARY): + path.write_bytes(b'inert') + pinned = [{'path': name, 'bytes': 5, 'sha256': hashlib.sha256(b'inert').hexdigest()} + for name in ('weights', 'LICENSE')] + manifest = dict(version=1, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, + abi_version=1, quantization=False, provisionable=True, sanitizers=False, + library=dict(path=conversion.native.LIBRARY, **conversion.builder.digest(build / conversion.native.LIBRARY)), + dynamic_dependencies=['libc.so.6'], + wrapper_sources={name: conversion.builder.digest(conversion.HERE / 'native-cpu' / name) + for name in ('CMakeLists.txt', 'adapter.h', 'adapter.cpp')}) + (build / 'build.json').write_text(json.dumps(manifest)) + progress, visited = {}, [] + def boundary(stage, result=None): + self.assertEqual(progress['stage'], stage) + visited.append(stage) + if stage == rejected: + raise ValueError('CANARY /private/model token') + return result + stack.enter_context(mock.patch.object(conversion.provision, 'execution_root', + side_effect=lambda _args: boundary('guard', (base / 'output', 'inert')))) + stack.enter_context(mock.patch.object(conversion.builder, 'verify_source', + side_effect=lambda _path: boundary('source', 'inert-tree'))) + stack.enter_context(mock.patch.object(conversion.provision, 'load_pins', return_value={'files': pinned})) + versions = {'torch': '2.14.0+cpu', 'transformers': '5.16.1', 'peft': '0.20.0', 'sentencepiece': '0.2.1'} + stack.enter_context(mock.patch.object(conversion.importlib.metadata, 'version', + side_effect=lambda name: boundary('runtime', versions[name]))) + original_digest = conversion.builder.digest + def digest(path): + if path.parent == model: + boundary('model') + elif path == build / conversion.native.LIBRARY: + boundary('build') + return original_digest(path) + stack.enter_context(mock.patch.object(conversion.builder, 'digest', side_effect=digest)) + def weights(*_args): + if progress['stage'] == 'recheck': + boundary('recheck') + stack.enter_context(mock.patch.object(conversion.provision, 'verify_weight_set', side_effect=weights)) + stack.enter_context(mock.patch.object(conversion.shutil, 'disk_usage', + side_effect=lambda _path: boundary('budget', SimpleNamespace(free=12 * 1024**3)))) + def converter(_command, _log, _environment, _timeout, observed): + self.assertIs(observed, progress) + boundary('convert') + progress['child_exit_status'] = 0 + (base / 'output/model.gguf').write_bytes(b'inert') + stack.enter_context(mock.patch.object(conversion, 'run_converter', side_effect=converter)) + tokenizer = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained= + lambda *_args, **_kwargs: boundary('tokenizer', object()))) + stack.enter_context(mock.patch.dict(sys.modules, {'gguf': SimpleNamespace(GGUFReader=lambda _path: object()), + 'transformers': tokenizer})) + stack.enter_context(mock.patch.object(conversion, 'verify_conversion', + side_effect=lambda *_args: boundary('verify', {}))) + original_copy = conversion.shutil.copyfile + def copyfile(src, dst): + boundary('bundle') + return original_copy(src, dst) + stack.enter_context(mock.patch.object(conversion.shutil, 'copyfile', side_effect=copyfile)) + stack.enter_context(mock.patch.object(conversion.provision, 'native_converter_pins', return_value={'notices': []})) + stack.enter_context(mock.patch.object(conversion.native, 'validate_manifest', + side_effect=lambda *_args: boundary('manifest'))) + stack.enter_context(mock.patch.object(conversion, 'private_bundle_modes', + side_effect=lambda _path: boundary('permissions'))) + args = SimpleNamespace(root=str(base / 'output'), source=source, build=build, model=model, + budget_bytes=10 * 1024**3, timeout_seconds=30) + try: + conversion.execute(args, progress) + except ValueError as error: + self.assertEqual(str(error), 'CANARY /private/model token') + return progress, conversion.failure_diagnostic(progress, error), visited + self.fail('inert stage refusal must not become success') + + def test_actual_pipeline_records_each_failure_stage_without_model_execution(self): + stages = ['guard', 'source', 'runtime', 'model', 'build', 'budget', 'convert', + 'tokenizer', 'verify', 'recheck', 'bundle', 'manifest', 'permissions'] + original_path = list(sys.path) + try: + for stage in stages: + with self.subTest(stage=stage): + progress, diagnostic, visited = self.exercise_stage(stage) + self.assertEqual(progress['stage'], stage) + self.assertEqual(visited[-1], stage) + self.assertEqual(diagnostic['stage'], stage) + self.assertEqual(diagnostic['failure'], 'contract') + self.assertNotIn('CANARY', json.dumps(diagnostic)) + self.assertNotIn('/private', json.dumps(diagnostic)) + finally: + sys.path[:] = original_path + + def test_child_nonzero_timeout_and_cancellation_retain_bounded_final_status(self): + signals = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) + before = {item: signal.getsignal(item) for item in signals} + for failure in ('nonzero', 'timeout', 'cancelled'): + with self.subTest(failure=failure), ExitStack() as stack: + progress = {'stage': 'convert'} + child = mock.Mock(pid=os.getpid()) + child.wait.return_value = child.poll.return_value = 7 + expected, status = 'child_failed', 7 + if failure == 'timeout': + stack.enter_context(mock.patch.object(conversion.time, 'monotonic', side_effect=[0, 2])) + child.poll.side_effect = [None, -15] + child.wait.return_value = -15 + expected, status = 'timeout', -15 + elif failure == 'cancelled': + def cancel(**_kwargs): + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + return 0 + child.wait.side_effect = cancel + child.poll.return_value = 0 + expected, status = 'cancelled', 0 + stack.enter_context(mock.patch.object(conversion.subprocess, 'Popen', return_value=child)) + with self.assertRaises(Exception) as result: + conversion.run_converter(['inert'], io.BytesIO(), {}, 1, progress) + diagnostic = conversion.failure_diagnostic(progress, result.exception) + self.assertEqual((diagnostic['failure'], diagnostic['child_exit_status']), (expected, status)) + self.assertEqual({item: signal.getsignal(item) for item in signals}, before) + + def test_main_emits_one_closed_failure_and_keeps_legacy_marker(self): + argv = ['convert', '--source', '/source', '--build', '/build', '--model', '/model', + '--root', '/output', '--budget-bytes', '1', '--execute', '--yes', '--disposable-guest'] + def fail(_args, progress): + progress.update(stage='verify', child_exit_status=0) + raise ValueError('CANARY /private/model') + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(sys, 'argv', argv), mock.patch.object(conversion, 'execute', side_effect=fail), \ + redirect_stdout(out), redirect_stderr(err), self.assertRaises(SystemExit) as stopped: + conversion.main() + self.assertEqual(stopped.exception.code, 1) + self.assertEqual(out.getvalue(), '') + lines = err.getvalue().splitlines() + self.assertEqual(len(lines), 2) + self.assertEqual(lines[1], 'NATIVE_CONVERSION_FAILED') + self.assertTrue(lines[0].startswith(conversion.DIAGNOSTIC_PREFIX)) + value = json.loads(lines[0][len(conversion.DIAGNOSTIC_PREFIX):]) + self.assertEqual(value, dict(version=1, kind='native-conversion-failure', stage='verify', + failure='contract', child_exit_status=0)) + self.assertNotIn('CANARY', err.getvalue()) + self.assertNotIn('/private', err.getvalue()) + + def test_failures_and_unknown_values_never_export_exception_text(self): + cases = [(FileNotFoundError('CANARY'), 'missing_file'), (PermissionError('CANARY'), 'permission'), + (MemoryError('CANARY'), 'memory'), (ImportError('CANARY'), 'import'), + (subprocess.CalledProcessError(7, ['CANARY']), 'subprocess'), (OSError('CANARY'), 'os_error'), + (KeyError('CANARY'), 'invalid_shape'), (TypeError('CANARY'), 'invalid_shape'), + (ValueError('NATIVE_CONVERSION_DISK_BUDGET'), 'disk_budget'), + (conversion.provision.ProvisionError('CANARY'), 'contract'), (RuntimeError('CANARY'), 'unknown')] + for error, expected in cases: + with self.subTest(expected=expected): + value = conversion.failure_diagnostic({'stage': ['CANARY'], 'child_exit_status': True}, error) + self.assertEqual(value['failure'], expected) + self.assertEqual(value['stage'], 'unknown') + self.assertIsNone(value['child_exit_status']) + self.assertNotIn('CANARY', json.dumps(value)) + for status in (-129, 256, True, '0'): + self.assertIsNone(conversion.child_status(status)) + + +if __name__ == '__main__': + unittest.main() From 4f063742937f2fea6a95104e800570f06418e805 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:19:36 +0200 Subject: [PATCH 05/39] Verify effective Qwen3 rotary dimensions against pinned loader --- docs/IMPLEMENTATION_STATUS.md | 21 ++++++++++++ workers/volparossa-ml/convert_llama_cpu.py | 38 ++++++++++++++++------ workers/volparossa-ml/test_llama_cpu.py | 30 +++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 228562f59..99b6e3877 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -111,6 +111,27 @@ and child failure, timeout and cancellation. Quality runs the new tests as part of the existing native contract step. This is diagnostic coverage, not a model conversion fix or permission to rerun the same failed attempt. +The exact diagnostic follow-up on core `7739206361aa0aeb7cf0b20d9f8cf1cc9da5c81b` +and Code `61d2c0c52468eee7728cf6f32a107baca5bae240` also remains **failed**: +[trial37623495917](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37623495917) +reached verification after the converter exited with status zero. Its closed +diagnostic reports `stage=verify`, `failure=contract`; it does not identify the +first rejected assertion. No model was admitted and inference did not run. +Reported process/unit/private-data and QEMU/scratch cleanup passed; the scoped +route/DNS comparison was unchanged. Original artifact ZIP SHA-256: +`50ad3ba19fe9811e132f2dad7ee3c49407d5fcecc86a65a816d7a0637eab996e`; +job `112799334236` log SHA-256: +`24bcefeb3279b24e011008fecca2037a0808dac2af82587527c1d378759c86a3`. + +Independent source review found a verifier incompatibility: the pinned Qwen3 +converter omits `rope.dimension_count`, while its loader defaults that value to +the explicit attention key length. The follow-up checks that effective rotary +dimension, key length and value length all equal the original model's head +dimension. Wrong explicit overrides and every missing required field still fail. +Tensor values, model pins, tokenizer checks and resource limits are unchanged. +This is a source-proven correction, not proof that it was the first failing check +in the original trial or that full model conversion now succeeds. + Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 545aecabf..3538ce8d2 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -198,6 +198,33 @@ def compare_tensor(stream, offset, source, target, check): "NATIVE_TENSOR_VALUE_CHANGED") +def verify_model_parameters(reader, config): + """Verify effective parameters of the exact pinned Qwen3 loader. + + The pinned converter omits rope.dimension_count for Qwen3. Its loader + defaults that one optional value to attention.key_length; Qwen3 requires + key, value and rotary dimensions to agree. Missing required fields still + fail, and an explicit rotary override must match the original head_dim. + """ + def field(name): + builder.require(name in reader.fields, "NATIVE_GGUF_METADATA") + return reader.fields[name].contents() + parameters = {"context_length": config["max_position_embeddings"], "embedding_length": config["hidden_size"], + "block_count": config["num_hidden_layers"], "feed_forward_length": config["intermediate_size"], + "attention.head_count": config["num_attention_heads"], "attention.head_count_kv": config["num_key_value_heads"], + "attention.key_length": config["head_dim"], "attention.value_length": config["head_dim"], + "rope.freq_base": config["rope_theta"], + "attention.layer_norm_rms_epsilon": config["rms_norm_eps"]} + for key, expected in parameters.items(): + if key in ("rope.freq_base", "attention.layer_norm_rms_epsilon"): + expected = struct.unpack(" Date: Wed, 7 Oct 2026 16:26:36 +0200 Subject: [PATCH 06/39] fix(compute): service owner controls during complete weight verification --- docs/IMPLEMENTATION_STATUS.md | 32 +++ workers/volparossa-ml/llama_cpu.py | 10 +- .../volparossa-ml/test_grounded_synthesis.py | 3 +- workers/volparossa-ml/test_llama_cpu.py | 204 +++++++++++++++++- workers/volparossa-ml/test_worker_protocol.py | 12 +- workers/volparossa-ml/worker.py | 33 +-- 6 files changed, 266 insertions(+), 28 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index aa524f9c3..69f7c1302 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -132,6 +132,38 @@ Tensor values, model pins, tokenizer checks and resource limits are unchanged. This is a source-proven correction, not proof that it was the first failing check in the original trial or that full model conversion now succeeds. +The following exact-source [trial 37631532611](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37631532611) +on Code `8a7034d5e09cf34f8ac67742f347eb657f769440` and core +`10fd16dc78a0c91dee627b9d8bdab2226f2cc415` **fails during execution**, +after a successful source build, conversion and model provisioning. The verified +original BF16 GGUF contains 8,051,283,488 bytes (SHA-256 +`d7934f6fa06f9781bc15540f0e2b83f89b1895e86440a4e97af4f0434311d1bd`). +The worker reaches `verify_files/begin` and then reports +`compute_control_ack_deadline`: an I/O-pressure pause was issued at 19.12, +but only the preceding resume was acknowledged. Source review found no owner +control checkpoint in the full-weight hash loop or subsequent native bundle and +post-execution verification. The follow-up processes controls on the execution +thread between the unchanged 1 MiB hash reads, including initial preparation, +native bundle validation and post-execution rehashing. Every original byte, +digest comparison, pressure threshold and deadline remains required. All 138 +focused worker/native tests pass, independently repeated by root; real files and +owner-control pipes prove pause/resume, cancellation without a partial result, +unchanged deadlines and tamper rejection. A single blocked read or other +non-cooperative operation can still exceed the existing deadline. This is a +tested control-path correction, not a successful repeat large-model trial. + +No native model turn or tool call completed. Six OpenCode requests failed within +this one workflow, all with cleanup confirmed; no workflow retry was dispatched. +Core peak memory was 2,303,844,352 bytes, with zero OOM kills and swap. Reported +unit/process/private-data/QEMU/scratch cleanup passed; scoped host-state files +match and no raw model output was exported. This is conversion/provisioning +evidence, not inference, successful coding or protected peer execution. +Original artifact ZIP SHA-256: +`78c4732774fc93dae9872df0460e2addf7f3849a6a190f803069fbc7d6a6e9c8`; +job `112826781182` log SHA-256: +`ef7d5c7bbeeebf497ec9a42881746a782b36f7c7bddc9febef11d7af373cb44f`. +All earlier failed trials and their consumed dispatch receipts remain preserved. + Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index 3583d03b4..9681b99f4 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -53,7 +53,7 @@ def validate_manifest(value, require): return value -def verified_bundle(request, worker, tokenizer): +def verified_bundle(request, worker, tokenizer, session): require = worker.require require(request.get("inference_backend") == KIND and request.get("model_profile") == PROFILE and request["mode"] == "private_conversation", "NATIVE_BACKEND_SCOPE") @@ -66,7 +66,7 @@ def verified_bundle(request, worker, tokenizer): value = validate_manifest(worker.parse_json(raw), require) for field in ("library", "gguf"): item = value[field] - require(worker.file_hash(root / item["path"], expected_size=item["bytes"])["sha256"] == item["sha256"], + require(worker.file_hash(root / item["path"], expected_size=item["bytes"], session=session)["sha256"] == item["sha256"], "NATIVE_ARTIFACT_DIGEST") build = worker.read_bounded(root / "build.json", 65536) require(hashlib.sha256(build).hexdigest() == value["build_manifest_sha256"], "NATIVE_BUILD_DIGEST") @@ -231,7 +231,7 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", "NATIVE_GREEDY_POLICY_REQUIRED") profile = worker.model_profile(PROFILE) - root, manifest, identity = verified_bundle(request, worker, tokenizer) + root, manifest, identity = verified_bundle(request, worker, tokenizer, session) session.private_progress("prompt_encode", "begin") prompt = worker.conversation_module().encode(tokenizer, dataset, profile) session.private_progress("prompt_encode", "complete") @@ -250,8 +250,8 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo finally: model.close() session.private_progress("verify_after", "begin") - weight_identity = worker.verify_sharded_weights(model_root, profile) - worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"])["sha256"] + weight_identity = worker.verify_sharded_weights(model_root, profile, session=session) + worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"], session=session)["sha256"] == manifest["gguf"]["sha256"], "NATIVE_GGUF_CHANGED") session.private_progress("verify_after", "complete") result = {"version": 1, "id": request["id"], "kind": "result", "status": "ok", "mode": request["mode"], diff --git a/workers/volparossa-ml/test_grounded_synthesis.py b/workers/volparossa-ml/test_grounded_synthesis.py index e4b8791a4..abf753ef7 100644 --- a/workers/volparossa-ml/test_grounded_synthesis.py +++ b/workers/volparossa-ml/test_grounded_synthesis.py @@ -162,7 +162,8 @@ def test_real_prepare_files_identity_binds_v5_and_planning_original_source_bytes (model / name).touch(mode=0o600) (model / "config.json").write_text(json.dumps(profile["config"])) # File/hash doubles exercise worker identity logic, never model data or load. - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) for mode, value in (("infer", grounded()), ("plan_document", planning(grounded()))): diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py index b62267991..073c31d7a 100644 --- a/workers/volparossa-ml/test_llama_cpu.py +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -12,6 +12,7 @@ import subprocess import sys import tempfile +import threading import time from types import SimpleNamespace import unittest @@ -20,7 +21,8 @@ import build_llama_cpu as builder import convert_llama_cpu as conversion import llama_cpu as native -from test_worker_protocol import WORKER, request, controlled_pipe, control +from test_worker_protocol import WORKER, request, controlled_pipe, control, dataset +from test_qwen4b_weights import synthetic_shards def job(): @@ -39,6 +41,147 @@ def manifest(): "chat_template_sha256": "d" * 64}, "build_manifest_sha256": "e" * 64} +class HashOwnerControlTests(unittest.TestCase): + def hash_with_control(self, action): + """Real files and owner pipes; never a model or a resource-pressure trial.""" + block = 1024 * 1024 + raw = b"a" * block + b"b" * block + b"c" * 17 + paused, expired = threading.Event(), threading.Event() + reads, results, errors = [], [], [] + reading = False + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, wire): + path = Path(directory) / "inert-weights" + path.write_bytes(raw) + os.write(writer, control(1)) + session.check() + session.request["max_seconds"] = 1 + original_start = session.started + aggregate = hashlib.sha256(b"prefix") + original_open, original_emit = WORKER.os.fdopen, WORKER.emit + + class ObservedReader: + def __init__(self, descriptor, mode): + self.source = original_open(descriptor, mode) + + def __enter__(self): + return self + + def __exit__(self, *args): + return self.source.__exit__(*args) + + def fileno(self): + return self.source.fileno() + + def read(self, size): + nonlocal reading + self_test.assertEqual(size, block) + reading = True + try: + data = self.source.read(size) + finally: + reading = False + reads.append(len(data)) + if len(reads) == 1: + os.write(writer, control(2, "cancel" if action == "cancel" else "pause")) + return data + + self_test = self + + def emit(record): + self.assertFalse(reading, "no ACK while a read is executing") + original_emit(record) + if record.get("phase") == "paused": + paused.set() + + def hash_file(): + try: + results.append(WORKER.file_hash(path, expected_size=len(raw), aggregate=aggregate, + session=session)) + except BaseException as error: + errors.append(error) + + with mock.patch.object(WORKER.os, "fdopen", ObservedReader), \ + mock.patch.object(WORKER, "emit", emit), \ + mock.patch.object(session, "elapsed", side_effect=lambda: 1001 if expired.is_set() else 0): + thread = threading.Thread(target=hash_file) + thread.start() + try: + if action != "cancel": + self.assertTrue(paused.wait(3), "missing pause ACK between hash reads") + self.assertTrue(thread.is_alive()) + self.assertEqual(reads, [block], "paused hashing must not read another chunk") + self.assertEqual(results, []) + if action == "resume": + os.write(writer, control(3, "resume")) + else: + expired.set() + thread.join(3) + self.assertFalse(thread.is_alive(), "hash worker did not finish bounded control") + finally: + if thread.is_alive(): + os.write(writer, control(session.sequence + 1, "cancel")) + thread.join(3) + self.assertEqual(session.started, original_start, "pause must not reset the deadline") + records = [json.loads(line) for line in wire.getvalue().splitlines()] + if action == "resume": + self.assertEqual(errors, []) + self.assertEqual(results, [{"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}]) + self.assertEqual(aggregate.hexdigest(), hashlib.sha256(b"prefix" + raw).hexdigest()) + self.assertEqual(reads, [block, block, 17, 0]) + self.assertEqual([row["phase"] for row in records], ["resumed", "paused", "resumed"]) + else: + self.assertEqual(results, []) + self.assertEqual(reads, [block]) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], WORKER.JobError) + self.assertEqual(str(errors[0]), "JOB_CANCELLED" if action == "cancel" else "JOB_DEADLINE_EXCEEDED") + + def test_hash_pause_resume_preserves_every_byte_and_aggregate(self): + self.hash_with_control("resume") + + def test_hash_cancel_does_not_return_a_partial_digest(self): + self.hash_with_control("cancel") + + def test_hash_pause_does_not_extend_original_deadline(self): + self.hash_with_control("deadline") + + def test_prepare_and_shards_forward_session_and_still_reject_tampering(self): + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, _): + root = Path(directory) + model, output = root / "model", root / "output" + model.mkdir(mode=0o700) + output.mkdir(mode=0o700) + profile, _ = synthetic_shards(model) + config = b"{}" + (model / "config.json").write_bytes(config) + profile["config"] = {} + profile["files"]["config.json"] = len(config) + profile["hashes"]["config.json"] = hashlib.sha256(config).hexdigest() + profile["max_rows"] = 4 + data = root / "input.json" + data.write_text(json.dumps(dataset())) + selected = dict(request(), mode="infer", model_root=str(model), output_root=str(output), + dataset_path=str(data)) + os.write(writer, control(1)) + hashed = [] + original = WORKER.file_hash + + def checked(path, *args, **kwargs): + self.assertIs(kwargs.get("session"), session) + hashed.append(path.name) + return original(path, *args, **kwargs) + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "file_hash", side_effect=checked): + prepared = WORKER.prepare_files(selected, session=session) + self.assertEqual(set(hashed), set(profile["files"])) + self.assertEqual(set(prepared[4]), set(profile["files"])) + damaged = model / profile["weight_shards"][-1] + damaged.write_bytes(b"x" * damaged.stat().st_size) + with self.assertRaisesRegex(WORKER.JobError, "MODEL_WEIGHTS_CHANGED_ON_DISK"): + WORKER.prepare_files(selected, session=session) + + class NativeCpuTests(unittest.TestCase): def test_effective_qwen3_rotary_dimension_matches_pinned_loader_without_weakening_required_fields(self): config = dict(max_position_embeddings=262144, hidden_size=2560, num_hidden_layers=36, @@ -245,7 +388,8 @@ def test_manifest_closed_shape_digest_scope_and_exact_proof_flags(self): with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(self): - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, _): + os.write(writer, control(1)) root = Path(directory) value = manifest() template = "original template" @@ -262,15 +406,18 @@ def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(s raw = json.dumps(value).encode() (root / "backend.json").write_bytes(raw) selected = dict(job(), native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) - with mock.patch.object(native.ctypes, "CDLL", side_effect=AssertionError("validation must not execute library")): - _, _, identity = native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template)) + with mock.patch.object(native.ctypes, "CDLL", side_effect=AssertionError("validation must not execute library")), \ + mock.patch.object(WORKER, "file_hash", wraps=WORKER.file_hash) as hashed: + _, _, identity = native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template), session) self.assertEqual(identity["gguf_sha256"], value["gguf"]["sha256"]) + self.assertEqual(len(hashed.call_args_list), 2) + self.assertTrue(all(call.kwargs.get("session") is session for call in hashed.call_args_list)) with self.assertRaisesRegex(WORKER.JobError, "MANIFEST_DIGEST"): native.verified_bundle(dict(selected, native_backend_sha256="0" * 64), WORKER, - SimpleNamespace(chat_template=template)) + SimpleNamespace(chat_template=template), session) (root / native.LIBRARY).write_bytes(b"x" * value["library"]["bytes"]) with self.assertRaisesRegex(WORKER.JobError, "ARTIFACT_DIGEST"): - native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template)) + native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template), session) for field in ("tensor_values_equal", "tokenizer_equal"): value = manifest(); value["verification"][field] = 1 with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) @@ -278,6 +425,51 @@ def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(s value = manifest(); value["library"]["path"] = name with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) + def test_native_verify_after_checks_original_shards_and_gguf_with_same_session(self): + # Inert native doubles reach the actual post-execution hashing path; + # these tests do not load a model or claim successful inference. + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, _): + root = Path(directory) + model_root = root / "weights" + model_root.mkdir() + profile, _ = synthetic_shards(model_root) + profile["new_tokens"], profile["id"], profile["revision"] = 1, "test", "test" + data = b"inert GGUF bytes" + (root / "model.gguf").write_bytes(data) + value = manifest() + value["gguf"].update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest()) + bridge, model = mock.Mock(), mock.Mock() + bridge.generation_policy.return_value = "greedy_v1" + bridge.encode.return_value = [1] + selected = WORKER.validate_request(job()) + os.write(writer, control(1)) + original_hash = WORKER.file_hash + hashed = [] + + def checked(path, *args, **kwargs): + model.close.assert_called_once() + self.assertIs(kwargs.get("session"), session) + hashed.append(path.name) + return original_hash(path, *args, **kwargs) + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ + mock.patch.object(WORKER, "file_hash", side_effect=checked), \ + mock.patch.object(WORKER, "finish_result", side_effect=WORKER.JobError("POST_HASH_REACHED")), \ + mock.patch.object(native, "verified_bundle", return_value=(root, value, {})) as bundle, \ + mock.patch.object(native, "NativeModel", return_value=model), \ + mock.patch.object(native, "generate", return_value={}): + with self.assertRaisesRegex(WORKER.JobError, "POST_HASH_REACHED"): + native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, + {}, {}, {}, WORKER) + self.assertIs(bundle.call_args.args[-1], session) + self.assertEqual(set(hashed), set(profile["files"]) | {"model.gguf"}) + model.reset_mock() + (root / "model.gguf").write_bytes(b"x" * len(data)) + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_GGUF_CHANGED"): + native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, + {}, {}, {}, WORKER) + def test_pause_and_resume_are_not_acknowledged_until_native_has_joined(self): with controlled_pipe() as (session, writer, wire): os.write(writer, control(1)); session.check() diff --git a/workers/volparossa-ml/test_worker_protocol.py b/workers/volparossa-ml/test_worker_protocol.py index 01edb4516..ea479267c 100644 --- a/workers/volparossa-ml/test_worker_protocol.py +++ b/workers/volparossa-ml/test_worker_protocol.py @@ -255,7 +255,8 @@ def test_large_profile_checks_exact_original_assets_and_architecture(self): source.write_text(json.dumps(dict(task_plan_input(), model_profile=WORKER.LARGE_MODEL_PROFILE))) value = WORKER.validate_request(dict(request(), mode="plan_tasks", model_profile=WORKER.LARGE_MODEL_PROFILE, model_root=str(model), output_root=str(output), dataset_path=str(source))) - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) with mock.patch.object(WORKER, "file_hash", side_effect=file_hash): @@ -436,7 +437,8 @@ def test_private_input_identity_binds_original_bytes_without_public_metadata(sel source.write_bytes(raw) value = WORKER.validate_request(dict(request(), mode="private_infer", model_profile=WORKER.LARGE_MODEL_PROFILE, model_root=str(model), output_root=str(output), dataset_path=str(source))) - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) with mock.patch.object(WORKER, "file_hash", side_effect=file_hash): @@ -485,7 +487,7 @@ def test_private_dispatch_has_one_real_generation_shape_no_training_and_only_eph tokens = [21, 2] if stop == "eos" else [21] * profile["new_tokens"] model, tokenizer, torch, transformers = task_planner_doubles(text, generated=tokens) value = WORKER.validate_request(dict(request(), mode="private_infer", model_profile=profile_name, output_root=str(root))) - with mock.patch.object(WORKER, "prepare_files", return_value=(root/"model", root, source, identity, files)), \ + with mock.patch.object(WORKER, "prepare_files", return_value=(root/"model", root, source, identity, files)) as prepared, \ mock.patch.object(WORKER, "configure_offline"), \ mock.patch.object(WORKER, "load_backend", return_value=(torch, transformers, mock.Mock(), WORKER.BACKENDS)) as backend, \ mock.patch.object(WORKER, "load_model", return_value=model) as loader, \ @@ -496,9 +498,11 @@ def test_private_dispatch_has_one_real_generation_shape_no_training_and_only_eph mock.patch.object(WORKER, "WIRE_OUTPUT", io.StringIO()): session = WORKER.Session(value) result = WORKER.execute_job(value, session) + prepared.assert_called_once_with(value, session=session) backend.assert_called_once_with(value["threads"], session) loader.assert_called_once_with(transformers, torch, root/"model", profile_name) - weights.assert_called_once_with(root/"model/model.safetensors", profile["files"]["model.safetensors"]) + weights.assert_called_once_with(root/"model/model.safetensors", profile["files"]["model.safetensors"], + session=session) self.assertEqual(result["mode"], "private_infer") self.assertEqual(result["dataset"], identity) self.assertEqual(result["model"], dict(id=profile["id"], revision=profile["revision"], files=files)) diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index 7b4955ded..cc0e8a2d1 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -776,7 +776,12 @@ def plain_path(value, directory): return path, metadata -def file_hash(path, expected_size=None, maximum=None, aggregate=None): +def file_hash(path, expected_size=None, maximum=None, aggregate=None, *, session=None): + # Hash every original byte, but service owner controls on this execution + # thread between bounded reads. A pause is never acknowledged while a read + # or digest update is running, and does not reset the original job deadline. + if session is not None: + session.check() descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) with os.fdopen(descriptor, "rb") as source: metadata = os.fstat(source.fileno()) @@ -785,6 +790,8 @@ def file_hash(path, expected_size=None, maximum=None, aggregate=None): require(maximum is None or metadata.st_size <= maximum, "ARTIFACT_TOO_LARGE") digest, length = hashlib.sha256(), 0 while True: + if session is not None: + session.check() data = source.read(1024 * 1024) if not data: break @@ -793,6 +800,8 @@ def file_hash(path, expected_size=None, maximum=None, aggregate=None): digest.update(data) if aggregate is not None: aggregate.update(data) + if session is not None: + session.check() require(length == metadata.st_size, "ARTIFACT_CHANGED") return {"bytes": length, "sha256": digest.hexdigest()} @@ -806,7 +815,7 @@ def read_bounded(path, maximum): return value -def verify_sharded_weights(model_root, profile, verified_files=None): +def verify_sharded_weights(model_root, profile, verified_files=None, *, session=None): """Hash the original shard bytes in pinned order, never the index as weights. The existing identity wire field remains a SHA256 over actual weight bytes. @@ -814,7 +823,7 @@ def verify_sharded_weights(model_root, profile, verified_files=None): """ shards = profile["weight_shards"] index_name = "model.safetensors.index.json" - index_file = file_hash(model_root / index_name, profile["files"][index_name]) + index_file = file_hash(model_root / index_name, profile["files"][index_name], session=session) require(index_file["sha256"] == profile["hashes"][index_name], "MODEL_FILES_NOT_PINNED") if verified_files is not None: verified_files[index_name] = index_file @@ -824,7 +833,7 @@ def verify_sharded_weights(model_root, profile, verified_files=None): and set(index["weight_map"].values()) == set(shards), "INVALID_WEIGHT_SHARD_INDEX") aggregate, total = hashlib.sha256(), 0 for name in shards: - checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate) + checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate, session=session) require(checked["sha256"] == profile["hashes"][name], "MODEL_WEIGHTS_CHANGED_ON_DISK") if verified_files is not None: verified_files[name] = checked @@ -835,7 +844,7 @@ def verify_sharded_weights(model_root, profile, verified_files=None): return identity -def prepare_files(request): +def prepare_files(request, session=None): profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) model_root, model_metadata = plain_path(request["model_root"], True) @@ -851,10 +860,10 @@ def prepare_files(request): sharded = set(profile.get("weight_shards", ())) if sharded: sharded.add("model.safetensors.index.json") - files = {name: file_hash(model_root / name, expected_size=size) + files = {name: file_hash(model_root / name, expected_size=size, session=session) for name, size in profile["files"].items() if name not in sharded} if sharded: - verify_sharded_weights(model_root, profile, files) + verify_sharded_weights(model_root, profile, files, session=session) require(all(files[name]["sha256"] == expected for name, expected in profile["hashes"].items()), "MODEL_FILES_NOT_PINNED") config = parse_json(read_bounded(model_root / "config.json", 8192)) @@ -1997,10 +2006,10 @@ def execute_private_infer(request, session, tokenizer, torch, transformers, vers session.private_progress("generation", "complete") session.private_progress("verify_after", "begin") if "weight_shards" in profile: - weight_identity = verify_sharded_weights(model_root, profile) + weight_identity = verify_sharded_weights(model_root, profile, session=session) else: weight_identity = None - require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"])["sha256"] + require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"], session=session)["sha256"] == profile["hashes"]["model.safetensors"], "MODEL_WEIGHTS_CHANGED_ON_DISK") session.private_progress("verify_after", "complete") result = {"version": VERSION, "id": request["id"], "kind": "result", "status": "ok", "mode": request["mode"], @@ -2037,10 +2046,10 @@ def execute_code_proposal(request, session, tokenizer, torch, transformers, vers outputs = generate(model, samples, tokenizer, torch, session, transformers, profile_name, generation_policy="greedy_v1") if "weight_shards" in profile: - weight_identity = verify_sharded_weights(model_root, profile) + weight_identity = verify_sharded_weights(model_root, profile, session=session) else: weight_identity = None - require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"])["sha256"] + require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"], session=session)["sha256"] == profile["hashes"]["model.safetensors"], "MODEL_WEIGHTS_CHANGED_ON_DISK") result = {"version": VERSION, "id": request["id"], "kind": "result", "status": "ok", "mode": "public_code_proposal", "purpose": "code_proposal", @@ -2300,7 +2309,7 @@ def execute_job(request, session): session.progress("preparing") session.private_progress("owner_gate", "complete") session.private_progress("verify_files", "begin") - model_root, output_root, dataset, data_identity, model_files = prepare_files(request) + model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session) session.private_progress("verify_files", "complete") session.check() cohort = None From f4cd8bd7ca310794dfc73cfcd096d6baedcf73bf Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:01:46 +0200 Subject: [PATCH 07/39] fix(compute): bound complete native tensor validation and preserve load phase --- THIRD_PARTY_LICENSES.md | 22 +++- .../src/compute/supervise/diagnostic.rs | 16 +++ docs/IMPLEMENTATION_STATUS.md | 45 ++++++++ .../integration/agent-private-conversation.py | 5 + .../test-agent-private-conversation.py | 17 +++ workers/volparossa-ml/build_llama_cpu.py | 90 +++++++++++++++- workers/volparossa-ml/convert_llama_cpu.py | 6 +- workers/volparossa-ml/llama_cpu.py | 44 +++++++- .../volparossa-ml/native-cpu/CMakeLists.txt | 33 ++++++ workers/volparossa-ml/native-cpu/README.md | 35 +++++- .../volparossa-ml/native-cpu/abi-smoke.cpp | 3 + workers/volparossa-ml/native-cpu/adapter.cpp | 10 ++ workers/volparossa-ml/native-cpu/adapter.h | 8 ++ .../bounded-tensor-validation.patch | 32 ++++++ .../native-cpu/loader-validation.cpp | 90 ++++++++++++++++ .../test_conversion_diagnostic.py | 10 +- workers/volparossa-ml/test_llama_cpu.py | 101 +++++++++++++++++- 17 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch create mode 100644 workers/volparossa-ml/native-cpu/loader-validation.cpp diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 59361e85e..8e96d2f05 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -9,14 +9,32 @@ for the license text shipped by each upstream project. The opt-in CPU backend uses [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp) at exact commit `7fe450e19305b828c199d602c23a8337aaa1f03b` (resolved release tag `v0.5.0`; MIT). The tag is unsigned. No prebuilt upstream executable, server, -GPU backend or implicit artifact download is used. Original code is unpatched; -the GPL-3.0-only adapter exposes a fixed owned C ABI and source-build flags. +GPU backend or implicit artifact download is used. The original checkout remains +unchanged; the build applies the explicitly recorded loader-only patch below to +a fresh staged copy. The GPL-3.0-only adapter exposes a fixed owned C ABI and +source-build flags. The builder preserves tracked upstream licenses/notices, including ggml and the Mozilla tinyBLAS notice, unchanged in the generated artifact. Static GCC runtime archives retain measured hashes and the original Debian GCC copyright/runtime-exception notice; compiler versions are recorded, not silently treated as upstream llama.cpp source or reproducible-build proof. +`workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch` changes both +asynchronous tensor-validation launches to deferred, single-thread execution and +polls the existing cancellation callback between validation results. It preserves +every tensor check, error aggregation and final completion callback. The MIT +upstream code and notices retain their original license; the surrounding owned +adapter and test harness remain GPL-3.0-only. Build manifest v2 records the clean +tree `fd570ef54b10ec5fecb739e7e04c8ce44a6f415a` and these SHA-256 digests: + +- Original `src/llama-model-loader.cpp`: `efdb5f273bd1ab77301f2cadea1a5a804bafb28040aca033e45605a97a450242`. +- Patch: `388fff7d5d9ca812abfeb27fddd1629144b7de6284e4bf685fbc8c147891e8c1`. +- Effective staged loader: `fcd26ea4ae21ea04954b275287c41a35e383015b736bbbe2e68f2ab5f6d095f9`. + +The builder verifies that the compilation database selects exactly the staged +loader. It rejects changed source/patch bytes and retains the clean upstream +checkout and original notices. This is a local patch, not an upstream release. + The separate offline conversion candidate starts only from the existing pinned Apache-2.0 `Qwen/Qwen3-4B-Instruct-2507` revision `cdbee75f17c01a7cc42f958dc650907174af0554`. It compares every original BF16 tensor diff --git a/crates/volparossa/src/compute/supervise/diagnostic.rs b/crates/volparossa/src/compute/supervise/diagnostic.rs index 4604976a3..68bbb4008 100644 --- a/crates/volparossa/src/compute/supervise/diagnostic.rs +++ b/crates/volparossa/src/compute/supervise/diagnostic.rs @@ -242,6 +242,19 @@ const WORKER_CODES: &[&str] = &[ "RESULT_TOO_LARGE", "JOB_CANCELLED", "JOB_DEADLINE_EXCEEDED", + "NATIVE_ABI_MISMATCH", + "NATIVE_BUILD_PATCH_BINDING", + "NATIVE_OPEN_DIAGNOSTIC_INVALID", + "NATIVE_OPEN_INVALID_ARGUMENT", + "NATIVE_CPU_UNSUPPORTED", + "NATIVE_BACKEND_INITIALIZATION_FAILED", + "NATIVE_MODEL_INITIALIZATION_FAILED", + "NATIVE_VOCABULARY_MISMATCH", + "NATIVE_CONTEXT_INITIALIZATION_FAILED", + "NATIVE_SAMPLER_INITIALIZATION_FAILED", + "NATIVE_MODEL_LOAD_FAILED", + "NATIVE_OWNER_CONTROL_FAILED", + "INVALID_NATIVE_CONTROL", ]; #[derive(Debug, Serialize, PartialEq, Eq)] @@ -580,6 +593,9 @@ assert result == dict(version=1, truncated=False, unrecognized_record=False, sta "BACKEND_IMPORT_FAILED" ); assert_eq!(describe(&failure("JOB_CANCELLED")).code, "JOB_CANCELLED"); + for code in WORKER_CODES.iter().filter(|code| code.contains("NATIVE")) { + assert_eq!(describe(&failure(code)).code, *code); + } assert_eq!( describe(&failure("JOB_DEADLINE_EXCEEDED")).code, "JOB_DEADLINE_EXCEEDED" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 69f7c1302..980b39475 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -164,6 +164,51 @@ job `112826781182` log SHA-256: `ef7d5c7bbeeebf497ec9a42881746a782b36f7c7bddc9febef11d7af373cb44f`. All earlier failed trials and their consumed dispatch receipts remain preserved. +The owner-responsive verification [trial 37641148934](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37641148934), +attempt 1 on Code `d48801a6` / core `773d6377`, also **fails**. Source build, +conversion and provisioning pass with the same full BF16 model. The retained +state now records all 20 issued controls acknowledged and reaches +`model_load/begin`, but ends with the closed `worker_other` diagnostic. The +receipt does not identify the native load failure's exact cause; no native turn, +tool call or coding task completes. Six failed runtime requests have confirmed +cleanup. Core cgroup peak is 8,106,295,296 bytes against 11 GiB, with zero swap +and OOM kills. Reported owned-unit, process, private-data, QEMU and scratch +cleanup passes. Normalized route/DNS observations match; raw IPv6 route bytes +differ in the excluded counter column, so byte-identical host snapshots are not +claimed for this run. No raw model output was exported. + +Original artifact `11492792961` ZIP SHA-256: +`b2621c8918f1304848fcc4a7b9e736fab2379cdf19c29de9ab11606e5c9e9cfc`; +job `112860150266` log SHA-256: +`4e32650af9e373e5e28ec93663777f1fe833feee419a5fe604fcfdfa6b7ec438`. +The consumed one-shot helper and receipts remain unchanged. Progress beyond +file verification is not successful model loading or inference. + +Source inspection found that the upstream loader starts one asynchronous +validation task per tensor, independently of the two-thread inference limit. +The new loader-only patch defers both validation paths to one execution thread +and polls the existing owner callback between tensors. It preserves all checks, +error aggregation and the final completion callback; cancellation rejects the +entire load. This removes a demonstrated source hazard, not a uniquely proved +cause of the historical failure. A single tensor check remains non-interruptible. + +The original checkout and notices remain unchanged. Build manifest v2 binds the +original tree, patch and effective loader digests and verifies the staged source +in the compilation database. Old build manifests and absent diagnostic accessors +are refused. Native errors now retain only fixed model/vocabulary/context/sampler +phase codes through the worker, core and fixture; private exception text remains +excluded. A fresh source-only build passes four check targets, including 398 tiny +F32 validations through both actual patched launch paths, invalid-tensor rejection +after complete checking, cancellation and fresh retry. Its build manifest SHA-256 +is `884bdbd00ad7cbcf1211b468d2835bf1e4b9edee2081cb6758712b3703ed0769`; +check log SHA-256 is +`adf689f10f35ebdeaf42f47d997e3496809f1683788d7dd7eacb1085622a5510`. +One hundred twenty-five focused worker tests and twenty-one diagnostic-fixture +tests pass, independently repeated by root. No patched model-loading or inference +trial has run, and no resource limit or task has been relaxed. +The fixed-code Rust diagnostic test also passes on CI's Rust 1.85. Independent +source review and a repeat of the actual tiny validation harness found no blocker. + Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke passes, but the unchanged upstream all-type tensor test **fails** under UBSan on an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index 95e93a506..e1356814c 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -73,6 +73,11 @@ 'BACKEND_EXECUTION_FAILED', 'BACKEND_NOT_INSTALLED', 'BACKEND_VERSION_MISMATCH', 'CPU_BACKEND_REQUIRED', 'MODEL_PARAMETER_DTYPE_MISMATCH', 'MODEL_WEIGHTS_CHANGED_ON_DISK', 'CONVERSATION_TOKENIZER_SHAPE', 'CONVERSATION_TOKEN_LIMIT', 'RESULT_TOO_LARGE', 'JOB_CANCELLED', 'JOB_DEADLINE_EXCEEDED', + 'NATIVE_ABI_MISMATCH', 'NATIVE_BUILD_PATCH_BINDING', 'NATIVE_OPEN_DIAGNOSTIC_INVALID', + 'NATIVE_OPEN_INVALID_ARGUMENT', 'NATIVE_CPU_UNSUPPORTED', 'NATIVE_BACKEND_INITIALIZATION_FAILED', + 'NATIVE_MODEL_INITIALIZATION_FAILED', 'NATIVE_VOCABULARY_MISMATCH', 'NATIVE_CONTEXT_INITIALIZATION_FAILED', + 'NATIVE_SAMPLER_INITIALIZATION_FAILED', 'NATIVE_MODEL_LOAD_FAILED', 'NATIVE_OWNER_CONTROL_FAILED', + 'INVALID_NATIVE_CONTROL', } SERVICE_IO_KINDS = {'none', 'not_found', 'permission_denied', 'unexpected_eof', 'broken_pipe', 'interrupted', 'invalid_data', 'other'} diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index 08e44aa80..a724d3fa9 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -251,6 +251,23 @@ def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(se self.assertIn('--property=StandardOutput=append:{diagnostic_log}', fixture) self.assertIn('--property=StandardError=append:{diagnostic_log}', fixture) + def test_native_load_diagnostics_preserve_only_closed_phases(self): + codes = {code for code in FIX['SERVICE_CODES'] if 'NATIVE' in code} + self.assertEqual(len(codes), 13) + events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] + with tempfile.TemporaryDirectory(prefix='native-diagnostic-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + unknown = dict(events[0], detail=dict(events[0]['detail'], code='NATIVE_PRIVATE_CANARY')) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(event) + '\n' + for event in [*events, unknown])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('CANARY', json.dumps(result)) + def test_terminal_execution_state_distinguishes_gate_pause_from_completed_model_load(self): value = dict(version=1, last_phase='paused', substage=dict(stage='owner_gate', state='begin', elapsed_ms=0), capacity=dict(decision='pause', constraint='cpu', cpu_some_avg10=24.5, io_some_avg10=0.0, diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py index f2167ba0e..67ee5f93c 100644 --- a/workers/volparossa-ml/build_llama_cpu.py +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -6,6 +6,7 @@ The resulting build.json is provenance, not independent installation authority. """ import argparse +import difflib import hashlib import json import os @@ -17,13 +18,16 @@ import subprocess import time +import llama_cpu as native + SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" ORIGIN = "https://github.com/ggml-org/llama.cpp.git" KIND = "llama_cpp_bf16_v1" HERE = Path(__file__).resolve().parent LIBRARY = "libvolparossa_llama_cpu.so" ALLOWED_NEEDED = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "ld-linux-x86-64.so.2"} -CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-bf16-tensors", "vp-upstream-tensor-tests") +CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", "vp-upstream-tensor-tests") +WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch") def require(condition, code): @@ -53,7 +57,73 @@ def verify_source(source): "NATIVE_SOURCE_ORIGIN") require(not command(["git", "status", "--porcelain", "--untracked-files=all"], source), "NATIVE_SOURCE_DIRTY") - return command(["git", "rev-parse", "HEAD^{tree}"], source).decode().strip() + tree = command(["git", "rev-parse", "HEAD^{tree}"], source).decode().strip() + require(tree == native.SOURCE_TREE, "NATIVE_SOURCE_TREE") + return tree + + +def patched_loader(source): + """Only one exact upstream file is transformed; source is never modified.""" + original = source / "src/llama-model-loader.cpp" + require(digest(original)["sha256"] == native.LOADER_ORIGINAL_SHA, "NATIVE_LOADER_ORIGINAL") + patch_path = HERE / "native-cpu/bounded-tensor-validation.patch" + require(digest(patch_path)["sha256"] == native.LOADER_PATCH_SHA, "NATIVE_LOADER_PATCH") + before = original.read_text(encoding="utf-8") + require(before.count("std::async(std::launch::async,") == 2, "NATIVE_LOADER_LAUNCH_SHAPE") + after = before.replace("std::async(std::launch::async,", "std::async(std::launch::deferred,") + old = " for (auto & future : validation_result) {\n auto result = future.get();" + new = (" for (auto & future : validation_result) {\n" + " // Complete validation stays on this execution thread; poll owner cancellation\n" + " // between tensors without exposing data or acknowledging pause while loading.\n" + " if (progress_callback && !progress_callback((float) size_done / size_data, progress_callback_user_data)) {\n" + " return false;\n }\n auto result = future.get();") + require(after.count(old) == 1, "NATIVE_LOADER_JOIN_SHAPE") + after = after.replace(old, new) + patch = "".join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), + fromfile="a/src/llama-model-loader.cpp", tofile="b/src/llama-model-loader.cpp")) + require(patch.encode() == patch_path.read_bytes(), "NATIVE_LOADER_PATCH_SHAPE") + result = after.encode("utf-8") + require(hashlib.sha256(result).hexdigest() == native.LOADER_EFFECTIVE_SHA, "NATIVE_LOADER_EFFECTIVE") + return result + + +def stage_loader(source, output): + data = patched_loader(source) + overlay = output / "loader-overlay" + overlay.mkdir(mode=0o700) + target = overlay / "llama-model-loader.cpp" + with target.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + target.chmod(0o444) + return target + + +def verify_loader_compilation(output, loader): + path = output / "build/compile_commands.json" + require(digest(path)["bytes"] <= 16 * 1024 * 1024, "NATIVE_COMPILE_DATABASE_BOUND") + entries = json.loads(path.read_bytes()) + require(type(entries) is list and 1 <= len(entries) <= 4096, "NATIVE_COMPILE_DATABASE_SHAPE") + matches = [Path(row["file"]) for row in entries if Path(row["file"]).name == loader.name] + require(matches == [loader] and not loader.is_symlink() + and digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_COMPILED_LOADER") + + +def stage_loader_checks(loader): + """Source-derived toy check: actual launch/join statements, never a model.""" + require(digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_LOADER_EFFECTIVE") + source = loader.read_text(encoding="utf-8") + launches = re.findall(r"validation_result\.emplace_back\(std::async\(std::launch::deferred,[\s\S]*?\}\)\);", source) + require(len(launches) == 2, "NATIVE_LOADER_CHECK_SHAPE") + start = source.index(" // check validation results\n") + end = source.index(" // check if this is the last call and do final cleanup\n", start) + fragments = dict(mapped=launches[0], host=launches[1], join=source[start:end]) + for name, fragment in fragments.items(): + path = loader.parent / ("validation-" + name + ".inc") + with path.open("x", encoding="utf-8") as stream: + stream.write(fragment + "\n") + path.chmod(0o444) def dependencies(raw): @@ -94,12 +164,18 @@ def build(source, output, execute=False, checks=False, sanitize=False): for tool in ("cmake", "c++", "cc", "readelf", "nice"): require(shutil.which(tool), "NATIVE_BUILD_TOOL_MISSING") require(not sanitize or checks, "NATIVE_SANITIZER_REQUIRES_CHECKS") + patched_loader(source) plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, "sanitizers": sanitize, "provisionable": not sanitize, + "source_overlay": native.loader_provenance(), "jobs": 2, "models_loaded": False, "downloads": False, "install": False} if not execute: return plan + wrapper_sources = {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES} output.mkdir(mode=0o700) + loader = stage_loader(source, output) + if checks: + stage_loader_checks(loader) started = time.monotonic() env = dict(os.environ) # Avoid inherited compiler/linker/pkgconfig overrides or arbitrary preload code. @@ -112,6 +188,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): commands = [ ["cmake", "-S", str(HERE / "native-cpu"), "-B", str(output / "build"), "-DVP_LLAMA_SOURCE=" + str(source), "-DCMAKE_BUILD_TYPE=Release", + "-DVP_LLAMA_MODEL_LOADER=" + str(loader), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", "-DVP_NATIVE_TESTS=" + ("ON" if checks else "OFF"), "-DVP_NATIVE_SANITIZE=" + ("ON" if sanitize else "OFF")], ["cmake", "--build", str(output / "build"), "--target", "volparossa_llama_cpu", @@ -123,6 +200,9 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(remaining > 0, "NATIVE_BUILD_DEADLINE") run_build(args, log, env, remaining) require(verify_source(source) == tree, "NATIVE_SOURCE_CHANGED") + require(wrapper_sources == {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES}, + "NATIVE_WRAPPER_CHANGED_DURING_BUILD") + verify_loader_compilation(output, loader) candidates = list((output / "build").rglob(LIBRARY)) require(len(candidates) == 1, "NATIVE_LIBRARY_OUTPUT") library = output / LIBRARY @@ -165,7 +245,8 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(runtime_notice.is_file(), "NATIVE_TOOLCHAIN_NOTICE") shutil.copyfile(runtime_notice, licenses / "GCC-runtime-copyright") (licenses / "GCC-runtime-copyright").chmod(0o444) - report = dict(plan, version=1, abi_version=1, library={"path": LIBRARY, **digest(library)}, + report = dict(plan, version=2, abi_version=1, library={"path": LIBRARY, **digest(library)}, + upstream_original_unchanged=True, loader_compile_source_verified=True, dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, checks=list(CHECK_TARGETS) if checks else [], known_upstream_sanitizer_failure={ @@ -174,8 +255,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): "function": "ggml_vec_dot_q1_0_q8_0", "original_log_bytes": 412, "original_log_sha256": "37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08"}, static_runtime_archives=runtime_archives, runtime_notice=digest(runtime_notice), - wrapper_sources={name: digest(HERE / "native-cpu" / name) - for name in ("CMakeLists.txt", "adapter.h", "adapter.cpp")}, + wrapper_sources=wrapper_sources, tool_versions={tool: command([tool, "--version"]).decode().splitlines()[0] for tool in ("cmake", "c++", "cc")}) write_json(output / "build.json", report) diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 3538ce8d2..0a28979e5 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -314,14 +314,16 @@ def check(): build_raw = (build_root / "build.json").read_bytes() builder.require(len(build_raw) <= 65536, "NATIVE_BUILD_MANIFEST_BOUND") build = json.loads(build_raw) - builder.require(build["version"] == 1 and build["source_commit"] == builder.SOURCE + native.validate_build_provenance(build, builder.require) + builder.verify_loader_compilation(build_root, build_root / "loader-overlay/llama-model-loader.cpp") + builder.require(build["source_commit"] == builder.SOURCE and build["kind"] == native.KIND and build["abi_version"] == 1 and build["quantization"] is False and build["provisionable"] is True and build["sanitizers"] is False and build["library"]["path"] == native.LIBRARY and set(build["dynamic_dependencies"]) <= builder.ALLOWED_NEEDED, "NATIVE_BUILD_MANIFEST") builder.require(build["wrapper_sources"] == {name: builder.digest(HERE / "native-cpu" / name) - for name in ("CMakeLists.txt", "adapter.h", "adapter.cpp")}, "NATIVE_WRAPPER_SOURCE_BINDING") + for name in builder.WRAPPER_FILES}, "NATIVE_WRAPPER_SOURCE_BINDING") library = build_root / native.LIBRARY builder.require(builder.digest(library) == {key: build["library"][key] for key in ("bytes", "sha256")}, "NATIVE_LIBRARY_PIN") diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index 9681b99f4..e7490035b 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -17,6 +17,10 @@ KIND = "llama_cpp_bf16_v1" SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" +SOURCE_TREE = "fd570ef54b10ec5fecb739e7e04c8ce44a6f415a" +LOADER_ORIGINAL_SHA = "efdb5f273bd1ab77301f2cadea1a5a804bafb28040aca033e45605a97a450242" +LOADER_EFFECTIVE_SHA = "fcd26ea4ae21ea04954b275287c41a35e383015b736bbbe2e68f2ab5f6d095f9" +LOADER_PATCH_SHA = "388fff7d5d9ca812abfeb27fddd1629144b7de6284e4bf685fbc8c147891e8c1" PROFILE = "qwen3-4b-instruct-2507-v1" LIBRARY = "libvolparossa_llama_cpu.so" WEIGHTS_SHA = "79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf" @@ -25,6 +29,27 @@ ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) +def loader_provenance(): + return dict(kind="serial_complete_tensor_validation_v1", original_path="src/llama-model-loader.cpp", + original_sha256=LOADER_ORIGINAL_SHA, effective_sha256=LOADER_EFFECTIVE_SHA, + patch_path="native-cpu/bounded-tensor-validation.patch", patch_sha256=LOADER_PATCH_SHA, + compiled_source="loader-overlay/llama-model-loader.cpp", validation_execution_threads=1, + complete_tensor_validation=True, owner_poll_between_tensors=True) + + +def validate_build_provenance(value, require): + # Build manifest v2 distinguishes the effective loader from clean upstream. + # ABI v1 is retained; old unpatched build manifests are not silently adopted. + overlay = value.get("source_overlay") if type(value) is dict else None + require(type(value) is dict and type(value.get("version")) is int and value["version"] == 2 + and value.get("source_commit") == SOURCE and value.get("source_tree") == SOURCE_TREE + and type(overlay) is dict and overlay == loader_provenance() + and type(overlay["validation_execution_threads"]) is int + and overlay["complete_tensor_validation"] is True and overlay["owner_poll_between_tensors"] is True + and value.get("loader_compile_source_verified") is True + and value.get("upstream_original_unchanged") is True, "NATIVE_BUILD_PATCH_BINDING") + + def validate_manifest(value, require): require(type(value) is dict and value.keys() == {"version", "kind", "abi_version", "source_commit", "model_profile", "source_weights_sha256", "source_weights_bytes", "library", "gguf", @@ -71,7 +96,8 @@ def verified_bundle(request, worker, tokenizer, session): build = worker.read_bounded(root / "build.json", 65536) require(hashlib.sha256(build).hexdigest() == value["build_manifest_sha256"], "NATIVE_BUILD_DIGEST") provenance = worker.parse_json(build) - require(type(provenance) is dict and provenance.get("version") == 1 + validate_build_provenance(provenance, require) + require(type(provenance) is dict and provenance.get("kind") == KIND and provenance.get("source_commit") == SOURCE and provenance.get("abi_version") == 1 and provenance.get("cpu") == "avx2_fma_f16c" and provenance.get("provisionable") is True and provenance.get("sanitizers") is False @@ -150,6 +176,7 @@ def __init__(self, root, capacity, threads, probe, require): signatures = { "vp_llama_abi_v1": (ctypes.c_uint32, []), "vp_llama_source_v1": (ctypes.c_char_p, []), + "vp_llama_open_stage_v1": (ctypes.c_uint32, []), "vp_llama_open_v1": (ctypes.c_int32, [ctypes.c_char_p, ctypes.c_uint32, ctypes.c_uint32, ABORT, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]), "vp_llama_decode_v1": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32), ctypes.c_uint32]), @@ -157,7 +184,11 @@ def __init__(self, root, capacity, threads, probe, require): "vp_llama_close_v1": (None, [ctypes.c_void_p]), } for name, (result, arguments) in signatures.items(): - function = getattr(self.lib, name) + try: + function = getattr(self.lib, name) + except AttributeError as error: + require(False, "NATIVE_ABI_MISMATCH") + raise error # require always raises; no incomplete ABI can proceed. function.restype, function.argtypes = result, arguments require(self.lib.vp_llama_abi_v1() == 1 and self.lib.vp_llama_source_v1() == SOURCE.encode(), "NATIVE_ABI_MISMATCH") @@ -165,7 +196,14 @@ def __init__(self, root, capacity, threads, probe, require): status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, probe.callback, None, ctypes.byref(self.handle)) probe.joined() - require(status == 0 and self.handle.value is not None, "NATIVE_MODEL_LOAD_FAILED") + stage = self.lib.vp_llama_open_stage_v1() + codes = {1: "NATIVE_OPEN_INVALID_ARGUMENT", 2: "NATIVE_CPU_UNSUPPORTED", + 3: "NATIVE_BACKEND_INITIALIZATION_FAILED", 4: "NATIVE_MODEL_INITIALIZATION_FAILED", + 5: "NATIVE_VOCABULARY_MISMATCH", 6: "NATIVE_CONTEXT_INITIALIZATION_FAILED", + 7: "NATIVE_SAMPLER_INITIALIZATION_FAILED", 8: "NATIVE_MODEL_LOAD_FAILED"} + require(type(stage) is int and stage in codes, "NATIVE_OPEN_DIAGNOSTIC_INVALID") + require(status == 0 and self.handle.value is not None, codes[stage]) + require(stage == 8, "NATIVE_OPEN_DIAGNOSTIC_INVALID") except BaseException: self.close() raise diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt index 505a85af7..b5009e74c 100644 --- a/workers/volparossa-ml/native-cpu/CMakeLists.txt +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -33,6 +33,32 @@ set(GGML_F16C ON CACHE BOOL "" FORCE) set(GGML_LLAMAFILE ON CACHE BOOL "" FORCE) set(GGML_CPU ON CACHE BOOL "" FORCE) add_subdirectory("${VP_LLAMA_SOURCE}" llama EXCLUDE_FROM_ALL) +# Keep the pinned checkout pristine. Only this exact source is replaced, and +# build_llama_cpu.py verifies its digest and actual compilation-database entry. +if(NOT IS_ABSOLUTE "${VP_LLAMA_MODEL_LOADER}" OR NOT EXISTS "${VP_LLAMA_MODEL_LOADER}") + message(FATAL_ERROR "The verified staged model loader is required") +endif() +get_target_property(llama_source_dir llama SOURCE_DIR) +get_target_property(llama_sources llama SOURCES) +set(effective_sources) +set(loader_replacements 0) +foreach(source IN LISTS llama_sources) + if(NOT IS_ABSOLUTE "${source}") + get_filename_component(source "${source}" ABSOLUTE BASE_DIR "${llama_source_dir}") + endif() + if(source STREQUAL "${VP_LLAMA_SOURCE}/src/llama-model-loader.cpp") + list(APPEND effective_sources "${VP_LLAMA_MODEL_LOADER}") + math(EXPR loader_replacements "${loader_replacements} + 1") + else() + list(APPEND effective_sources "${source}") + endif() +endforeach() +if(NOT loader_replacements EQUAL 1) + message(FATAL_ERROR "Expected exactly one original model loader") +endif() +set_property(TARGET llama PROPERTY SOURCES "${effective_sources}") +set_source_files_properties("${VP_LLAMA_MODEL_LOADER}" TARGET_DIRECTORY llama + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) add_library(volparossa_llama_cpu SHARED adapter.cpp) target_link_libraries(volparossa_llama_cpu PRIVATE llama) target_compile_options(volparossa_llama_cpu PRIVATE -Wall -Wextra -Werror) @@ -43,6 +69,13 @@ if(VP_NATIVE_TESTS) add_executable(vp-native-abi-smoke abi-smoke.cpp) target_compile_options(vp-native-abi-smoke PRIVATE -UNDEBUG) target_link_libraries(vp-native-abi-smoke PRIVATE volparossa_llama_cpu) + # Launch/join statements are extracted from the exact staged loader. This + # exercises real ggml validation on tiny F32 arrays, never loads a model. + add_executable(vp-native-loader-validation loader-validation.cpp) + get_filename_component(loader_overlay_dir "${VP_LLAMA_MODEL_LOADER}" DIRECTORY) + target_include_directories(vp-native-loader-validation PRIVATE "${loader_overlay_dir}") + target_compile_options(vp-native-loader-validation PRIVATE -UNDEBUG) + target_link_libraries(vp-native-loader-validation PRIVATE ggml) # Unchanged upstream test, generated toy tensors only. No model files or CLI. add_executable(vp-upstream-tensor-tests "${VP_LLAMA_SOURCE}/tests/test-quantize-fns.cpp") target_link_libraries(vp-upstream-tensor-tests PRIVATE ggml ggml-cpu) diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md index febb65d8a..d5ccf09bb 100644 --- a/workers/volparossa-ml/native-cpu/README.md +++ b/workers/volparossa-ml/native-cpu/README.md @@ -34,6 +34,10 @@ default; `--execute` builds at low priority with at most two compiler jobs into a new output directory. `build.json` retains the source/tree, wrapper hashes, compiler versions, actual library hash, CPU requirements and dependency list. Original upstream notices are copied unchanged. +Build manifest v2 also binds the explicitly recorded loader-only patch, original +and effective loader digests, and actual staged-source compilation. The checkout +stays pristine; old v1 build manifests cannot masquerade as the patched backend. +See [third-party provenance](../../../THIRD_PARTY_LICENSES.md#explicit-llamacpp-cpu-inference-candidate). The actual static GCC runtime archives are hashed; their original Debian GCC copyright/runtime-exception notice is retained alongside upstream notices. @@ -43,6 +47,27 @@ tensor test under ASan/UBSan. Such artifacts are marked **not provisionable**. These are not model or performance tests; the wrapper does not replace or change the all-type test or its tolerances. +Both loader paths previously launched a separate asynchronous validation task per +tensor, independently of the inference thread setting. The local patch defers +those checks to one execution thread and polls the existing owner callback between +tensors. Every check still runs before successful loading; cancellation rejects +the load and frees its state. A single tensor check is not internally interruptible, +so this is not a guarantee of maximum cancellation latency. Pause acknowledgment +still waits for native execution to stop. + +A new source-derived harness executes both patched launch paths and the actual +validation-result loop on 398 tiny F32 arrays. It verifies complete checking even +when the first tensor is invalid, cancellation before selected checks and a fresh +complete retry. The normal source build passes that harness, the ABI smoke, +BF16/F32 scope and original all-type test. A separate ASan/UBSan execution covers +the new harness/extracted statements only, not the already built ggml archive. +It does not supersede the historical full-suite sanitizer failure below. + +The additive ABI-v1 last-open-stage accessor reports only fixed phase numbers +for model, vocabulary, context and sampler initialization. The worker and core +reject unknown stages and retain only allowlisted diagnostics, never exception +text, model paths, tensor names or user content. Missing accessors are refused. + The original all-type sanitizer check **failed** on a misaligned `uint32_t` load in upstream `ggml/src/ggml-cpu/arch/x86/quants.c:590`, `ggml_vec_dot_q1_0_q8_0`. Its retained 412-byte log has SHA-256 @@ -121,7 +146,9 @@ and resume are acknowledged at stopped decode boundaries by the existing owner protocol. All handles are closed deterministically, and the supervisor retains its independent hard-budget and process-cleanup authority. -Actual guest conversion, memory/performance feasibility, real EOS completion and -the unchanged OpenCode read/edit/test scenario still require their own evidence. -Passing a source build, ABI smoke or synthetic tensor test does not establish any -of those outcomes. +Actual guest conversion has passed in the preserved trials, including complete +tensor-value verification. The latest unpatched-loader trial stopped during model +loading; neither its unique failure cause nor successful native inference is +established. Patched loading, memory/performance feasibility, real EOS completion +and the unchanged OpenCode read/edit/test scenario still require their own guest +evidence. A source build, ABI smoke or synthetic tensor test is not that evidence. diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp index b033a44e1..69d698b08 100644 --- a/workers/volparossa-ml/native-cpu/abi-smoke.cpp +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -12,10 +12,12 @@ static int32_t abort_immediately(void * opaque) { int main() { assert(vp_llama_abi_v1() == 1); + assert(vp_llama_open_stage_v1() == VP_OPEN_NONE); assert(std::strcmp(vp_llama_source_v1(), "7fe450e19305b828c199d602c23a8337aaa1f03b") == 0); void * handle = nullptr; int callbacks = 0; assert(vp_llama_open_v1(nullptr, 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_stage_v1() == VP_OPEN_ARGUMENT); assert(vp_llama_open_v1("relative", 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); assert(vp_llama_open_v1("/never-read-model", 13313, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); assert(vp_llama_open_v1("/never-read-model", 16, 3, abort_immediately, &callbacks, &handle) == VP_INVALID); @@ -27,6 +29,7 @@ int main() { // Unsupported instruction set is a legitimate explicit refusal, not a pass // of model loading. On this capable build host, the callback aborts first. assert((status == VP_ABORTED && callbacks == 1) || (status == VP_INVALID && callbacks == 0)); + assert(vp_llama_open_stage_v1() == (status == VP_ABORTED ? VP_OPEN_BACKEND : VP_OPEN_CPU)); assert(handle == nullptr); vp_llama_close_v1(nullptr); } diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp index 9b68aa617..2d0ec7d0f 100644 --- a/workers/volparossa-ml/native-cpu/adapter.cpp +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -35,20 +35,24 @@ bool aborted(void * pointer) { bool load_progress(float, void * pointer) { return !aborted(pointer); } void discard_log(ggml_log_level, const char *, void *) {} std::once_flag initialized; +thread_local uint32_t open_stage = VP_OPEN_NONE; } extern "C" uint32_t vp_llama_abi_v1(void) { return 1; } extern "C" const char * vp_llama_source_v1(void) { return source; } +extern "C" uint32_t vp_llama_open_stage_v1(void) { return open_stage; } extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, uint32_t threads, vp_abort_v1 abort, void * opaque, void ** handle) { + open_stage = VP_OPEN_ARGUMENT; if (!handle) { return VP_INVALID; } *handle = nullptr; if (!path || path[0] != '/' || std::strlen(path) > 4096 || !abort || capacity < 2 || capacity > max_context || threads < 1 || threads > 2) { return VP_INVALID; } + open_stage = VP_OPEN_CPU; #if defined(__x86_64__) && defined(__GNUC__) if (!__builtin_cpu_supports("avx2") || !__builtin_cpu_supports("fma") || !__builtin_cpu_supports("f16c")) { return VP_INVALID; } @@ -56,6 +60,7 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, return VP_INVALID; #endif try { + open_stage = VP_OPEN_BACKEND; std::call_once(initialized, [] { // No private text, paths, tensor names or backend errors on stdout/stderr. llama_log_set(discard_log, nullptr); @@ -73,9 +78,11 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, model_params.check_tensors = true; model_params.progress_callback = load_progress; model_params.progress_callback_user_data = value.get(); + open_stage = VP_OPEN_MODEL; value->model = llama_model_load_from_file(path, model_params); if (aborted(value.get())) { return VP_ABORTED; } if (!value->model) { return VP_BACKEND; } + open_stage = VP_OPEN_VOCABULARY; const auto * vocab = llama_model_get_vocab(value->model); if (llama_vocab_n_tokens(vocab) != vocabulary || llama_vocab_eos(vocab) != 151645) { return VP_INVALID; @@ -94,12 +101,15 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, context_params.op_offload = false; context_params.abort_callback = aborted; context_params.abort_callback_data = value.get(); + open_stage = VP_OPEN_CONTEXT; value->context = llama_init_from_model(value->model, context_params); if (aborted(value.get())) { return VP_ABORTED; } if (!value->context) { return VP_BACKEND; } + open_stage = VP_OPEN_SAMPLER; value->sampler = llama_sampler_init_greedy(); if (!value->sampler) { return VP_BACKEND; } *handle = value.release(); + open_stage = VP_OPEN_READY; return VP_OK; } catch (...) { return VP_BACKEND; } } diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h index 99ba07094..38b7de6e0 100644 --- a/workers/volparossa-ml/native-cpu/adapter.h +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -10,8 +10,16 @@ extern "C" { // one Python execution thread. Callback exceptions must never cross this ABI. typedef int32_t (*vp_abort_v1)(void *); enum vp_status_v1 { VP_OK = 0, VP_INVALID = 1, VP_ABORTED = 2, VP_BACKEND = 3 }; +// Closed last-open phase on the calling execution thread. No exception text or +// paths cross the ABI. Existing v1 entry points/status values remain unchanged. +enum vp_open_stage_v1 { + VP_OPEN_NONE = 0, VP_OPEN_ARGUMENT = 1, VP_OPEN_CPU = 2, + VP_OPEN_BACKEND = 3, VP_OPEN_MODEL = 4, VP_OPEN_VOCABULARY = 5, + VP_OPEN_CONTEXT = 6, VP_OPEN_SAMPLER = 7, VP_OPEN_READY = 8 +}; uint32_t vp_llama_abi_v1(void); const char * vp_llama_source_v1(void); +uint32_t vp_llama_open_stage_v1(void); int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, uint32_t threads, vp_abort_v1 abort, void * opaque, void ** handle); diff --git a/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch b/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch new file mode 100644 index 000000000..6632c4b50 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch @@ -0,0 +1,32 @@ +--- a/src/llama-model-loader.cpp ++++ b/src/llama-model-loader.cpp +@@ -1648,7 +1648,7 @@ + uint8_t * data = (uint8_t *) mapping->addr() + weight->offs; + + if (check_tensors) { +- validation_result.emplace_back(std::async(std::launch::async, [cur, data, n_size] { ++ validation_result.emplace_back(std::async(std::launch::deferred, [cur, data, n_size] { + return std::make_pair(cur, ggml_validate_row_data(cur->type, data, n_size)); + })); + } +@@ -1676,7 +1676,7 @@ + file->seek(weight->offs, SEEK_SET); + file->read_raw(cur->data, n_size); + if (check_tensors) { +- validation_result.emplace_back(std::async(std::launch::async, [cur, n_size] { ++ validation_result.emplace_back(std::async(std::launch::deferred, [cur, n_size] { + return std::make_pair(cur, ggml_validate_row_data(cur->type, cur->data, n_size)); + })); + } +@@ -1763,6 +1763,11 @@ + // check validation results + bool validation_failed = false; + for (auto & future : validation_result) { ++ // Complete validation stays on this execution thread; poll owner cancellation ++ // between tensors without exposing data or acknowledging pause while loading. ++ if (progress_callback && !progress_callback((float) size_done / size_data, progress_callback_user_data)) { ++ return false; ++ } + auto result = future.get(); + if (!result.second) { + LLAMA_LOG_ERROR("%s: tensor '%s' has invalid data\n", __func__, ggml_get_name(result.first)); diff --git a/workers/volparossa-ml/native-cpu/loader-validation.cpp b/workers/volparossa-ml/native-cpu/loader-validation.cpp new file mode 100644 index 000000000..ce2247efa --- /dev/null +++ b/workers/volparossa-ml/native-cpu/loader-validation.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The included launch/join fragments come from the hash-verified staged MIT +// loader. Only the surrounding toy tensors and observation harness are ours. +// This is not a full model load or inference/latency proof. +#include "ggml.h" +#include +#include +#include +#include +#include +#include +#include + +static int validations = 0; +static std::thread::id execution_thread; +static bool checked_validation(ggml_type type, const void * data, size_t size) { + assert(std::this_thread::get_id() == execution_thread); + ++validations; + return ggml_validate_row_data(type, data, size); +} + +using pending = std::vector>>; +// Instrument calls, not their result: real ggml checks still run completely. +#define ggml_validate_row_data checked_validation +static void mapped(pending & validation_result, ggml_tensor * cur, uint8_t * data, size_t n_size) { +#include "validation-mapped.inc" +} +static void host(pending & validation_result, ggml_tensor * cur, size_t n_size) { +#include "validation-host.inc" +} +#undef ggml_validate_row_data + +static int polls = 0; +static int cancel_on_poll = 0; +static bool progress(float, void *) { + assert(std::this_thread::get_id() == execution_thread); + return ++polls != cancel_on_poll; +} +static bool join(pending & validation_result) { + const size_t size_done = 1, size_data = 1; + auto progress_callback = progress; + void * progress_callback_user_data = nullptr; + // The production logger remains disabled; the toy contains no private data. +#define LLAMA_LOG_ERROR(...) ((void) 0) +#include "validation-join.inc" +#undef LLAMA_LOG_ERROR + return true; +} + +static void run(bool invalid, int cancellation) { + constexpr int count = 398; + validations = polls = 0; + cancel_on_poll = cancellation; + execution_thread = std::this_thread::get_id(); + float valid[2] = {1.0f, -1.0f}; + float corrupt[2] = {std::numeric_limits::quiet_NaN(), 0.0f}; + std::vector tensors(count); + pending validation_result; + for (int i = 0; i < count; ++i) { + auto * cur = &tensors[i]; + cur->type = GGML_TYPE_F32; + cur->data = invalid && i == 0 ? corrupt : valid; + if (i % 2 == 0) { + mapped(validation_result, cur, static_cast(cur->data), sizeof(valid)); + } else { + host(validation_result, cur, sizeof(valid)); + } + } + assert(validations == 0); + for (auto & future : validation_result) { + assert(future.wait_for(std::chrono::seconds(0)) == std::future_status::deferred); + } + bool complete = false, rejected = false; + try { complete = join(validation_result); } + catch (const std::runtime_error &) { rejected = true; } + if (cancellation != 0) { + assert(!complete && !rejected && validations == cancellation - 1 && polls == cancellation); + } else { + assert(complete == !invalid && rejected == invalid && validations == count && polls == count); + } +} + +int main() { + run(false, 0); + run(true, 0); // A bad first tensor must not skip the other validation results. + run(false, 1); + run(false, 3); + run(false, 398); + run(false, 0); // A fresh attempt after cancellation checks every tensor again. +} diff --git a/workers/volparossa-ml/test_conversion_diagnostic.py b/workers/volparossa-ml/test_conversion_diagnostic.py index f91061f47..a5ca5f519 100644 --- a/workers/volparossa-ml/test_conversion_diagnostic.py +++ b/workers/volparossa-ml/test_conversion_diagnostic.py @@ -28,12 +28,14 @@ def exercise_stage(self, rejected): path.write_bytes(b'inert') pinned = [{'path': name, 'bytes': 5, 'sha256': hashlib.sha256(b'inert').hexdigest()} for name in ('weights', 'LICENSE')] - manifest = dict(version=1, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, + manifest = dict(version=2, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, + source_tree=conversion.native.SOURCE_TREE, source_overlay=conversion.native.loader_provenance(), + loader_compile_source_verified=True, upstream_original_unchanged=True, abi_version=1, quantization=False, provisionable=True, sanitizers=False, library=dict(path=conversion.native.LIBRARY, **conversion.builder.digest(build / conversion.native.LIBRARY)), dynamic_dependencies=['libc.so.6'], wrapper_sources={name: conversion.builder.digest(conversion.HERE / 'native-cpu' / name) - for name in ('CMakeLists.txt', 'adapter.h', 'adapter.cpp')}) + for name in conversion.builder.WRAPPER_FILES}) (build / 'build.json').write_text(json.dumps(manifest)) progress, visited = {}, [] def boundary(stage, result=None): @@ -46,6 +48,10 @@ def boundary(stage, result=None): side_effect=lambda _args: boundary('guard', (base / 'output', 'inert')))) stack.enter_context(mock.patch.object(conversion.builder, 'verify_source', side_effect=lambda _path: boundary('source', 'inert-tree'))) + # This suite checks closed diagnostic staging, not native compilation; + # real staged-source/hash checks have separate source-only coverage. + stack.enter_context(mock.patch.object(conversion.builder, 'verify_loader_compilation', + side_effect=lambda _root, _loader: boundary('build'))) stack.enter_context(mock.patch.object(conversion.provision, 'load_pins', return_value={'files': pinned})) versions = {'torch': '2.14.0+cpu', 'transformers': '5.16.1', 'peft': '0.20.0', 'sentencepiece': '0.2.1'} stack.enter_context(mock.patch.object(conversion.importlib.metadata, 'version', diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py index 073c31d7a..1c4f022b4 100644 --- a/workers/volparossa-ml/test_llama_cpu.py +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -396,7 +396,9 @@ def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(s for field, data in (("library", b"inert-not-an-executable"), ("gguf", b"inert-not-a-model")): (root / value[field]["path"]).write_bytes(data) value[field].update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest()) - provenance = {"version": 1, "abi_version": 1, "kind": native.KIND, "source_commit": native.SOURCE, + provenance = {"version": 2, "abi_version": 1, "kind": native.KIND, "source_commit": native.SOURCE, + "source_tree": native.SOURCE_TREE, "source_overlay": native.loader_provenance(), + "loader_compile_source_verified": True, "upstream_original_unchanged": True, "cpu": "avx2_fma_f16c", "library": value["library"], "provisionable": True, "sanitizers": False, "quantization": False} build_raw = json.dumps(provenance).encode() @@ -566,6 +568,7 @@ def test_c_abi_handle_closes_after_joined_python_error_and_only_once(self): library = mock.Mock() library.vp_llama_abi_v1.return_value = 1 library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = 8 def allocate(_path, _capacity, _threads, _callback, _opaque, out): out._obj.value = 123 return 0 @@ -583,6 +586,102 @@ def allocate(_path, _capacity, _threads, _callback, _opaque, out): model.close(); model.close() library.vp_llama_close_v1.assert_called_once() + def test_abi_requires_closed_open_stage_even_when_existing_abi_version_matches(self): + codes = {1: "NATIVE_OPEN_INVALID_ARGUMENT", 2: "NATIVE_CPU_UNSUPPORTED", + 3: "NATIVE_BACKEND_INITIALIZATION_FAILED", 4: "NATIVE_MODEL_INITIALIZATION_FAILED", + 5: "NATIVE_VOCABULARY_MISMATCH", 6: "NATIVE_CONTEXT_INITIALIZATION_FAILED", + 7: "NATIVE_SAMPLER_INITIALIZATION_FAILED", 8: "NATIVE_MODEL_LOAD_FAILED"} + for stage, expected in [*codes.items(), (0, "NATIVE_OPEN_DIAGNOSTIC_INVALID"), + (9, "NATIVE_OPEN_DIAGNOSTIC_INVALID"), + (True, "NATIVE_OPEN_DIAGNOSTIC_INVALID")]: + with self.subTest(stage=stage): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = stage + library.vp_llama_open_v1.return_value = 3 + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, expected): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + library.vp_llama_decode_v1.assert_not_called() + library.vp_llama_close_v1.assert_not_called() + # An older ABI1 library cannot silently use the new patched provenance. + library = SimpleNamespace(vp_llama_abi_v1=mock.Mock(return_value=1), + vp_llama_source_v1=mock.Mock(return_value=native.SOURCE.encode())) + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_ABI_MISMATCH"): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + + def test_success_handle_with_wrong_open_stage_is_rejected_and_closed(self): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = 4 + def allocate(_path, _capacity, _threads, _callback, _opaque, out): + out._obj.value = 123 + return 0 + library.vp_llama_open_v1.side_effect = allocate + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OPEN_DIAGNOSTIC_INVALID"): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + library.vp_llama_close_v1.assert_called_once() + + def test_effective_loader_provenance_rejects_old_or_ambiguous_builds(self): + value = dict(version=2, source_commit=native.SOURCE, source_tree=native.SOURCE_TREE, + source_overlay=native.loader_provenance(), loader_compile_source_verified=True, + upstream_original_unchanged=True) + native.validate_build_provenance(value, WORKER.require) + for key, bad in (("version", 1), ("source_tree", "0" * 40), ("source_commit", "0" * 40), + ("loader_compile_source_verified", 1), ("upstream_original_unchanged", False)): + with self.subTest(key=key), self.assertRaisesRegex(WORKER.JobError, "NATIVE_BUILD_PATCH_BINDING"): + native.validate_build_provenance(dict(value, **{key: bad}), WORKER.require) + for key, bad in (("validation_execution_threads", True), ("complete_tensor_validation", 1), + ("owner_poll_between_tensors", 1), ("effective_sha256", native.LOADER_ORIGINAL_SHA), + ("original_sha256", "0" * 64), ("patch_sha256", "0" * 64), + ("compiled_source", "src/llama-model-loader.cpp")): + changed = copy.deepcopy(value) + changed["source_overlay"][key] = bad + with self.subTest(key=key), self.assertRaisesRegex(WORKER.JobError, "NATIVE_BUILD_PATCH_BINDING"): + native.validate_build_provenance(changed, WORKER.require) + + def test_compilation_must_use_exact_staged_loader_not_clean_upstream(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "build").mkdir() + loader = root / "llama-model-loader.cpp" + loader.write_bytes(b"inert-source") + commands = root / "build/compile_commands.json" + commands.write_text(json.dumps([{"file": str(loader)}])) + with mock.patch.object(native, "LOADER_EFFECTIVE_SHA", hashlib.sha256(b"inert-source").hexdigest()): + builder.verify_loader_compilation(root, loader) + for rows in ([{"file": "/unmodified/llama-model-loader.cpp"}], [], + [{"file": str(loader)}, {"file": str(loader)}]): + commands.write_text(json.dumps(rows)) + with self.assertRaises(ValueError): + builder.verify_loader_compilation(root, loader) + commands.write_text(json.dumps([{"file": str(loader)}])) + loader.write_bytes(b"changed-source") + with self.assertRaisesRegex(ValueError, "NATIVE_COMPILED_LOADER"): + builder.verify_loader_compilation(root, loader) + + def test_patch_identity_and_fresh_staging_never_overwrite_existing_source(self): + patch = builder.HERE / "native-cpu/bounded-tensor-validation.patch" + self.assertEqual(builder.digest(patch)["sha256"], native.LOADER_PATCH_SHA) + text = patch.read_text() + self.assertEqual(text.count("+ validation_result.emplace_back(std::async(std::launch::deferred,"), 1) + self.assertEqual(text.count("+ validation_result.emplace_back(std::async(std::launch::deferred,"), 1) + self.assertNotIn("- return std::make_pair", text) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with mock.patch.object(builder, "patched_loader", return_value=b"inert-source"): + loader = builder.stage_loader(root / "original", root) + self.assertEqual(loader.read_bytes(), b"inert-source") + self.assertEqual(stat.S_IMODE(loader.stat().st_mode), 0o444) + self.assertEqual(stat.S_IMODE(loader.parent.stat().st_mode), 0o700) + with self.assertRaises(FileExistsError): + builder.stage_loader(root / "original", root) + self.assertEqual(loader.read_bytes(), b"inert-source") + def test_native_execute_rejects_implicit_sampling_before_any_library_or_model_load(self): bridge = mock.Mock() bridge.generation_policy.return_value = None From bccd7f3ee613f4132a506e32b1f098806d81ec44 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:50:46 +0200 Subject: [PATCH 08/39] compute: classify native JSON rejection without private output --- .../src/compute/private_conversation/WIRE.md | 10 ++- .../src/compute/private_conversation/qwen.rs | 29 +++++++- .../src/compute/private_conversation/tests.rs | 66 ++++++++++++++++++- .../src/compute/supervise/diagnostic.rs | 5 ++ docs/IMPLEMENTATION_STATUS.md | 42 ++++++++++-- .../integration/agent-private-conversation.py | 2 + .../test-agent-private-conversation.py | 22 ++++++- 7 files changed, 166 insertions(+), 10 deletions(-) diff --git a/crates/volparossa/src/compute/private_conversation/WIRE.md b/crates/volparossa/src/compute/private_conversation/WIRE.md index 1e38fec8f..d9bbf3922 100644 --- a/crates/volparossa/src/compute/private_conversation/WIRE.md +++ b/crates/volparossa/src/compute/private_conversation/WIRE.md @@ -134,7 +134,15 @@ not retry a failed generation or disclose raw parser/backend diagnostics. When explicitly enabled, the `volparossa::compute::private_diagnostic` debug target records a fixed native-output rejection category only after the complete report binding passes. It never includes raw output, tool names or arguments; -the wire reply remains `invalid_output`. These events observe report validation, +the wire reply remains `invalid_output`. JSON rejection uses only Serde's closed +`syntax`, `data`, `eof` or `io` category, emitted as +`conversation_native_json_{category}`. No error message, field name, path, line or +column is exported. `data` includes schema/type and duplicate-key rejection; +`eof` is an incomplete JSON body, not proof of a token or transport limit. `io` +is retained for exhaustive classification, not a claim that the current +string-backed parser performs I/O. Historical `conversation_native_json` records +remain readable, but cannot retrospectively be assigned a finer category. +These events observe report validation, not model attempts: validating the same bound report again may repeat its code without another execution. Request/result counters track actual attempts. diff --git a/crates/volparossa/src/compute/private_conversation/qwen.rs b/crates/volparossa/src/compute/private_conversation/qwen.rs index 9d6e8522b..626167443 100644 --- a/crates/volparossa/src/compute/private_conversation/qwen.rs +++ b/crates/volparossa/src/compute/private_conversation/qwen.rs @@ -106,6 +106,18 @@ fn checked_parse(input: &Input, raw: &str, id: &str) -> Result { Ok(value) } +fn json_rejection_code(error: &serde_json::Error) -> &'static str { + // Never format the error: it may contain private field names or values. + // The current string parser cannot produce I/O errors, but keep the + // exhaustive category closed without treating that case as success. + match error.classify() { + serde_json::error::Category::Syntax => "conversation_native_json_syntax", + serde_json::error::Category::Data => "conversation_native_json_data", + serde_json::error::Category::Eof => "conversation_native_json_eof", + serde_json::error::Category::Io => "conversation_native_json_io", + } +} + /// A closed local observation, used only after the complete worker report has /// been independently bound to this exact input/output. Never export the parser /// error: JSON diagnostics can contain private tool names or argument text. @@ -116,8 +128,8 @@ pub(super) fn rejection_code(input: &Input, output: &Value, id: &str) -> &'stati let Err(error) = checked_parse(input, raw, id) else { return "conversation_native_output_other"; }; - if error.is::() { - return "conversation_native_json"; + if let Some(error) = error.downcast_ref::() { + return json_rejection_code(error); } match error.to_string().as_str() { "conversation_request_id" => "conversation_request_id", @@ -151,3 +163,16 @@ pub(super) fn turn(input: &Input, output: &Value, id: &str) -> Result { None => Ok(incomplete("invalid_output")), } } + +#[cfg(test)] +mod tests { + #[test] + fn json_io_category_does_not_format_private_error_text() { + // Inert classification only; the production from_str path performs no I/O. + let error = serde_json::Error::io(std::io::Error::other("/PRIVATE_CANARY/field")); + assert_eq!( + super::json_rejection_code(&error), + "conversation_native_json_io" + ); + } +} diff --git a/crates/volparossa/src/compute/private_conversation/tests.rs b/crates/volparossa/src/compute/private_conversation/tests.rs index 338c20cf3..510863ad9 100644 --- a/crates/volparossa/src/compute/private_conversation/tests.rs +++ b/crates/volparossa/src/compute/private_conversation/tests.rs @@ -494,6 +494,68 @@ fn qwen_native_preface_preserves_exact_call_and_rejects_ambiguous_framing() { } } +#[test] +fn qwen_json_categories_preserve_strict_turns_and_literal_argument_delimiters() { + let value = input(); + let id = "ab".repeat(16); + for profile in [ModelProfile::Qwen600, ModelProfile::Qwen4bInstruct2507] { + let input = Input::decode_profile(&serde_json::to_vec(&value).unwrap(), profile).unwrap(); + for (body, code) in [ + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":!}}"#, + "conversation_native_json_syntax", + ), + ( + r#"{"name":false,"arguments":{"PRIVATE_CANARY":0}}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{},"PRIVATE_CANARY":0}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":0,"PRIVATE_CANARY":1}}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":"unfinished"#, + "conversation_native_json_eof", + ), + ] { + let output = native_output(&format!("{body}")); + let turn = qwen::turn(&input, &output, &id).unwrap(); + assert_eq!(turn, json!({"type":"incomplete","reason":"invalid_output"})); + assert_eq!(qwen::rejection_code(&input, &output, &id), code); + assert!(!code.contains("PRIVATE_CANARY")); + let report = json!({"id":id,"outputs":[output],"conversation":turn, + "prompt_tokens":128,"conversation_limits":capabilities(profile)}); + validate_report(&report, &serde_json::to_vec(&value).unwrap(), profile).unwrap(); + } + let literal = r#"{"name":"vp_0","arguments":{"text":"PRIVATE_CANARY and "}}"#; + let output = native_output(literal); + assert_eq!( + qwen::turn(&input, &output, &id).unwrap()["arguments"], + json!({"text":"PRIVATE_CANARY and "}) + ); + assert_eq!( + qwen::rejection_code(&input, &output, &id), + "conversation_native_output_other" + ); + let mut partial = native_output("{"); + partial["generation"]["stop_reason"] = "token_limit".into(); + partial["generated_tokens"] = 1024.into(); + assert_eq!( + qwen::turn(&input, &partial, &id).unwrap()["reason"], + "token_limit" + ); + partial["text_truncated"] = true.into(); + assert_eq!( + qwen::turn(&input, &partial, &id).unwrap()["reason"], + "wire_truncated" + ); + } +} + #[test] fn qwen_rejection_diagnostics_are_closed_and_keep_incomplete_wire_output() { let mut value = input(); @@ -509,7 +571,7 @@ fn qwen_rejection_diagnostics_are_closed_and_keep_incomplete_wire_output() { ), ( r#"{"PRIVATE_CANARY":0}"#, - "conversation_native_json", + "conversation_native_json_data", ), ( r#"{"name":"PRIVATE_CANARY","arguments":{}}"#, @@ -619,7 +681,7 @@ fn qwen_diagnostic_requires_bound_report_and_explicit_private_debug_target() { assert_eq!( serde_json::from_str::(record.trim()).unwrap(), json!({"version":1,"phase":"execution", - "detail":{"code":"conversation_native_json","io_kind":"none","exit_code":null,"signal":null,"stderr_class":null}}) + "detail":{"code":"conversation_native_json_data","io_kind":"none","exit_code":null,"signal":null,"stderr_class":null}}) ); } } diff --git a/crates/volparossa/src/compute/supervise/diagnostic.rs b/crates/volparossa/src/compute/supervise/diagnostic.rs index 68bbb4008..6e56daeb6 100644 --- a/crates/volparossa/src/compute/supervise/diagnostic.rs +++ b/crates/volparossa/src/compute/supervise/diagnostic.rs @@ -55,7 +55,12 @@ const FIXED_CODES: &[&str] = &[ "conversation_native_marker", "conversation_native_call", "conversation_native_preface", + // Retained for original receipts; new JSON rejections use closed categories. "conversation_native_json", + "conversation_native_json_syntax", + "conversation_native_json_data", + "conversation_native_json_eof", + "conversation_native_json_io", "conversation_unknown_tool", "conversation_custom_input", "conversation_arguments", diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 980b39475..0be27fb6e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -19,9 +19,10 @@ Last updated: 2026-10-07 - [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the existing isolated private-conversation worker. The source-only build of pinned - llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. This is **not yet - an actual model-conversion, generation-speed or successful OpenCode trial**. - The original timed-out 4B result remains failed in the evidence below. + llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. Actual conversion + and provisioning now pass, but the latest trial's model result is incomplete + (`invalid_output`): **no usable native turn or successful OpenCode coding task + is proved**. All original failed results remain in the evidence below. This first backend accepts only the original Qwen3-4B-Instruct-2507 profile and explicitly negotiated `greedy_v1`; public jobs, training and other profiles retain their existing backend. It does not establish confidential peer execution. @@ -205,7 +206,8 @@ check log SHA-256 is `adf689f10f35ebdeaf42f47d997e3496809f1683788d7dd7eacb1085622a5510`. One hundred twenty-five focused worker tests and twenty-one diagnostic-fixture tests pass, independently repeated by root. No patched model-loading or inference -trial has run, and no resource limit or task has been relaxed. +trial had run at that source-only checkpoint, and no resource limit or task was +relaxed. The subsequent actual trial is recorded below. The fixed-code Rust diagnostic test also passes on CI's Rust 1.85. Independent source review and a repeat of the actual tiny validation harness found no blocker. @@ -221,6 +223,38 @@ and tolerances; its 18 F32 vector cases and three BF16 checks pass (log SHA-256 This is scoped kernel evidence, not a passing complete upstream suite or model inference. Sanitized build artifacts cannot be provisioned as production models. +The subsequent [trial 37654339995](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37654339995), +attempt 1 on Code `8b5d5b6a` / core `f4cd8bd7`, remains **failed**. Native source +build, conversion and provisioning complete. One submitted model request returns +one cleanup-confirmed incomplete result (`invalid_output`); no usable turn, tool +call, permission request or completed command is observed. The provider reports +`invalid_model_output` once, with zero `execution_failed`, token-limit and +wire-truncation events. Core records `result_observed` once and +`conversation_native_json` twice: the latter are two validations of the same +report, not two executions. The tagged tool-call body failed typed JSON +deserialization, but the historical closed code does not distinguish syntax, +schema/type, duplicate fields or incomplete JSON. No raw model output was +exported, and no unique prompt, template or model-loading cause is established. + +Core cgroup peak is 4,555,878,400 bytes against 11 GiB, with zero OOM kills and +swap. Task-memory and graceful-core-stop fields are absent; they are not zero or +successful observations. Reported owned units/process-group/private data and +outer QEMU/scratch cleanup pass. Normalized route/DNS observations match, while +raw IPv6 route hashes differ; this is scoped observation, not byte-identical host +state or a complete firewall audit. Original artifact `11499080343` ZIP SHA-256: +`c7d3ed9150e8eb8e7d58a65ed46b0179c997a74f2191c621cf0109552278b6dd`; +job `112905525075` decoded log SHA-256: +`fd62b12408eee3e4bbf6c994a84a9cede4fd088806103525782cf428f1ff6610`. +The original archives, logs and consumed receipts remain unchanged. + +The diagnostic-only follow-up separates Serde's closed JSON error categories +(`syntax`, `data`, `eof`, `io`) after the unchanged report binding. Historical +generic codes remain readable. It exports no parser message, field name, source +position or output fragment and leaves `invalid_output`, single-call acceptance, +the complete prompt/model and every resource/deadline bound unchanged. This is +source-level diagnostic coverage, not a repaired model result or passing coding +trial; no new model execution has been performed for this follow-up. + ### Bank and Transaction layer implementation The 2026-10-04 user extension adds VOLPAROSSA Bank and a reusable Transaction-layer. diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index e1356814c..53b91e541 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -67,6 +67,8 @@ 'compute_stderr_size', 'compute_stderr_read', 'compute_wait', 'compute_worker_exit', 'conversation_request_id', 'conversation_native_marker', 'conversation_native_call', 'conversation_native_preface', 'conversation_native_json', 'conversation_unknown_tool', + 'conversation_native_json_syntax', 'conversation_native_json_data', + 'conversation_native_json_eof', 'conversation_native_json_io', 'conversation_custom_input', 'conversation_arguments', 'conversation_call_id', 'conversation_empty_answer', 'conversation_native_output_other', 'JOB_INPUT_NOT_FOUND', 'JOB_PATH_PERMISSION_DENIED', 'JOB_MEMORY_EXHAUSTED', 'BACKEND_IMPORT_FAILED', diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index a724d3fa9..f5397d44b 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -222,7 +222,7 @@ def test_service_diagnostic_is_bounded_and_requires_owner_only_regular_file(self def test_native_output_subtypes_are_fixed_local_observations_not_raw_errors(self): codes = {code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_')} - self.assertEqual(len(codes), 11) + self.assertEqual(len(codes), 15) events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] with tempfile.TemporaryDirectory(prefix='qwen-fixture-test-', dir=HERE) as directory: @@ -251,6 +251,26 @@ def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(se self.assertIn('--property=StandardOutput=append:{diagnostic_log}', fixture) self.assertIn('--property=StandardError=append:{diagnostic_log}', fixture) + def test_native_json_diagnostics_keep_old_code_and_only_four_closed_categories(self): + codes = {'conversation_native_json', 'conversation_native_json_syntax', 'conversation_native_json_data', + 'conversation_native_json_eof', 'conversation_native_json_io'} + self.assertEqual({code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_native_json')}, codes) + events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] + with tempfile.TemporaryDirectory(prefix='native-json-diagnostic-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + unknown = dict(events[0], detail=dict(events[0]['detail'], code='conversation_native_json_PRIVATE_CANARY')) + leaked = dict(events[0], detail=dict(events[0]['detail'], message='/PRIVATE_CANARY/secret', line=2, column=4)) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(event) + '\n' + for event in [*events, unknown, leaked])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('CANARY', json.dumps(result)) + self.assertNotIn('column', json.dumps(result)) + def test_native_load_diagnostics_preserve_only_closed_phases(self): codes = {code for code in FIX['SERVICE_CODES'] if 'NATIVE' in code} self.assertEqual(len(codes), 13) From 84cfc466ef9eec4f09412e7f661e2b773f98bb73 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:07:53 +0200 Subject: [PATCH 09/39] Bind native verification to measured source and executed GGUF --- crates/volparossa/src/compute/native_cpu.rs | 5 +- .../src/compute/native_cpu/tests.rs | 19 +++ .../src/compute/private_conversation/WIRE.md | 25 ++- docs/IMPLEMENTATION_STATUS.md | 54 ++++++- workers/volparossa-ml/llama_cpu.py | 19 ++- workers/volparossa-ml/test_llama_cpu.py | 144 +++++++++++++++++- workers/volparossa-ml/worker.py | 22 ++- 7 files changed, 268 insertions(+), 20 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu.rs b/crates/volparossa/src/compute/native_cpu.rs index e4e341722..f7d6f60f0 100644 --- a/crates/volparossa/src/compute/native_cpu.rs +++ b/crates/volparossa/src/compute/native_cpu.rs @@ -99,7 +99,10 @@ impl Manifest { json!({"kind":KIND,"abi_version":1,"source_commit":SOURCE, "manifest_sha256":hash,"library_sha256":self.library.sha256, "gguf_sha256":self.gguf.sha256,"gguf_bytes":self.gguf.bytes, - "source_weights_sha256":WEIGHTS}) + "source_weights_sha256":WEIGHTS, + "verification_scope":{"version":1, + "source_weights":"initial_complete_bytes_only", + "execution_weights":"gguf_complete_bytes_before_and_after"}}) } } diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs index 834336969..0e4951dd5 100644 --- a/crates/volparossa/src/compute/native_cpu/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -232,6 +232,16 @@ fn result_must_bind_authorized_native_identity_and_honest_precision() { ("/inference_backend/manifest_sha256", json!("0".repeat(64))), ("/inference_backend/library_sha256", json!("0".repeat(64))), ("/inference_backend/gguf_sha256", json!("0".repeat(64))), + ("/inference_backend/verification_scope/version", json!(true)), + ("/inference_backend/verification_scope/version", json!(2)), + ( + "/inference_backend/verification_scope/source_weights", + json!("before_and_after"), + ), + ( + "/inference_backend/verification_scope/execution_weights", + json!("initial_only"), + ), ("/model_parameter_dtype", json!("bfloat16")), ("/model_attention_backend", json!("sdpa")), ] { @@ -239,6 +249,15 @@ fn result_must_bind_authorized_native_identity_and_honest_precision() { *bad.pointer_mut(pointer).unwrap() = replacement; assert!(request.check_report(&bad).is_err()); } + let mut old = good.clone(); + old["inference_backend"] + .as_object_mut() + .unwrap() + .remove("verification_scope"); + assert!(request.check_report(&old).is_err()); + let mut extra = good; + extra["inference_backend"]["verification_scope"]["unverified"] = json!(true); + assert!(request.check_report(&extra).is_err()); } #[test] diff --git a/crates/volparossa/src/compute/private_conversation/WIRE.md b/crates/volparossa/src/compute/private_conversation/WIRE.md index d9bbf3922..c7b301144 100644 --- a/crates/volparossa/src/compute/private_conversation/WIRE.md +++ b/crates/volparossa/src/compute/private_conversation/WIRE.md @@ -236,9 +236,28 @@ its weight length is 8,044,982,000 bytes. It is not the index's hash, nor the index's tensor-size metadata. Worker `model.files` retains the real index and shard names. Only this profile adds `model.weights` with layout `safetensors_shards_concat_v1`, aggregate `bytes`/`sha256`, and ordered `files`. -Provisioning and the worker verify every original shard plus the aggregate; the -worker verifies again after generation and the Rust supervisor checks the exact -reported asset set/identity. Single-file profile reports remain unchanged. +Provisioning and the worker verify every original shard plus the aggregate. The +PyTorch backend verifies those execution weights again after generation; its +behavior and single-file profile reports remain unchanged. The Rust supervisor +checks the exact reported asset set/identity for both backends. + +For the explicit `llama_cpp_bf16_v1` backend, the original shards are conversion +source provenance, not the weights read by native inference. The worker carries +the actual complete initial shard/aggregate measurement into `model.weights`; +it does not reconstruct that measurement from expected constants or claim a +second source-shard verification at the end. The owner-bound converted GGUF is +fully hashed both before native loading and after the native handle is closed. +Converter tensor-value equality, original model identity, native complete tensor +validation and library/build/manifest authorization remain required. + +The internal native `inference_backend` report must now include exactly +`verification_scope: {"version":1,"source_weights":"initial_complete_bytes_only", +"execution_weights":"gguf_complete_bytes_before_and_after"}`. The core rejects +missing, unknown or different scope, including old worker reports without this +field; there is no permissive interpretation or fallback. This intentionally +changes the internal native verification contract, not the public conversation +schema. It does not attest continuous filesystem immutability against the local +owner, successful inference, or private execution on other nodes. Candidate resources are CPU BF16/SDPA, at most two threads, 600 seconds per task, 10GiB observed RSS, 24GiB address space and 10.5GiB known spare memory before diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ddf1cdc4c..f62a45a42 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -20,9 +20,10 @@ Last updated: 2026-10-07 - [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the existing isolated private-conversation worker. The source-only build of pinned llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. Actual conversion - and provisioning now pass, but the latest trial's model result is incomplete - (`invalid_output`): **no usable native turn or successful OpenCode coding task - is proved**. All original failed results remain in the evidence below. + and provisioning now pass, but the latest trial ends at `compute_deadline` + during post-generation verification: **no usable native turn or successful + OpenCode coding task is proved**. The earlier `invalid_output` result and all + original failed trials remain in the evidence below. This first backend accepts only the original Qwen3-4B-Instruct-2507 profile and explicitly negotiated `greedy_v1`; public jobs, training and other profiles retain their existing backend. It does not establish confidential peer execution. @@ -253,7 +254,52 @@ generic codes remain readable. It exports no parser message, field name, source position or output fragment and leaves `invalid_output`, single-call acceptance, the complete prompt/model and every resource/deadline bound unchanged. This is source-level diagnostic coverage, not a repaired model result or passing coding -trial; no new model execution has been performed for this follow-up. +trial. No new model execution had run at that checkpoint; its subsequent actual +trial is recorded below. + +The diagnostic follow-up [trial 37671030306](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37671030306), +attempt 1 on Code `2b2687fec5376b6e49f8266dff71cc7935845e99` / core +`36d616fb6d79a36fe7c8ee95c4ad84bd56b561ae`, **fails at the execution +deadline**, not at the earlier JSON rejection. Build, conversion and provisioning +complete. The worker records 44 generated tokens and an internal generation +completion at 518,788 ms, then `verify_after/begin` at 519,120 ms. Its last state +is paused under `quiet_hold`, with all 31 issued controls acknowledged and pause +as the last acknowledgement. Core reports `compute_deadline`; the provider +records one cleanup-confirmed `execution_budget_exceeded` request. Internal +generation completion is not a verified result delivered to OpenCode. + +The task ends after 603,331 ms with zero completed native turns, observed tool +calls, approved reads/edits/tests or completed commands. The fixture is unchanged +and independent verification is not reached. Core cgroup peak is 9,466,974,208 +bytes within 11 GiB, with zero OOM kills and swap. These checkpoints do not measure +total pause time or establish a unique cause of the deadline overrun. Reported +runtime/project, guest-unit/private-data, QEMU/scratch and owned CI-host cleanup +pass. Normalized route/DNS observations match, while the raw byte comparison is +false; this is not byte-identical host state or a complete firewall audit. No raw +model output was exported. + +Original artifact `11505764017` ZIP SHA-256: +`935e115a86abf1da83fe0e3d68ebac90d37ecbe399c7dcc79f055ff26dc33e83`; +job `112962546447` original raw log, 68,786 bytes, SHA-256: +`369407954fff82728d2ff41d5d0e229c78726ea8ae487bf2112964138ca492d8`. +The log is retained without decoding or newline normalization. Earlier failures, +original receipts, model/task checks and resource/deadline bounds are unchanged. + +The next source candidate makes native verification scope explicit. Native +inference reads the converted GGUF, not the original conversion-source shards. +The worker retains the actual complete initial shard/aggregate measurement and +fully verifies the used GGUF before loading and after closing the native handle. +It no longer reads the unused 8,044,982,000 source bytes a second time after +generation. The PyTorch execution-weight checks remain unchanged. + +A mandatory closed `verification_scope` in the internal native report states +this distinction; Rust rejects old reports, missing scope and extra or different +scope. Conversion tensor checks, complete GGUF hashes, model identity, owner +controls, resources and deadlines are unchanged. The focused Python and Rust +contract checks and independent source review pass. This removes redundant work, +not the failed trial: **a new complete native turn and OpenCode coding scenario +still need actual execution evidence**. See the +[verification contract](../crates/volparossa/src/compute/private_conversation/WIRE.md). ### Browser consent cooperation and public filter snapshots diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index e7490035b..ce740cd14 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -29,6 +29,11 @@ ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) +def verification_scope(): + return dict(version=1, source_weights="initial_complete_bytes_only", + execution_weights="gguf_complete_bytes_before_and_after") + + def loader_provenance(): return dict(kind="serial_complete_tensor_validation_v1", original_path="src/llama-model-loader.cpp", original_sha256=LOADER_ORIGINAL_SHA, effective_sha256=LOADER_EFFECTIVE_SHA, @@ -107,7 +112,8 @@ def verified_bundle(request, worker, tokenizer, session): == value["verification"]["chat_template_sha256"], "NATIVE_TEMPLATE_BINDING") identity = dict(kind=KIND, abi_version=1, source_commit=SOURCE, manifest_sha256=expected, library_sha256=value["library"]["sha256"], gguf_sha256=value["gguf"]["sha256"], - gguf_bytes=value["gguf"]["bytes"], source_weights_sha256=WEIGHTS_SHA) + gguf_bytes=value["gguf"]["bytes"], source_weights_sha256=WEIGHTS_SHA, + verification_scope=verification_scope()) return root, value, identity @@ -265,10 +271,18 @@ def generate(model, prompt, tokenizer, profile, session, worker, observation=Non def execute(request, session, tokenizer, torch, versions, model_root, output_root, - dataset, data_identity, model_files, worker): + dataset, data_identity, model_files, worker, *, initial_source_weights): worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", "NATIVE_GREEDY_POLICY_REQUIRED") profile = worker.model_profile(PROFILE) + worker.require(type(initial_source_weights) is dict and initial_source_weights == profile["weights"] + and all(model_files.get(name) == {"bytes": profile["files"][name], + "sha256": profile["hashes"][name]} for name in initial_source_weights["files"]), + "NATIVE_SOURCE_PROVENANCE") + # Carry measured provenance, not a digest reconstructed from expected pins. + # Only the GGUF below is used by NativeModel. The original source files are + # not claimed to have been checked again at the end of this execution. + weight_identity = dict(initial_source_weights, files=list(initial_source_weights["files"])) root, manifest, identity = verified_bundle(request, worker, tokenizer, session) session.private_progress("prompt_encode", "begin") prompt = worker.conversation_module().encode(tokenizer, dataset, profile) @@ -288,7 +302,6 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo finally: model.close() session.private_progress("verify_after", "begin") - weight_identity = worker.verify_sharded_weights(model_root, profile, session=session) worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"], session=session)["sha256"] == manifest["gguf"]["sha256"], "NATIVE_GGUF_CHANGED") session.private_progress("verify_after", "complete") diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py index 1c4f022b4..664a8638a 100644 --- a/workers/volparossa-ml/test_llama_cpu.py +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -23,6 +23,7 @@ import llama_cpu as native from test_worker_protocol import WORKER, request, controlled_pipe, control, dataset from test_qwen4b_weights import synthetic_shards +from test_conversation import conversation def job(): @@ -41,6 +42,92 @@ def manifest(): "chat_template_sha256": "d" * 64}, "build_manifest_sha256": "e" * 64} +def source_fixture(root): + model, output = root / "source", root / "output" + model.mkdir(mode=0o700) + output.mkdir(mode=0o700) + profile, _ = synthetic_shards(model) + config = b"{}" + (model / "config.json").write_bytes(config) + profile.update(config={}, new_tokens=1, id="inert-source", revision="inert-revision") + profile["files"]["config.json"] = len(config) + profile["hashes"]["config.json"] = hashlib.sha256(config).hexdigest() + data = root / "input.json" + data.write_text(json.dumps(dict(conversation(), generation_policy="greedy_v1"))) + selected = WORKER.validate_request(dict(job(), model_root=str(model), output_root=str(output), + dataset_path=str(data))) + return selected, profile + + +class NativeSourceProvenanceTests(unittest.TestCase): + def test_initial_measurement_is_retained_without_changing_prepared_tuple(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = source_fixture(Path(directory)) + measured, collector = [], {} + original = WORKER.verify_sharded_weights + + def verify(*args, **kwargs): + result = original(*args, **kwargs) + measured.append(result) + # Distinguish carrying the completed measurement from silently + # copying the mutable expected profile after it was verified. + profile["weights"]["sha256"] = "0" * 64 + return result + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "verify_sharded_weights", side_effect=verify): + prepared = WORKER.prepare_files(selected, initial_source=collector) + self.assertEqual(len(prepared), 5) + self.assertEqual(len(measured), 1) + self.assertEqual(collector, {"weights": measured[0]}) + self.assertIsNot(collector["weights"], measured[0]) + self.assertIsNot(collector["weights"]["files"], measured[0]["files"]) + self.assertNotEqual(collector["weights"], profile["weights"]) + self.assertEqual(collector["weights"]["sha256"], hashlib.sha256( + b"".join((prepared[0] / name).read_bytes() for name in profile["weight_shards"])).hexdigest()) + profile["weights"] = copy.deepcopy(measured[0]) + damaged = prepared[0] / profile["weight_shards"][-1] + damaged.write_bytes(b"x" * damaged.stat().st_size) + rejected = {} + with self.assertRaisesRegex(WORKER.JobError, "MODEL_WEIGHTS_CHANGED_ON_DISK"): + WORKER.prepare_files(selected, initial_source=rejected) + self.assertEqual(rejected, {}, "failed initial verification cannot create provenance") + + def test_collector_is_private_native_only_and_must_start_empty(self): + for change, collector in (({}, []), ({}, {"weights": {}}), + ({"inference_backend": None}, {}), + ({"model_profile": WORKER.QWEN_MODEL_PROFILE}, {}), + ({"mode": "private_infer"}, {})): + with self.subTest(change=change, collector=collector), \ + mock.patch.object(WORKER, "plain_path", side_effect=AssertionError("must fail before file access")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_SOURCE_PROVENANCE"): + WORKER.prepare_files(dict(job(), **change), initial_source=collector) + + def test_actual_worker_dispatch_forwards_the_measured_source_identity(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = source_fixture(Path(directory)) + tokenizer = SimpleNamespace(pad_token_id=151643, eos_token_id=151645) + transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=lambda *a, **k: tokenizer)) + backend = mock.Mock() + backend.execute.return_value = "inert native dispatch" + original = WORKER.verify_sharded_weights + measured = [] + + def verify(*args, **kwargs): + value = original(*args, **kwargs) + measured.append(value) + return value + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "verify_sharded_weights", side_effect=verify), \ + mock.patch.object(WORKER, "configure_offline"), \ + mock.patch.object(WORKER, "load_backend", return_value=(mock.Mock(), transformers, mock.Mock(), {})), \ + mock.patch.dict(WORKER.sys.modules, {"volparossa_llama_cpu": backend}): + self.assertEqual(WORKER.execute_job(selected, mock.Mock()), "inert native dispatch") + self.assertEqual(len(measured), 1) + self.assertEqual(backend.execute.call_args.kwargs, {"initial_source_weights": measured[0]}) + + class HashOwnerControlTests(unittest.TestCase): def hash_with_control(self, action): """Real files and owner pipes; never a model or a resource-pressure trial.""" @@ -412,6 +499,9 @@ def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(s mock.patch.object(WORKER, "file_hash", wraps=WORKER.file_hash) as hashed: _, _, identity = native.verified_bundle(selected, WORKER, SimpleNamespace(chat_template=template), session) self.assertEqual(identity["gguf_sha256"], value["gguf"]["sha256"]) + self.assertEqual(identity["verification_scope"], { + "version": 1, "source_weights": "initial_complete_bytes_only", + "execution_weights": "gguf_complete_bytes_before_and_after"}) self.assertEqual(len(hashed.call_args_list), 2) self.assertTrue(all(call.kwargs.get("session") is session for call in hashed.call_args_list)) with self.assertRaisesRegex(WORKER.JobError, "MANIFEST_DIGEST"): @@ -427,7 +517,7 @@ def test_owner_manifest_and_actual_artifact_hashes_checked_before_library_load(s value = manifest(); value["library"]["path"] = name with self.assertRaises(WORKER.JobError): native.validate_manifest(value, WORKER.require) - def test_native_verify_after_checks_original_shards_and_gguf_with_same_session(self): + def test_native_verify_after_checks_only_used_gguf_and_retains_initial_source(self): # Inert native doubles reach the actual post-execution hashing path; # these tests do not load a model or claim successful inference. with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, _): @@ -445,6 +535,8 @@ def test_native_verify_after_checks_original_shards_and_gguf_with_same_session(s bridge.encode.return_value = [1] selected = WORKER.validate_request(job()) os.write(writer, control(1)) + files = {} + initial = WORKER.verify_sharded_weights(model_root, profile, files, session=session) original_hash = WORKER.file_hash hashed = [] @@ -463,14 +555,55 @@ def checked(path, *args, **kwargs): mock.patch.object(native, "generate", return_value={}): with self.assertRaisesRegex(WORKER.JobError, "POST_HASH_REACHED"): native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, - {}, {}, {}, WORKER) + {}, {}, files, WORKER, initial_source_weights=initial) self.assertIs(bundle.call_args.args[-1], session) - self.assertEqual(set(hashed), set(profile["files"]) | {"model.gguf"}) + self.assertEqual(hashed, ["model.gguf"]) + result = WORKER.finish_result.call_args.args[0] + self.assertEqual(result["model"]["weights"], initial) + self.assertIsNot(result["model"]["weights"], initial) + self.assertIsNot(result["model"]["weights"]["files"], initial["files"]) + # Changing now-unused source bytes does not change the measured + # initial provenance or silently claim an end-of-run source check. + for name in profile["weight_shards"]: + (model_root / name).write_bytes(b"x" * profile["files"][name]) + model.reset_mock() + hashed.clear() + with self.assertRaisesRegex(WORKER.JobError, "POST_HASH_REACHED"): + native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, + {}, {}, files, WORKER, initial_source_weights=initial) + self.assertEqual(hashed, ["model.gguf"]) + self.assertEqual(WORKER.finish_result.call_args.args[0]["model"]["weights"], initial) model.reset_mock() (root / "model.gguf").write_bytes(b"x" * len(data)) with self.assertRaisesRegex(WORKER.JobError, "NATIVE_GGUF_CHANGED"): native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, - {}, {}, {}, WORKER) + {}, {}, files, WORKER, initial_source_weights=initial) + # The remaining post-GGUF check still services the same actual + # owner pipe after the native handle closes; cancel cannot emit + # a successful report or reuse the earlier source measurement + # as a substitute for finishing execution-weight verification. + model.reset_mock() + (root / "model.gguf").write_bytes(data) + completed_before_cancel = WORKER.finish_result.call_count + model.close.side_effect = lambda: os.write(writer, control(2, "cancel")) + with self.assertRaisesRegex(WORKER.JobError, "JOB_CANCELLED"): + native.execute(selected, session, mock.Mock(), mock.Mock(), {}, model_root, root, + {}, {}, files, WORKER, initial_source_weights=initial) + self.assertEqual(WORKER.finish_result.call_count, completed_before_cancel) + + def test_native_rejects_missing_or_changed_initial_provenance_before_loading(self): + profile = WORKER.model_profile(native.PROFILE) + files = {name: {"bytes": size, "sha256": profile["hashes"][name]} + for name, size in profile["files"].items()} + wrong = copy.deepcopy(profile["weights"]) + wrong["sha256"] = "0" * 64 + for initial, selected_files in ((None, files), ({}, files), (wrong, files), (profile["weights"], {})): + with self.subTest(initial=initial), \ + mock.patch.object(native, "verified_bundle", side_effect=AssertionError("must not load")), \ + mock.patch.object(WORKER, "conversation_module", return_value=SimpleNamespace(generation_policy=lambda *a: "greedy_v1")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_SOURCE_PROVENANCE"): + native.execute(job(), mock.Mock(), mock.Mock(), mock.Mock(), {}, None, None, + {}, {}, selected_files, WORKER, initial_source_weights=initial) def test_pause_and_resume_are_not_acknowledged_until_native_has_joined(self): with controlled_pipe() as (session, writer, wire): @@ -688,7 +821,8 @@ def test_native_execute_rejects_implicit_sampling_before_any_library_or_model_lo with mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ mock.patch.object(native, "NativeModel", side_effect=AssertionError("must not load")), \ self.assertRaisesRegex(WORKER.JobError, "NATIVE_GREEDY_POLICY_REQUIRED"): - native.execute(job(), mock.Mock(), mock.Mock(), mock.Mock(), {}, None, None, {}, {}, {}, WORKER) + native.execute(job(), mock.Mock(), mock.Mock(), mock.Mock(), {}, None, None, {}, {}, {}, WORKER, + initial_source_weights=None) def test_tensor_mapping_is_complete_for_exact_qwen_layers_not_arbitrary_names(self): self.assertEqual(conversion.tensor_name("model.layers.35.self_attn.q_norm.weight"), "blk.35.attn_q_norm.weight") diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index cc0e8a2d1..ddd4d4f4f 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -844,7 +844,15 @@ def verify_sharded_weights(model_root, profile, verified_files=None, *, session= return identity -def prepare_files(request, session=None): +def prepare_files(request, session=None, *, initial_source=None): + # Native inference consumes a converted GGUF, not these source shards. Keep + # their actually measured initial identity without changing the existing + # five-value API or the ordinary PyTorch before/after verification path. + if initial_source is not None: + require(type(initial_source) is dict and not initial_source + and request.get("inference_backend") == "llama_cpp_bf16_v1" + and request.get("model_profile") == QWEN4B_MODEL_PROFILE + and request.get("mode") == "private_conversation", "NATIVE_SOURCE_PROVENANCE") profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) model_root, model_metadata = plain_path(request["model_root"], True) @@ -863,7 +871,9 @@ def prepare_files(request, session=None): files = {name: file_hash(model_root / name, expected_size=size, session=session) for name, size in profile["files"].items() if name not in sharded} if sharded: - verify_sharded_weights(model_root, profile, files, session=session) + measured_weights = verify_sharded_weights(model_root, profile, files, session=session) + if initial_source is not None: + initial_source["weights"] = dict(measured_weights, files=list(measured_weights["files"])) require(all(files[name]["sha256"] == expected for name, expected in profile["hashes"].items()), "MODEL_FILES_NOT_PINNED") config = parse_json(read_bounded(model_root / "config.json", 8192)) @@ -2309,7 +2319,10 @@ def execute_job(request, session): session.progress("preparing") session.private_progress("owner_gate", "complete") session.private_progress("verify_files", "begin") - model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session) + initial_source = {} + preparation = ({"initial_source": initial_source} + if request.get("inference_backend") == "llama_cpp_bf16_v1" else {}) + model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session, **preparation) session.private_progress("verify_files", "complete") session.check() cohort = None @@ -2342,7 +2355,8 @@ def execute_job(request, session): native_backend = sys.modules.get("volparossa_llama_cpu") require(native_backend is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") return native_backend.execute(request, session, tokenizer, torch, versions, model_root, output_root, - dataset, data_identity, model_files, SimpleNamespace(**globals())) + dataset, data_identity, model_files, SimpleNamespace(**globals()), + initial_source_weights=initial_source.get("weights")) if request["mode"] == "plan_document": plan = plan_document(tokenizer, dataset, session, profile_name) raw = json.dumps(plan, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")).encode("ascii") From 4e5126ad14f45ab861ab3f4e318c58f193d954b1 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:29:33 +0200 Subject: [PATCH 10/39] feat(compute): bind optional native tool output constraints --- .github/workflows/native-cpu-contract.yml | 76 ++++ .github/workflows/quality.yml | 2 +- THIRD_PARTY_LICENSES.md | 10 + crates/volparossa/src/compute.rs | 6 +- crates/volparossa/src/compute/native_cpu.rs | 84 +++- .../src/compute/native_cpu/tests.rs | 2 + .../native_cpu/tests/output_constraint.rs | 120 ++++++ .../src/compute/private_conversation.rs | 58 ++- .../src/compute/private_conversation/tests.rs | 2 + .../tests/output_constraint.rs | 177 +++++++++ .../volparossa/src/compute/private_serve.rs | 22 ++ .../src/compute/private_serve/tests.rs | 2 + .../private_serve/tests/output_constraint.rs | 160 ++++++++ .../src/compute/private_serve/wire.rs | 7 + docs/IMPLEMENTATION_STATUS.md | 30 +- workers/volparossa-ml/build_llama_cpu.py | 13 +- workers/volparossa-ml/conversation.py | 36 +- workers/volparossa-ml/convert_llama_cpu.py | 2 + workers/volparossa-ml/llama_cpu.py | 74 +++- .../volparossa-ml/native-cpu/CMakeLists.txt | 8 +- workers/volparossa-ml/native-cpu/README.md | 26 ++ .../volparossa-ml/native-cpu/abi-smoke.cpp | 2 + workers/volparossa-ml/native-cpu/adapter.cpp | 73 ++++ workers/volparossa-ml/native-cpu/adapter.h | 6 + .../native-cpu/output-constraint-tests.cpp | 205 ++++++++++ .../native-cpu/output-constraint.cpp | 192 +++++++++ .../native-cpu/output-constraint.h | 30 ++ .../test_conversion_diagnostic.py | 25 +- .../test_native_output_constraint.py | 368 ++++++++++++++++++ workers/volparossa-ml/worker.py | 30 +- 30 files changed, 1823 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/native-cpu-contract.yml create mode 100644 crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs create mode 100644 crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs create mode 100644 crates/volparossa/src/compute/private_serve/tests/output_constraint.rs create mode 100644 workers/volparossa-ml/native-cpu/output-constraint-tests.cpp create mode 100644 workers/volparossa-ml/native-cpu/output-constraint.cpp create mode 100644 workers/volparossa-ml/native-cpu/output-constraint.h create mode 100644 workers/volparossa-ml/test_native_output_constraint.py diff --git a/.github/workflows/native-cpu-contract.yml b/.github/workflows/native-cpu-contract.yml new file mode 100644 index 000000000..1541db622 --- /dev/null +++ b/.github/workflows/native-cpu-contract.yml @@ -0,0 +1,76 @@ +name: Native CPU source contracts + +on: + push: + branches: + - feature/native-cpu-inference + - feature/native-coding-output-constraint + paths: + - workers/volparossa-ml/native-cpu/** + - workers/volparossa-ml/build_llama_cpu.py + - workers/volparossa-ml/llama_cpu.py + - .github/workflows/native-cpu-contract.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-cpu-contract-${{ github.ref }} + cancel-in-progress: false + +jobs: + native: + name: Source-only ${{ matrix.mode }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + mode: [normal, asan-ubsan] + env: + PYTHONDONTWRITEBYTECODE: '1' + NATIVE_MODE: ${{ matrix.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require disposable hosted runner and exact source + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$RUNNER_OS" = Linux + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -z "$(git status --porcelain)" + for tool in cmake c++ cc git readelf; do command -v "$tool"; done + - name: Fetch the pinned upstream source only + run: | + set -euo pipefail + mkdir -p build/native-contract + git init build/native-contract/llama + git -C build/native-contract/llama remote add origin https://github.com/ggml-org/llama.cpp.git + git -C build/native-contract/llama fetch --depth=1 origin 7fe450e19305b828c199d602c23a8337aaa1f03b + git -C build/native-contract/llama checkout --detach 7fe450e19305b828c199d602c23a8337aaa1f03b + test "$(git -C build/native-contract/llama rev-parse HEAD)" = 7fe450e19305b828c199d602c23a8337aaa1f03b + - name: Compile and run bounded native and upstream checks without models + run: | + set -euo pipefail + flags=() + if test "$NATIVE_MODE" = asan-ubsan; then flags+=(--sanitize); fi + env -i PATH=/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ + python3 -B workers/volparossa-ml/build_llama_cpu.py \ + --source "$PWD/build/native-contract/llama" \ + --output "$PWD/build/native-contract/output" --execute --checks "${flags[@]}" + - name: Retain original source-only reports and logs, including failures + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-source-${{ matrix.mode }}-${{ github.sha }} + path: | + build/native-contract/output/build.json + build/native-contract/output/build.log + build/native-contract/output/checks.log + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 50f8b530d..ae8d18e33 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -78,7 +78,7 @@ jobs: - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml - run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic + run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint - name: Install pinned dependency-audit tools run: | diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index d605c7287..fce603014 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -6,6 +6,16 @@ for the license text shipped by each upstream project. ## Explicit llama.cpp CPU inference candidate +The optional `qwen_tagged_tool_json_v1` profile also uses the pinned internal +`src/llama-grammar.h` API for grammar initialization, cloning, string acceptance, +candidate filtering and freeing. VOLPAROSSA's `output-constraint.h` and `.cpp` +wrap this unchanged upstream implementation; they are original GPL-3.0-only +code, not an upstream patch. Build manifest v3 binds these wrapper files along +with the existing adapter and loader patch. Synthetic-piece tests link the exact +grammar engine without loading a model. API compatibility and sanitizer behavior +require a fresh source build; previous backend receipts do not validate this +extension. + The opt-in CPU backend uses [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp) at exact commit `7fe450e19305b828c199d602c23a8337aaa1f03b` (resolved release tag `v0.5.0`; MIT). The tag is unsigned. No prebuilt upstream executable, server, diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index b7d6b03f5..04a5afd28 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -252,7 +252,11 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result>, +} + +fn present_output_constraints<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result>, D::Error> { + Vec::::deserialize(deserializer).map(Some) } impl Manifest { fn validate(&self) -> Result<()> { ensure!( - self.version == 1 + ((self.version == 1 && self.output_constraints.is_none()) + || (self.version == 2 + && self.output_constraints.as_deref() + == Some(&[OutputConstraint::QwenTaggedToolJsonV1]))) && self.kind == KIND && self.abi_version == 1 && self.source_commit == SOURCE @@ -132,9 +153,38 @@ impl Options { "compute_native_backend_policy" ); } + if mode == Mode::PrivateConversation { + let input = super::private_conversation::Input::decode_profile(raw, profile)?; + if input.output_constraint().is_some() { + self.request(profile)?.select_constraint(&input)?; + } + } Ok(()) } + pub(super) fn supports_output_constraint(&self, profile: ModelProfile) -> bool { + self.request(profile) + .is_ok_and(|request| request.supported_output_constraint.is_some()) + } + + pub(super) fn request_for_dataset( + &self, + profile: ModelProfile, + mode: Mode, + dataset: &Path, + ) -> Result { + let mut request = self.request(profile)?; + if mode == Mode::PrivateConversation { + let raw = super::read_file( + dataset, + super::private_conversation::request_frame(profile) as u64, + )?; + let input = super::private_conversation::Input::decode_profile(&raw, profile)?; + request.select_constraint(&input)?; + } + Ok(request) + } + pub(super) fn request(&self, profile: ModelProfile) -> Result { let (root, hash) = match (&self.native_backend_root, &self.native_backend_sha256) { (None, None) => return Ok(Request::default()), @@ -172,6 +222,12 @@ impl Options { native_backend_root: Some("/native-backend"), native_backend_sha256: Some(hash.clone()), expected_report: Some(manifest.report(hash)), + supported_output_constraint: manifest + .output_constraints + .as_ref() + .and_then(|values| values.first().copied()), + output_constraint: None, + output_constraint_tools: None, }) } } @@ -184,11 +240,29 @@ pub(super) struct Request { native_backend_root: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] native_backend_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_constraint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_constraint_tools: Option>, #[serde(skip)] expected_report: Option, + #[serde(skip)] + supported_output_constraint: Option, } impl Request { + fn select_constraint(&mut self, input: &super::private_conversation::Input) -> Result<()> { + if let Some(constraint) = input.output_constraint() { + ensure!( + self.enabled() && self.supported_output_constraint == Some(constraint), + "compute_native_backend_output_constraint" + ); + } + self.output_constraint = input.output_constraint(); + self.output_constraint_tools = input.output_constraint_tools(); + Ok(()) + } + pub(super) fn enabled(&self) -> bool { self.inference_backend.is_some() } @@ -198,6 +272,12 @@ impl Request { value.get("inference_backend") == self.expected_report.as_ref(), "compute_native_backend_result_binding" ); + let expected_constraint = self.output_constraint.map(|constraint| json!(constraint)); + ensure!( + value.get("output_constraint") == expected_constraint.as_ref() + && value.get("output_constraint_tools").is_none(), + "compute_native_backend_output_constraint_binding" + ); if self.enabled() { ensure!( value["model_parameter_dtype"] == "bf16_with_exact_f32_norms" diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs index 0e4951dd5..a80dda70c 100644 --- a/crates/volparossa/src/compute/native_cpu/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -5,6 +5,8 @@ use clap::{CommandFactory as _, error::ErrorKind}; use super::*; +mod output_constraint; + #[test] fn native_flattened_options_keep_full_cli_argument_groups_unique() { crate::Cli::command().debug_assert(); diff --git a/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs new file mode 100644 index 000000000..f1126e2bd --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs @@ -0,0 +1,120 @@ +use super::{super::super::private_conversation::Input, *}; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn constrained_manifest() -> Value { + let mut value = manifest(); + value["version"] = 2.into(); + value["output_constraints"] = json!([CONSTRAINT]); + value +} + +fn input() -> Vec { + serde_json::to_vec(&json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Read."}], + "tools":[{"type":"custom","name":"PRIVATE_CANARY","description":"Read."}], + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT})) + .unwrap() +} + +fn constrained_fixture() -> (tempfile::TempDir, Options) { + let (root, mut options) = fixture(); + let raw = serde_json::to_vec(&constrained_manifest()).unwrap(); + fs::write(root.path().join("backend.json"), &raw).unwrap(); + options.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + (root, options) +} + +#[test] +fn manifest_version_binds_exact_constraint_capability_without_legacy_widening() { + for (version, constraints, accepted) in [ + (1, None, true), + (1, Some(Value::Null), false), + (1, Some(json!([])), false), + (2, None, false), + (1, Some(json!([CONSTRAINT])), false), + (2, Some(json!([CONSTRAINT])), true), + (3, Some(json!([CONSTRAINT])), false), + (2, Some(Value::Null), false), + (2, Some(json!([])), false), + (2, Some(json!([CONSTRAINT, CONSTRAINT])), false), + (2, Some(json!(["other"])), false), + (2, Some(json!([true])), false), + ] { + let mut value = manifest(); + value["version"] = version.into(); + if let Some(constraints) = constraints { + value["output_constraints"] = constraints; + } + let valid = serde_json::from_value::(value) + .is_ok_and(|manifest| manifest.validate().is_ok()); + assert_eq!(valid, accepted); + } +} + +#[test] +fn constrained_worker_request_requires_authorized_manifest_and_core_aliases() { + let raw = input(); + let (_old_root, legacy) = fixture(); + assert!(!legacy.supports_output_constraint(PROFILE)); + assert!( + legacy + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .is_err() + ); + assert!( + Options::default() + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .is_err() + ); + let (root, mut options) = constrained_fixture(); + assert!(options.supports_output_constraint(PROFILE)); + assert!(!options.supports_output_constraint(ModelProfile::Qwen600)); + options + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .unwrap(); + let dataset = root.path().join("input.json"); + fs::write(&dataset, raw).unwrap(); + let request = options + .request_for_dataset(PROFILE, Mode::PrivateConversation, &dataset) + .unwrap(); + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 5); + assert_eq!(value["output_constraint"], CONSTRAINT); + assert_eq!(value["output_constraint_tools"], json!(["vp_0"])); + assert!(!value.to_string().contains("PRIVATE_CANARY")); + assert!(!value.to_string().contains(root.path().to_str().unwrap())); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(!options.supports_output_constraint(PROFILE)); +} + +#[test] +fn support_does_not_select_constraint_and_result_cannot_claim_unrequested_support() { + let (_root, options) = constrained_fixture(); + let mut request = options.request(PROFILE).unwrap(); + let legacy = serde_json::to_value(&request).unwrap(); + assert_eq!(legacy.as_object().unwrap().len(), 3); + let mut result = json!({"inference_backend":request.expected_report, + "model_parameter_dtype":"bf16_with_exact_f32_norms","model_attention_backend":"llama_cpp_cpu"}); + request.check_report(&result).unwrap(); + result["output_constraint"] = CONSTRAINT.into(); + assert!(request.check_report(&result).is_err()); + let input = Input::decode_profile(&input(), PROFILE).unwrap(); + request.select_constraint(&input).unwrap(); + request.check_report(&result).unwrap(); + let mut extra = result.clone(); + extra["output_constraint_tools"] = json!(["vp_0"]); + assert!(request.check_report(&extra).is_err()); + for invalid in [Value::Null, json!(false), json!("other")] { + result["output_constraint"] = invalid; + assert!(request.check_report(&result).is_err()); + } + result.as_object_mut().unwrap().remove("output_constraint"); + assert!(request.check_report(&result).is_err()); + let value = serde_json::to_value(&request).unwrap(); + let mut restored = value.as_object().unwrap().clone(); + restored.remove("output_constraint"); + restored.remove("output_constraint_tools"); + assert_eq!(Value::Object(restored), legacy); +} diff --git a/crates/volparossa/src/compute/private_conversation.rs b/crates/volparossa/src/compute/private_conversation.rs index fdb4dcde4..f3a596876 100644 --- a/crates/volparossa/src/compute/private_conversation.rs +++ b/crates/volparossa/src/compute/private_conversation.rs @@ -28,6 +28,12 @@ pub(super) struct Input { deserialize_with = "present_generation_policy" )] generation_policy: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_output_constraint" + )] + output_constraint: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -43,6 +49,12 @@ fn present_generation_policy<'de, D: serde::Deserializer<'de>>( GenerationPolicy::deserialize(deserializer).map(Some) } +fn present_output_constraint<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + super::native_cpu::OutputConstraint::deserialize(deserializer).map(Some) +} + /// The supervisor must not bind a result after silently accepting duplicate keys. pub(super) fn decode_worker_message(raw: &[u8]) -> Result { #[derive(Deserialize)] @@ -139,6 +151,18 @@ fn arguments(value: &Value) -> bool { value.is_object() && serde_json::to_vec(value).is_ok_and(|raw| raw.len() <= 4096) } +fn constrained_argument_depth(value: &Value, depth: u8) -> bool { + match value { + Value::Object(values) => { + depth <= 16 && values.values().all(|value| constrained_argument_depth(value, depth + 1)) + } + Value::Array(values) => { + depth <= 16 && values.iter().all(|value| constrained_argument_depth(value, depth + 1)) + } + _ => true, + } +} + impl Tool { fn key(&self) -> (&str, Option<&str>) { match self { @@ -180,6 +204,18 @@ impl Tool { } impl Input { + pub(super) fn output_constraint(&self) -> Option { + self.output_constraint + } + + pub(super) fn output_constraint_tools(&self) -> Option> { + self.output_constraint.map(|_| { + (0..self.tools.len()) + .map(|index| format!("vp_{index}")) + .collect() + }) + } + pub(super) fn requires_generation_policy_handshake(&self) -> bool { self.generation_policy.is_some() } @@ -217,6 +253,13 @@ impl Input { fn validate_profile(&self, profile: ModelProfile) -> Result<()> { let bounds = limits::for_profile(profile); + ensure!( + self.output_constraint.is_none() + || (profile == ModelProfile::Qwen4bInstruct2507 + && self.generation_policy == Some(GenerationPolicy::GreedyV1) + && (1..=32).contains(&self.tools.len())), + "conversation_output_constraint" + ); ensure!( self.generation_policy.is_none() || profile.is_native_conversation(), "conversation_generation_policy" @@ -333,7 +376,12 @@ impl Input { arguments: args, } => { self.call(call_id, name, namespace.as_deref(), false, &seen)?; - ensure!(arguments(args), "conversation_arguments"); + ensure!( + arguments(args) + && (self.output_constraint.is_none() + || constrained_argument_depth(args, 1)), + "conversation_arguments" + ); } Output::CustomToolCall { call_id, @@ -401,6 +449,11 @@ pub(super) fn validate_report(report: &Value, raw: &[u8], profile: ModelProfile) report.get("generation_policy") == expected_policy.as_ref(), "conversation_generation_policy_binding" ); + let expected_constraint = input.output_constraint.map(|constraint| json!(constraint)); + ensure!( + report.get("output_constraint") == expected_constraint.as_ref(), + "conversation_output_constraint_binding" + ); let expected = if profile.is_native_conversation() { qwen::turn( &input, @@ -445,6 +498,9 @@ pub(super) fn summary(report: &Value, profile: ModelProfile) -> Value { if let Some(policy) = report.get("generation_policy") { result["generation_policy"] = policy.clone(); } + if let Some(constraint) = report.get("output_constraint") { + result["output_constraint"] = constraint.clone(); + } result } diff --git a/crates/volparossa/src/compute/private_conversation/tests.rs b/crates/volparossa/src/compute/private_conversation/tests.rs index 510863ad9..ca94c7179 100644 --- a/crates/volparossa/src/compute/private_conversation/tests.rs +++ b/crates/volparossa/src/compute/private_conversation/tests.rs @@ -1,6 +1,8 @@ //! Admission/report tests only; none assert model inference or tool-use quality. use super::*; +mod output_constraint; + fn input() -> Value { json!({"version":1,"visibility":"private_local","instructions":"Review the supplied code.", "tools":[{"type":"function","name":"read_file","description":"Read an approved file.", diff --git a/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs new file mode 100644 index 000000000..69e170275 --- /dev/null +++ b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs @@ -0,0 +1,177 @@ +//! Pure wire and original-text validation; no model execution or grammar quality claim. +use super::*; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn selected() -> Value { + let mut value = input(); + value["tools"][0]["namespace"] = Value::Null; + value["generation_policy"] = "greedy_v1".into(); + value["output_constraint"] = CONSTRAINT.into(); + value +} + +fn decode_selected(value: &Value) -> Result { + Input::decode_profile(&serde_json::to_vec(value).unwrap(), PROFILE) +} + +fn report(text: &str) -> Value { + let mut output = native_output(text); + output["generation"]["model_profile"] = PROFILE.to_string().into(); + output["generation"]["max_new_tokens"] = PROFILE.spec().max_new_tokens.into(); + let input = decode_selected(&selected()).unwrap(); + let id = "ab".repeat(16); + json!({"id":id,"outputs":[output],"conversation":qwen::turn(&input,&output,&id).unwrap(), + "prompt_tokens":128,"conversation_limits":capabilities(PROFILE), + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}) +} + +#[test] +fn constraint_is_explicit_nonnull_qwen4b_greedy_with_offered_tools_only() { + let value = selected(); + let decoded = decode_selected(&value).unwrap(); + assert_eq!(decoded.output_constraint_tools(), Some(vec!["vp_0".into()])); + assert_eq!( + serde_json::from_slice::(&decoded.bytes_profile(PROFILE).unwrap()).unwrap(), + value + ); + for invalid in [Value::Null, json!(true), json!(1), json!([]), json!("other")] { + let mut bad = value.clone(); + bad["output_constraint"] = invalid; + assert!(decode_selected(&bad).is_err()); + } + for profile in [ModelProfile::Smol360, ModelProfile::Qwen600] { + assert!(Input::decode_profile(&serde_json::to_vec(&value).unwrap(), profile).is_err()); + } + let mut no_policy = value.clone(); + no_policy.as_object_mut().unwrap().remove("generation_policy"); + assert!(decode_selected(&no_policy).is_err()); + let mut no_tools = value.clone(); + no_tools["tools"] = json!([]); + assert!(decode_selected(&no_tools).is_err()); + let mut client_aliases = value; + client_aliases["output_constraint_tools"] = json!(["vp_0"]); + assert!(decode_selected(&client_aliases).is_err()); +} + +#[test] +fn constraint_aliases_are_core_derived_bounded_and_contiguous() { + let mut value = selected(); + let tool = value["tools"][0].clone(); + value["tools"] = (0..32) + .map(|index| { + let mut tool = tool.clone(); + tool["name"] = format!("private_tool_{index}").into(); + tool + }) + .collect::>() + .into(); + let aliases = decode_selected(&value) + .unwrap() + .output_constraint_tools() + .unwrap(); + assert_eq!( + aliases, + (0..32).map(|index| format!("vp_{index}")).collect::>() + ); + assert!( + !serde_json::to_string(&aliases) + .unwrap() + .contains("private_tool") + ); + let mut extra = tool; + extra["name"] = "extra".into(); + value["tools"].as_array_mut().unwrap().push(extra); + assert!(decode_selected(&value).is_err()); +} + +#[test] +fn absent_constraint_preserves_legacy_bytes_and_report_shape() { + let raw = br#"{"version":1,"visibility":"private_local","instructions":"Reply.","history":[{"type":"message","role":"user","text":"Hello"}],"tools":[],"generation_policy":"greedy_v1"}"#; + let input = Input::decode_profile(raw, PROFILE).unwrap(); + assert!(input.output_constraint().is_none()); + assert!(input.output_constraint_tools().is_none()); + assert_eq!(input.bytes_profile(PROFILE).unwrap(), raw.to_vec()); + let mut result = report("Plain answer."); + result.as_object_mut().unwrap().remove("output_constraint"); + validate_report(&result, raw, PROFILE).unwrap(); + assert!(summary(&result, PROFILE).get("output_constraint").is_none()); + result["output_constraint"] = CONSTRAINT.into(); + assert!(validate_report(&result, raw, PROFILE).is_err()); +} + +#[test] +fn constrained_result_is_exactly_bound_and_never_forces_a_tool() { + let raw = serde_json::to_vec(&selected()).unwrap(); + for text in [ + "Plain answer, without calling a tool.", + "{\"name\":\"vp_0\",\"arguments\":{\"name\":\"demo.rs\"}}", + ] { + let result = report(text); + validate_report(&result, &raw, PROFILE).unwrap(); + assert_eq!(summary(&result, PROFILE)["output_constraint"], CONSTRAINT); + for invalid in [Value::Null, json!(true), json!("other")] { + let mut bad = result.clone(); + bad["output_constraint"] = invalid; + assert!(validate_report(&bad, &raw, PROFILE).is_err()); + } + let mut absent = result; + absent.as_object_mut().unwrap().remove("output_constraint"); + assert!(validate_report(&absent, &raw, PROFILE).is_err()); + } +} + +#[test] +fn constraint_claim_does_not_repair_or_authorize_invalid_original_output() { + let raw = serde_json::to_vec(&selected()).unwrap(); + for text in [ + "{\"name\":\"vp_0\",\"arguments\":{broken}}", + "{\"name\":\"vp_31\",\"arguments\":{}}", + "{\"name\":\"vp_0\",\"arguments\":{},\"PRIVATE_CANARY\":1}", + ] { + let result = report(text); + assert_eq!(result["conversation"]["reason"], "invalid_output"); + validate_report(&result, &raw, PROFILE).unwrap(); + assert_eq!(summary(&result, PROFILE)["turn_complete"], false); + let mut forged = result; + forged["conversation"] = json!({"type":"assistant","text":"repaired"}); + assert!(validate_report(&forged, &raw, PROFILE).is_err()); + } +} + +#[test] +fn duplicate_constraint_keys_are_rejected_before_binding() { + let raw = serde_json::to_string(&selected()).unwrap(); + let field = format!("\"output_constraint\":\"{CONSTRAINT}\""); + let duplicate = raw.replace(&field, &format!("{field},{field}")); + assert!(Input::decode_profile(duplicate.as_bytes(), PROFILE).is_err()); + for value in ["null", "\"other\"", "\"qwen_tagged_tool_json_v1\""] { + let raw = format!("{{{field},\"output_constraint\":{value}}}"); + assert!(decode_worker_message(raw.as_bytes()).is_err()); + } +} + +#[test] +fn constrained_argument_nesting_limit_is_exact_and_does_not_change_legacy() { + let mut nested = json!({}); + for depth in 1..=17 { + let text = format!("{{\"name\":\"vp_0\",\"arguments\":{nested}}}"); + let result = report(&text); + assert_eq!(result["conversation"]["type"] == "function_call", depth <= 16); + let mut legacy = selected(); + legacy.as_object_mut().unwrap().remove("output_constraint"); + let input = decode_selected(&legacy).unwrap(); + assert_eq!( + qwen::turn(&input, &result["outputs"][0], &"ab".repeat(16)).unwrap()["type"], + "function_call" + ); + nested = json!({"x":nested}); + } + let mut arrays = json!(0); + for _ in 0..15 { + arrays = json!([arrays]); + } + assert!(constrained_argument_depth(&json!({"x":arrays}), 1)); + assert!(!constrained_argument_depth(&json!({"x":[arrays]}), 1)); +} diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index 961bad5ac..33e71721e 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -250,6 +250,7 @@ struct Handshakes { conversation: bool, generation_policy: Option, execution_error: Option, + output_constraint: Option, } fn conversation_capabilities( @@ -279,6 +280,17 @@ fn conversation_capabilities( caps["inference_backend"] = super::native_cpu::KIND.into(); caps["required_generation_policy"] = "greedy_v1".into(); } + if handshakes.output_constraint == Some(1) { + caps["output_constraint_version"] = 1.into(); + caps["output_constraints"] = if config + .native_backend + .supports_output_constraint(config.model_profile) + { + json!([super::native_cpu::OutputConstraint::QwenTaggedToolJsonV1]) + } else { + json!([]) + }; + } caps } @@ -303,10 +315,12 @@ fn respond( wire::Operation::ConversationCapabilities { generation_policy_version, execution_error_version, + output_constraint_version, } => { handshakes.conversation = true; handshakes.generation_policy = generation_policy_version; handshakes.execution_error = execution_error_version; + handshakes.output_constraint = output_constraint_version; let mut response = wire::response(&request.id, "conversation_capabilities"); response["capabilities"] = conversation_capabilities(config, gate, handshakes); response @@ -315,12 +329,20 @@ fn respond( if !handshakes.conversation || (conversation.requires_generation_policy_handshake() && handshakes.generation_policy != Some(1)) + || (conversation.output_constraint().is_some() + && handshakes.output_constraint != Some(1)) { wire::error(Some(&request.id), "handshake_required") } else if gate.is_closed() { wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { wire::error(Some(&request.id), "busy") + } else if conversation.output_constraint().is_some() + && !config + .native_backend + .supports_output_constraint(config.model_profile) + { + wire::error(Some(&request.id), "invalid_request") } else if config.native_backend.enabled() && !conversation.requires_generation_policy_handshake() { diff --git a/crates/volparossa/src/compute/private_serve/tests.rs b/crates/volparossa/src/compute/private_serve/tests.rs index 271024005..bdc9f14aa 100644 --- a/crates/volparossa/src/compute/private_serve/tests.rs +++ b/crates/volparossa/src/compute/private_serve/tests.rs @@ -4,6 +4,8 @@ use super::*; use tokio::io::AsyncWriteExt as _; +mod output_constraint; + const FIRST: &str = "01010101010101010101010101010101"; const SECOND: &str = "02020202020202020202020202020202"; const THIRD: &str = "03030303030303030303030303030303"; diff --git a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs new file mode 100644 index 000000000..68dce6d79 --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs @@ -0,0 +1,160 @@ +//! Inert owner-manifest and IPC negotiation only; no worker or model is started. +use super::*; + +use sha2::{Digest as _, Sha256}; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; + +fn native_config(root: &std::path::Path, extended: bool) -> private_task::ExecutionConfig { + let mut config = config(root); + config.model_profile = ModelProfile::Qwen4bInstruct2507; + let backend = root.join("backend"); + fs::create_dir(&backend).unwrap(); + fs::set_permissions(&backend, fs::Permissions::from_mode(0o700)).unwrap(); + for name in ["libvolparossa_llama_cpu.so", "model.gguf"] { + let path = backend.join(name); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let mut manifest = json!({"version":1,"kind":"llama_cpp_bf16_v1","abi_version":1, + "source_commit":"7fe450e19305b828c199d602c23a8337aaa1f03b", + "model_profile":"qwen3-4b-instruct-2507-v1", + "source_weights_sha256":"79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf", + "source_weights_bytes":8_044_982_000_u64, + "library":{"path":"libvolparossa_llama_cpu.so","sha256":"a".repeat(64),"bytes":1}, + "gguf":{"path":"model.gguf","sha256":"b".repeat(64),"bytes":1}, + "verification":{"tensor_count":398,"tensor_values_equal":true,"tokenizer_equal":true, + "chat_template_sha256":"c".repeat(64)},"build_manifest_sha256":"d".repeat(64)}); + if extended { + manifest["version"] = 2.into(); + manifest["output_constraints"] = json!([CONSTRAINT]); + } + let raw = serde_json::to_vec(&manifest).unwrap(); + fs::write(backend.join("backend.json"), &raw).unwrap(); + config.native_backend.native_backend_root = Some(backend); + config.native_backend.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + config +} + +fn conversation() -> Value { + json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Read."}], + "tools":[{"type":"custom","name":"tool","description":"Read."}], + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}) +} + +#[test] +fn constraint_negotiation_rejects_invalid_null_and_duplicate_versions() { + for version in [ + json!(1), + json!(0), + json!(2), + Value::Null, + json!(true), + json!("1"), + ] { + let request = request( + FIRST, + json!({"type":"conversation_capabilities","output_constraint_version":version}), + ); + let valid = serde_json::from_value::(request).is_ok_and(|request| { + request + .validate_profile(ModelProfile::Qwen4bInstruct2507) + .is_ok() + }); + assert_eq!(valid, version == json!(1)); + } + let raw = format!( + r#"{{"version":1,"id":"{FIRST}","operation":{{"type":"conversation_capabilities","output_constraint_version":1,"output_constraint_version":1}}}}"# + ); + assert!(serde_json::from_str::(&raw).is_err()); +} + +#[test] +fn capability_is_opt_in_and_requires_verified_owner_manifest_support() { + for supported in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = native_config(root.path(), supported); + let gate = Semaphore::new(1); + let mut handshakes = Handshakes::default(); + let original = conversation_capabilities(&config, &gate, &handshakes); + assert!(original.get("output_constraint_version").is_none()); + assert!(original.get("output_constraints").is_none()); + handshakes.output_constraint = Some(1); + let mut caps = conversation_capabilities(&config, &gate, &handshakes); + assert_eq!( + caps.as_object_mut() + .unwrap() + .remove("output_constraint_version"), + Some(json!(1)) + ); + assert_eq!( + caps.as_object_mut().unwrap().remove("output_constraints"), + Some(if supported { + json!([CONSTRAINT]) + } else { + json!([]) + }) + ); + assert_eq!(caps, original); + let mut changed = config; + changed.native_backend.native_backend_sha256 = Some("0".repeat(64)); + assert_eq!( + conversation_capabilities(&changed, &gate, &handshakes)["output_constraints"], + json!([]) + ); + changed.native_backend = super::super::super::native_cpu::Options::default(); + assert_eq!( + conversation_capabilities(&changed, &gate, &handshakes)["output_constraints"], + json!([]) + ); + } +} + +#[test] +fn constrained_admission_requires_current_connection_handshake_and_manifest() { + for supported in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path(), supported)); + let gate = Arc::new(Semaphore::new(1)); + // Occupy the only slot: a correctly admitted shape returns busy, never starts a worker. + let _occupied = gate.try_acquire().unwrap(); + let mut active = None; + let mut handshakes = Handshakes::default(); + for selected in [false, true, false] { + let mut operation = + json!({"type":"conversation_capabilities","generation_policy_version":1}); + if selected { + operation["output_constraint_version"] = 1.into(); + } + let query = serde_json::from_value(request(FIRST, operation)).unwrap(); + respond(query, &config, &gate, &mut active, &mut handshakes); + assert_eq!(handshakes.output_constraint, selected.then_some(1)); + let submit = serde_json::from_value(request( + SECOND, + json!({"type":"submit_conversation","conversation":conversation()}), + )) + .unwrap(); + let result = respond(submit, &config, &gate, &mut active, &mut handshakes); + let expected = if !selected { + "handshake_required" + } else if !supported { + "invalid_request" + } else { + "busy" + }; + assert_eq!(result, wire::error(Some(SECOND), expected)); + assert!(active.is_none()); + } + let mut other_connection = Handshakes::default(); + let submit = serde_json::from_value(request( + THIRD, + json!({"type":"submit_conversation","conversation":conversation()}), + )) + .unwrap(); + assert_eq!( + respond(submit, &config, &gate, &mut active, &mut other_connection), + wire::error(Some(THIRD), "handshake_required") + ); + } +} diff --git a/crates/volparossa/src/compute/private_serve/wire.rs b/crates/volparossa/src/compute/private_serve/wire.rs index 618a5a682..31b1149d0 100644 --- a/crates/volparossa/src/compute/private_serve/wire.rs +++ b/crates/volparossa/src/compute/private_serve/wire.rs @@ -31,6 +31,8 @@ pub(super) enum Operation { generation_policy_version: Option, #[serde(default, deserialize_with = "present_version")] execution_error_version: Option, + #[serde(default, deserialize_with = "present_version")] + output_constraint_version: Option, }, SubmitConversation { conversation: super::super::private_conversation::Input, @@ -75,6 +77,7 @@ impl Request { if let Operation::ConversationCapabilities { generation_policy_version, execution_error_version, + output_constraint_version, } = &self.operation { ensure!( @@ -85,6 +88,10 @@ impl Request { execution_error_version.is_none_or(|version| version == 1), "private_ipc_execution_error_version" ); + ensure!( + output_constraint_version.is_none_or(|version| version == 1), + "private_ipc_output_constraint_version" + ); } if let Operation::Cancel { task_id } = &self.operation { ensure!(valid_id(task_id), "private_ipc_invalid_request"); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b004af78f..296a354c7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -11,12 +11,40 @@ detailed evidence ledger, including historical failures—not an installation gu [Original v1 scorecard](#fixed-alpha-v1-scorecard) · [Completion requirements](#definition-of-done) -Last updated: 2026-10-07 +Last updated: 2026-10-15 ## Current integration and active work ### Explicit native CPU inference candidate +#### Tool-call output constraint candidate + +- [ ] Continue the existing native OpenCode integration after actual Code run + `37683661957` on Code `45510055` and core `9e9086ee` produced one invalid JSON + tool-call body. That run proves no usable turn, tool action or coding result; + it does not identify a unique cause inside generation. + +The new optional `qwen_tagged_tool_json_v1` profile binds IPC negotiation, +owner-authorized backend manifest v2/build v3, the actual serialized dataset, +core-derived offered-tool aliases, worker request and returned result. The +native ABI preserves the old unselected greedy path. Selected requests allow +ordinary answers, or one tagged JSON tool call with arguments nesting at most +16. The strict final parser remains authoritative: no output repair, forced +tool use, prompt truncation, extra time or alternate model is introduced. + +171 targeted inert Python checks pass, including a serialized selected dataset +through the actual worker/encode/decode path with a synthetic native executor. +That test caught and corrected a cross-layer input-field rejection. Thirteen +new Rust cases cover manifest, request/result, parser and IPC bindings; execution +is pending on GitHub. Source-only C++ checks exercise the pinned grammar with +synthetic pieces; their native compilation and sanitizer execution are pending. +The new hosted workflow retains failures and runs the constraint checks before +the unchanged all-type tensor test with its known sanitizer finding. No local +Rust, native build or inference ran. These contracts are not proof of an actual +OpenCode coding task or protected peer execution. + +#### Earlier native candidate evidence + - [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the existing isolated private-conversation worker. The source-only build of pinned llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. Actual conversion diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py index 67ee5f93c..7cf67db6f 100644 --- a/workers/volparossa-ml/build_llama_cpu.py +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -26,8 +26,10 @@ HERE = Path(__file__).resolve().parent LIBRARY = "libvolparossa_llama_cpu.so" ALLOWED_NEEDED = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "ld-linux-x86-64.so.2"} -CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", "vp-upstream-tensor-tests") -WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch") +CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", + "vp-native-output-constraint", "vp-upstream-tensor-tests") +WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch", + "output-constraint.h", "output-constraint.cpp") def require(condition, code): @@ -166,6 +168,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(not sanitize or checks, "NATIVE_SANITIZER_REQUIRES_CHECKS") patched_loader(source) plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, + "output_constraints": [native.OUTPUT_CONSTRAINT], "sanitizers": sanitize, "provisionable": not sanitize, "source_overlay": native.loader_provenance(), "jobs": 2, "models_loaded": False, "downloads": False, "install": False} @@ -221,7 +224,11 @@ def build(source, output, execute=False, checks=False, sanitize=False): for target in CHECK_TARGETS: matches = list((output / "build").rglob(target)) require(len(matches) == 1 and matches[0].is_file(), "NATIVE_CHECK_TARGET") + log.write(("NATIVE_CHECK_BEGIN " + target + "\n").encode("ascii")) + log.flush() run_build([str(matches[0])], log, check_env, min(120, 1800 - (time.monotonic() - started))) + log.write(("NATIVE_CHECK_PASS " + target + "\n").encode("ascii")) + log.flush() licenses = output / "licenses" licenses.mkdir(mode=0o700) # Preserve all tracked notices, not only the top-level MIT declaration. @@ -245,7 +252,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(runtime_notice.is_file(), "NATIVE_TOOLCHAIN_NOTICE") shutil.copyfile(runtime_notice, licenses / "GCC-runtime-copyright") (licenses / "GCC-runtime-copyright").chmod(0o444) - report = dict(plan, version=2, abi_version=1, library={"path": LIBRARY, **digest(library)}, + report = dict(plan, version=3, abi_version=1, library={"path": LIBRARY, **digest(library)}, upstream_original_unchanged=True, loader_compile_source_verified=True, dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, checks=list(CHECK_TARGETS) if checks else [], diff --git a/workers/volparossa-ml/conversation.py b/workers/volparossa-ml/conversation.py index 75cfbd426..f6b63ad38 100644 --- a/workers/volparossa-ml/conversation.py +++ b/workers/volparossa-ml/conversation.py @@ -16,6 +16,7 @@ QWEN = "qwen3-0.6b-v1" QWEN4B = "qwen3-4b-instruct-2507-v1" NATIVE_PROFILES = (QWEN, QWEN4B) +OUTPUT_CONSTRAINT = "qwen_tagged_tool_json_v1" def bounds(profile_name=None): @@ -69,6 +70,17 @@ def generation_policy(value, profile_name=None): return policy +def output_constraint(value, profile_name=None): + if "output_constraint" not in value: + return None + constraint = value["output_constraint"] + require(profile_name == QWEN4B and type(constraint) is str and constraint == OUTPUT_CONSTRAINT + and generation_policy(value, profile_name) == "greedy_v1" + and type(value.get("tools")) is list and 1 <= len(value["tools"]) <= 32, + "CONVERSATION_OUTPUT_CONSTRAINT") + return constraint + + def validate_call(value, tools, seen): custom = value["type"] == "custom_tool_call" fields(value, ("type", "call_id", "name", "input" if custom else "arguments"), ("namespace",)) @@ -81,8 +93,10 @@ def validate_call(value, tools, seen): def validate(value, profile_name=None): maximum, instructions, history, tools, message, description = bounds(profile_name) - fields(value, ("version", "visibility", "instructions", "history", "tools"), ("generation_policy",)) + fields(value, ("version", "visibility", "instructions", "history", "tools"), + ("generation_policy", "output_constraint")) generation_policy(value, profile_name) + output_constraint(value, profile_name) require(type(value["version"]) is int and value["version"] == 1 and value["visibility"] == "private_local", "CONVERSATION_SCOPE") require(text(value["instructions"], instructions) and type(value["history"]) is list @@ -194,9 +208,27 @@ def invalid_constant(_value): raise ValueError("CONVERSATION_JSON_CONSTANT") +def constrained_argument_depth(value): + # Count argument containers only: the arguments object is depth one; + # the tagged tool envelope and scalar/string contents do not add depth. + pending = [(value, 1)] + while pending: + item, depth = pending.pop() + if type(item) in (dict, list): + if depth > 16: + return False + pending.extend((child, depth + 1) for child in + (item.values() if type(item) is dict else item)) + return True + + def decode(value, output, profile_name=None, request_id=None): if profile_name in NATIVE_PROFILES: - return native().decode(value, output, request_id) + result = native().decode(value, output, request_id) + if "output_constraint" in value and result["type"] == "function_call" \ + and not constrained_argument_depth(result["arguments"]): + return {"type": "incomplete", "reason": "invalid_output"} + return result if output["text_truncated"]: return {"type": "incomplete", "reason": "wire_truncated"} if output["generation"]["stop_reason"] != "eos": diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 0a28979e5..537ca4d9e 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -368,6 +368,8 @@ def check(): "library": {"path": native.LIBRARY, **builder.digest(root / native.LIBRARY)}, "gguf": {"path": "model.gguf", **builder.digest(output)}, "verification": verified, "build_manifest_sha256": hashlib.sha256(build_raw).hexdigest()} + if build["version"] == 3: + manifest.update(version=2, output_constraints=list(build["output_constraints"])) native.validate_manifest(manifest, builder.require) retained = sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) builder.require(retained + 65536 <= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index ce740cd14..1802335f8 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -27,6 +27,19 @@ WEIGHTS_BYTES = 8044982000 HEX = re.compile(r"[0-9a-f]{64}\Z") ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) +OUTPUT_CONSTRAINT = "qwen_tagged_tool_json_v1" + + +def validate_constraints(value, legacy_version, require, version_error="NATIVE_OUTPUT_CONSTRAINT_VERSION"): + """Versioned capability, not a request to enable it or executable grammar.""" + version = value.get("version") if type(value) is dict else None + require(type(version) is int and version in (legacy_version, legacy_version + 1), + version_error) + if version == legacy_version: + require("output_constraints" not in value, "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return [] + require(value.get("output_constraints") == [OUTPUT_CONSTRAINT], "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return [OUTPUT_CONSTRAINT] def verification_scope(): @@ -43,10 +56,11 @@ def loader_provenance(): def validate_build_provenance(value, require): - # Build manifest v2 distinguishes the effective loader from clean upstream. - # ABI v1 is retained; old unpatched build manifests are not silently adopted. + # Build v3 adds an explicit, optional generation extension to the same ABI. + # Historical patched build v2 retains its exact absent-capability shape. overlay = value.get("source_overlay") if type(value) is dict else None - require(type(value) is dict and type(value.get("version")) is int and value["version"] == 2 + validate_constraints(value, 2, require, "NATIVE_BUILD_PATCH_BINDING") + require(type(value) is dict and value.get("source_commit") == SOURCE and value.get("source_tree") == SOURCE_TREE and type(overlay) is dict and overlay == loader_provenance() and type(overlay["validation_execution_threads"]) is int @@ -56,11 +70,12 @@ def validate_build_provenance(value, require): def validate_manifest(value, require): - require(type(value) is dict and value.keys() == {"version", "kind", "abi_version", "source_commit", + constraints = validate_constraints(value, 1, require) + fields = {"version", "kind", "abi_version", "source_commit", "model_profile", "source_weights_sha256", "source_weights_bytes", "library", "gguf", - "verification", "build_manifest_sha256"}, "NATIVE_MANIFEST_FIELDS") - require(type(value["version"]) is int and value["version"] == 1 - and type(value["abi_version"]) is int and value["abi_version"] == 1 + "verification", "build_manifest_sha256"} + require(value.keys() == fields | ({"output_constraints"} if constraints else set()), "NATIVE_MANIFEST_FIELDS") + require(type(value["abi_version"]) is int and value["abi_version"] == 1 and value["kind"] == KIND and value["source_commit"] == SOURCE and value["model_profile"] == PROFILE and value["source_weights_sha256"] == WEIGHTS_SHA and type(value["source_weights_bytes"]) is int and value["source_weights_bytes"] == WEIGHTS_BYTES, @@ -102,6 +117,13 @@ def verified_bundle(request, worker, tokenizer, session): require(hashlib.sha256(build).hexdigest() == value["build_manifest_sha256"], "NATIVE_BUILD_DIGEST") provenance = worker.parse_json(build) validate_build_provenance(provenance, require) + require(provenance["version"] == value["version"] + 1 + and provenance.get("output_constraints") == value.get("output_constraints"), + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if "output_constraint" in request: + require(request["output_constraint"] == OUTPUT_CONSTRAINT + and value.get("output_constraints") == [OUTPUT_CONSTRAINT], + "NATIVE_OUTPUT_CONSTRAINT_UNSUPPORTED") require(type(provenance) is dict and provenance.get("kind") == KIND and provenance.get("source_commit") == SOURCE and provenance.get("abi_version") == 1 and provenance.get("cpu") == "avx2_fma_f16c" @@ -174,7 +196,8 @@ def joined(self): class NativeModel: - def __init__(self, root, capacity, threads, probe, require): + def __init__(self, root, capacity, threads, probe, require, *, output_constraints=None, + output_constraint=None, output_constraint_tools=None): self.handle = ctypes.c_void_p() self.require, self.probe = require, probe # The caller checked owner-authorized digest before this executable load. @@ -198,6 +221,23 @@ def __init__(self, root, capacity, threads, probe, require): function.restype, function.argtypes = result, arguments require(self.lib.vp_llama_abi_v1() == 1 and self.lib.vp_llama_source_v1() == SOURCE.encode(), "NATIVE_ABI_MISMATCH") + if output_constraints is not None: + require(output_constraints == [OUTPUT_CONSTRAINT], "NATIVE_OUTPUT_CONSTRAINT_BINDING") + for name, result, arguments in ( + ("vp_llama_output_constraint_abi_v1", ctypes.c_uint32, []), + ("vp_llama_set_output_constraint_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32])): + require(hasattr(self.lib, name), "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_output_constraint_abi_v1() + require(type(extension) is int and extension == 1, + "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH") + if output_constraint is not None or output_constraint_tools is not None: + require(output_constraints == [OUTPUT_CONSTRAINT] and output_constraint == OUTPUT_CONSTRAINT + and type(output_constraint_tools) is list and 1 <= len(output_constraint_tools) <= 32 + and output_constraint_tools == [f"vp_{index}" for index in range(len(output_constraint_tools))], + "NATIVE_OUTPUT_CONSTRAINT_BINDING") try: status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, probe.callback, None, ctypes.byref(self.handle)) @@ -210,6 +250,10 @@ def __init__(self, root, capacity, threads, probe, require): require(type(stage) is int and stage in codes, "NATIVE_OPEN_DIAGNOSTIC_INVALID") require(status == 0 and self.handle.value is not None, codes[stage]) require(stage == 8, "NATIVE_OPEN_DIAGNOSTIC_INVALID") + if output_constraint is not None: + status = self.lib.vp_llama_set_output_constraint_v1(self.handle, 1, len(output_constraint_tools)) + probe.joined() + require(status == 0, "NATIVE_OUTPUT_CONSTRAINT_CONFIGURATION_FAILED") except BaseException: self.close() raise @@ -274,6 +318,13 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo dataset, data_identity, model_files, worker, *, initial_source_weights): worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", "NATIVE_GREEDY_POLICY_REQUIRED") + constraint = worker.conversation_constraint_binding(request, dataset, PROFILE) + constraint_options = {} + if constraint is not None: + # The core supplies aliases, never arbitrary grammar. Validate against the + # actual ordered input, not a separate count controlled by a peer. + aliases = [f"vp_{index}" for index in range(len(dataset["tools"]))] + constraint_options.update(output_constraint=constraint, output_constraint_tools=aliases) profile = worker.model_profile(PROFILE) worker.require(type(initial_source_weights) is dict and initial_source_weights == profile["weights"] and all(model_files.get(name) == {"bytes": profile["files"][name], @@ -284,13 +335,16 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo # not claimed to have been checked again at the end of this execution. weight_identity = dict(initial_source_weights, files=list(initial_source_weights["files"])) root, manifest, identity = verified_bundle(request, worker, tokenizer, session) + if "output_constraints" in manifest: + constraint_options["output_constraints"] = manifest["output_constraints"] session.private_progress("prompt_encode", "begin") prompt = worker.conversation_module().encode(tokenizer, dataset, profile) session.private_progress("prompt_encode", "complete") session.check() probe = OwnerProbe(session, worker) session.private_progress("model_load", "begin") - model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require) + model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require, + **constraint_options) try: session.private_progress("model_load", "complete") session.progress("baseline") @@ -316,6 +370,8 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo "prompt_tokens": len(prompt), "conversation_limits": worker.conversation_module().capabilities(PROFILE, profile), "generation_policy": "greedy_v1", "model_parameter_dtype": "bf16_with_exact_f32_norms", "model_attention_backend": "llama_cpp_cpu", "inference_backend": identity} + if "output_constraint" in request: + result["output_constraint"] = request["output_constraint"] session.private_progress("result", "begin") result = worker.finish_result(result, output_root, session) session.private_progress("result", "complete") diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt index b5009e74c..1590315a0 100644 --- a/workers/volparossa-ml/native-cpu/CMakeLists.txt +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -59,7 +59,8 @@ endif() set_property(TARGET llama PROPERTY SOURCES "${effective_sources}") set_source_files_properties("${VP_LLAMA_MODEL_LOADER}" TARGET_DIRECTORY llama PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) -add_library(volparossa_llama_cpu SHARED adapter.cpp) +add_library(volparossa_llama_cpu SHARED adapter.cpp output-constraint.cpp) +target_include_directories(volparossa_llama_cpu PRIVATE "${VP_LLAMA_SOURCE}/src") target_link_libraries(volparossa_llama_cpu PRIVATE llama) target_compile_options(volparossa_llama_cpu PRIVATE -Wall -Wextra -Werror) target_link_options(volparossa_llama_cpu PRIVATE -static-libstdc++ -static-libgcc @@ -69,6 +70,11 @@ if(VP_NATIVE_TESTS) add_executable(vp-native-abi-smoke abi-smoke.cpp) target_compile_options(vp-native-abi-smoke PRIVATE -UNDEBUG) target_link_libraries(vp-native-abi-smoke PRIVATE volparossa_llama_cpu) + # The exact pinned grammar engine with synthetic token pieces; no model or inference. + add_executable(vp-native-output-constraint output-constraint-tests.cpp output-constraint.cpp) + target_include_directories(vp-native-output-constraint PRIVATE "${VP_LLAMA_SOURCE}/src") + target_compile_options(vp-native-output-constraint PRIVATE -UNDEBUG -Wall -Wextra -Werror) + target_link_libraries(vp-native-output-constraint PRIVATE llama) # Launch/join statements are extracted from the exact staged loader. This # exercises real ggml validation on tiny F32 arrays, never loads a model. add_executable(vp-native-loader-validation loader-validation.cpp) diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md index d5ccf09bb..a58881291 100644 --- a/workers/volparossa-ml/native-cpu/README.md +++ b/workers/volparossa-ml/native-cpu/README.md @@ -26,6 +26,32 @@ handles, never guessed upstream struct layouts. llama/ggml and the C++ runtime are statically linked into the owned library; only the checked platform C/math loader libraries remain dynamic. Arbitrary dynamic search paths are rejected. +## Optional tool-call output constraint + +The new `qwen_tagged_tool_json_v1` profile is explicit and additive. Backend +manifest v2 and build manifest v3 bind the implemented capability; historical +backend v1/build v2 remain valid without it. Conversation IPC negotiates version +1 separately, and the core derives `vp_0` through `vp_N-1` from the actual offered +tools. Callers cannot supply grammar or executable code. Requests without tools +omit the selector, and requests without the selector retain the old greedy path. + +For selected Qwen4B/greedy requests, ordinary answers remain possible. If the +model begins a tagged tool call, native candidate filtering limits it to one +offered alias and an object-valued JSON argument. Container depth is at most 16, +counting the arguments object as depth 1, not the fixed call envelope. Token +lookahead checks split markers, their trailing bytes and UTF-8 before accepting +a candidate. No generated response is repaired or replaced after generation. +The existing strict final parser, tool authorization, prompt/output limits, +deadlines and cleanup still apply. This is not arbitrary JSON-schema validation +or proof that a proposed tool action is useful or authorized. + +The source-only hosted workflow builds normal and ASan/UBSan variants from the +exact upstream pin, without models. New checks run before the unchanged all-type +tensor check so its known sanitizer failure cannot hide their result. That +failure is neither suppressed nor converted into a passing full-suite result. +Native compilation, real-model behavior and a successful OpenCode task remain +separate acceptance requirements for the new candidate. + ## Source build and conversion `../build_llama_cpu.py` consumes a clean local checkout of the exact commit. It diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp index 69d698b08..070ff6c29 100644 --- a/workers/volparossa-ml/native-cpu/abi-smoke.cpp +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -12,6 +12,8 @@ static int32_t abort_immediately(void * opaque) { int main() { assert(vp_llama_abi_v1() == 1); + assert(vp_llama_output_constraint_abi_v1() == 1); + assert(vp_llama_set_output_constraint_v1(nullptr, 1, 1) == VP_INVALID); assert(vp_llama_open_stage_v1() == VP_OPEN_NONE); assert(std::strcmp(vp_llama_source_v1(), "7fe450e19305b828c199d602c23a8337aaa1f03b") == 0); void * handle = nullptr; diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp index 2d0ec7d0f..56fdffe1f 100644 --- a/workers/volparossa-ml/native-cpu/adapter.cpp +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -1,10 +1,15 @@ // SPDX-License-Identifier: GPL-3.0-only #include "adapter.h" #include "llama.h" +#include "output-constraint.h" +#include +#include +#include #include #include #include #include +#include namespace { constexpr uint32_t max_context = 12288 + 1024; @@ -21,8 +26,11 @@ struct state { uint32_t used = 0; bool ready = false; bool failed = false; + std::unique_ptr constraint; + uint32_t generated = 0; ~state() { if (context) { llama_synchronize(context); } + constraint.reset(); if (sampler) { llama_sampler_free(sampler); } if (context) { llama_free(context); } if (model) { llama_model_free(model); } @@ -33,6 +41,51 @@ bool aborted(void * pointer) { return value->abort(value->opaque) != 0; } bool load_progress(float, void * pointer) { return !aborted(pointer); } +bool token_piece(const llama_vocab * vocab, llama_token token, std::string & piece) { + std::array bytes{}; + const auto size = llama_token_to_piece(vocab, token, bytes.data(), bytes.size(), 0, true); + if (size < 1 || size > static_cast(bytes.size())) { return false; } + piece.assign(bytes.data(), static_cast(size)); + return true; +} + +int32_t constrained_sample(state * value, int32_t * output) { + if (value->generated >= 1024 || (!value->generated && value->used > 12288)) { + value->failed = true; return VP_INVALID; + } + const auto * logits = llama_get_logits_ith(value->context, -1); + if (!logits) { value->failed = true; return VP_BACKEND; } + const auto * vocab = llama_model_get_vocab(value->model); + std::vector candidates; + candidates.reserve(vocabulary); + for (int32_t id = 0; id < vocabulary; ++id) { + if ((id % 4096) == 0 && aborted(value)) { value->failed = true; return VP_ABORTED; } + if (std::isfinite(logits[id]) && (id == 151645 || !llama_vocab_is_control(vocab, id))) { + candidates.push_back({id, logits[id], 0.0f}); + } + } + if (candidates.empty()) { value->failed = true; return VP_BACKEND; } + llama_token_data_array array{candidates.data(), candidates.size(), -1, false}; + value->constraint->filter(array); + // Linear heap construction, then highest finite permitted logit. Ascending + // token ID breaks ties exactly as the unchanged upstream greedy sampler. + std::string piece; + llama_token selected = -1; + const auto result = vp::select_candidate(candidates, [&](llama_token id) { + piece.clear(); + const bool eos = id == 151645; + return (eos || token_piece(vocab, id, piece)) && value->constraint->permits(piece, eos); + }, [&] { return aborted(value); }, selected); + if (result == vp::selection::chosen && !aborted(value)) { + value->constraint->accept(piece, selected == 151645); + *output = selected; + ++value->generated; + value->ready = false; + return VP_OK; + } + value->failed = true; + return result == vp::selection::cancelled || aborted(value) ? VP_ABORTED : VP_BACKEND; +} void discard_log(ggml_log_level, const char *, void *) {} std::once_flag initialized; thread_local uint32_t open_stage = VP_OPEN_NONE; @@ -41,6 +94,25 @@ thread_local uint32_t open_stage = VP_OPEN_NONE; extern "C" uint32_t vp_llama_abi_v1(void) { return 1; } extern "C" const char * vp_llama_source_v1(void) { return source; } extern "C" uint32_t vp_llama_open_stage_v1(void) { return open_stage; } +extern "C" uint32_t vp_llama_output_constraint_abi_v1(void) { return 1; } + +extern "C" int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t count) { + auto * value = static_cast(handle); + if (!value || value->failed || value->used || value->constraint || mode != 1 || count < 1 || count > 32) { + return VP_INVALID; + } + try { + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + const auto * vocab = llama_model_get_vocab(value->model); + std::string open, close; + if (llama_vocab_is_control(vocab, 151657) || llama_vocab_is_control(vocab, 151658) || + !token_piece(vocab, 151657, open) || open != "" || + !token_piece(vocab, 151658, close) || close != "") { return VP_INVALID; } + value->constraint = std::make_unique(vocab, count); + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, uint32_t threads, vp_abort_v1 abort, @@ -141,6 +213,7 @@ extern "C" int32_t vp_llama_sample_v1(void * handle, int32_t * token) { if (!value || !token || value->failed || !value->ready) { return VP_INVALID; } try { if (aborted(value)) { value->failed = true; return VP_ABORTED; } + if (value->constraint) { return constrained_sample(value, token); } const auto generated = llama_sampler_sample(value->sampler, value->context, -1); if (generated < 0 || generated >= vocabulary) { value->failed = true; return VP_BACKEND; } *token = generated; diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h index 38b7de6e0..93455d205 100644 --- a/workers/volparossa-ml/native-cpu/adapter.h +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -20,6 +20,12 @@ enum vp_open_stage_v1 { uint32_t vp_llama_abi_v1(void); const char * vp_llama_source_v1(void); uint32_t vp_llama_open_stage_v1(void); +// Optional, additive ABI. Mode 1 is qwen_tagged_tool_json_v1, with aliases +// vp_0..vp_(tool_count-1), count 1..32 and JSON nesting <=16 (arguments object +// is depth 1; fixed envelope excluded). Configure once before any decode. +// Absence preserves the original greedy_v1 path. +uint32_t vp_llama_output_constraint_abi_v1(void); +int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t tool_count); int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, uint32_t threads, vp_abort_v1 abort, void * opaque, void ** handle); diff --git a/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp new file mode 100644 index 000000000..81557da5a --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Real pinned upstream GBNF engine, synthetic pieces/logits only. No model, +// native inference, tool execution or protected-network claim. +#include "output-constraint.h" +#include +#include +#include + +namespace { +const std::string call = "{\"name\":\"vp_0\",\"arguments\":{\"input\":\"ok\"}}"; + +void feed(vp::tool_constraint & value, const std::string & text) { + assert(value.permits(text, false)); + value.accept(text, false); +} + +void ordinary_and_optional() { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits("", true)); + feed(value, "A normal answer, not a tool."); + assert(value.permits("", true)); + value.accept("", true); + assert(!value.permits("more", false)); + for (unsigned count : {0u, 33u}) { + bool rejected = false; + try { vp::tool_constraint invalid(nullptr, count); } + catch (const std::invalid_argument &) { rejected = true; } + assert(rejected); + } +} + +void atomic_split_and_tail() { + for (std::size_t split = 0; split <= call.size(); ++split) { + vp::tool_constraint value(nullptr, 1); + if (split) { feed(value, call.substr(0, split)); } + if (split < call.size()) { feed(value, call.substr(split)); } + assert(value.permits("", true)); + assert(!value.permits(call, false)); // No second call after the first. + assert(!value.permits(" trailing prose", false)); + } + vp::tool_constraint value(nullptr, 1); + feed(value, "I will inspect it.\n{bad}", false)); + assert(!value.permits("call>{\"name\":\"vp_1\"", false)); + feed(value, "call>"); + assert(!value.permits("", true)); + assert(!value.permits("{\"arguments\":{}}", false)); + feed(value, "{\"name\":\"vp_0\",\"arguments\":{}}"); + assert(!value.permits("", true)); + feed(value, ""); + assert(value.permits("", true)); + // Invalid lookahead did not mutate the actual chosen grammar state. + value.accept("", true); +} + +void preface_and_aliases() { + for (const auto & prefix : {"Same line ", "```\n", "~~~\n", "\n", "\n"}) { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits(prefix + call, false)); + assert(!value.permits(std::string(prefix) + "{\"name\":\"vp_31\",\"arguments\":{}}"); + assert(value.permits("", true)); + for (const auto & name : {"vp_32", "other", "vp_00"}) { + vp::tool_constraint invalid(nullptr, 32); + assert(!invalid.permits(std::string("{\"name\":\"") + name + "\",", false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, ""; + for (const auto & text : {std::string("caf\xc3\xa9"), std::string("\xf0\x9f\xa6\x8a"), + std::string("\\\"\\\\\\n\\u00e9\\uD83E\\uDD8A")}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix); + for (char byte : text) { feed(value, std::string(1, byte)); } + feed(value, suffix); + assert(value.permits("", true)); + } + for (const auto & text : {std::string("\xc0\x80"), std::string("\xed\xa0\x80"), + std::string("\xf4\x90\x80\x80"), std::string("\\x00"), std::string("\\uD800x"), + std::string("\0", 1)}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix); + assert(!value.permits(text + suffix, false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, "plain \xc3"); + assert(!partial.permits("", true)); + assert(!partial.permits("bad", false)); + feed(partial, "\xa9"); + assert(partial.permits("", true)); + vp::tool_constraint wrong_position(nullptr, 1); + assert(!wrong_position.permits("\xc3", false)); +} + +void depth_and_wire_bounds() { + for (unsigned depth : {16u, 17u}) { + vp::tool_constraint value(nullptr, 1); + std::string body = "{}"; + for (unsigned level = 1; level < depth; ++level) { body = "{\"a\":" + body + "}"; } + const std::string text = "{\"name\":\"vp_0\",\"arguments\":" + body + "}"; + assert(value.permits(text, false) == (depth == 16)); + } + vp::tool_constraint exact(nullptr, 1); + feed(exact, std::string(4094, 'a')); + assert(exact.permits("", true)); + assert(!exact.permits("a", false)); + vp::tool_constraint escapes(nullptr, 1); + feed(escapes, std::string(2047, '\n')); + assert(!escapes.permits("\n", false)); + vp::tool_constraint long_piece(nullptr, 1); + assert(!long_piece.permits(std::string(4097, 'a'), false)); +} + +void json_values_and_incomplete_suffix() { + const std::string prefix = "{\"name\":\"vp_0\",\"arguments\":"; + const std::string suffix = "}"; + for (const auto & body : {"{}", "{\"v\":[null,true,false,-1,0,1.25e-3,{\"x\":[]}]}", + "{\"v\":\"a marker inside a JSON string\"}"}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix + body + suffix); + assert(value.permits("", true)); + } + for (const auto & body : {"[]", "null", "{\"v\":01}", "{\"v\":1.}", "{\"v\":NaN}", + "{\"v\":+1}", "{\"v\":true,}", "{\"v\":[1,]}"}) { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits(prefix + body + suffix, false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, prefix + "{}}x", false)); + feed(partial, "call>"); + assert(partial.permits("", true)); + assert(!partial.permits("\xc3", false)); // No incomplete UTF-8 after complete grammar. +} + +void finite_selection_and_cancel() { + vp::tool_constraint optional(nullptr, 1); + std::vector ordinary{{0, 3.0f, 0.0f}, {1, 2.0f, 0.0f}}; + llama_token ordinary_choice = -1; + assert(vp::select_candidate(ordinary, [&](llama_token id) { + return optional.permits(id == 0 ? "No tool is needed." : call, false); + }, [] { return false; }, ordinary_choice) == vp::selection::chosen); + assert(ordinary_choice == 0); // An offered tool never forces a tool call. + vp::tool_constraint value(nullptr, 1); + feed(value, " pieces{"call>{bad}", "call>{\"name\":\"vp_0\",\"arguments\":{}}"}; + const float inf = std::numeric_limits::infinity(); + const float nan = std::numeric_limits::quiet_NaN(); + std::vector candidates{{0, 100.0f, 0.0f}, {1, 1.0f, 0.0f}, + {2, inf, 0.0f}, {3, -inf, 0.0f}, {4, nan, 0.0f}}; + llama_token chosen = -1; + assert(vp::select_candidate(candidates, [&](llama_token id) { + assert(id == 0 || id == 1); + return value.permits(pieces[id], false); + }, [] { return false; }, chosen) == vp::selection::chosen); + assert(chosen == 1); + feed(value, pieces[chosen]); + assert(value.permits("", true)); + candidates = {{1, 2.0f, 0.0f}, {0, 2.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { return true; }, + [] { return false; }, chosen) == vp::selection::chosen); + assert(chosen == 0); // Same finite greedy tie order, not forced tool selection. + candidates = {{0, nan, 0.0f}, {1, inf, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { assert(false); return true; }, + [] { return false; }, chosen) == vp::selection::empty); + assert(chosen == -1); + candidates = {{0, 1.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { return false; }, + [] { return false; }, chosen) == vp::selection::empty); + assert(chosen == -1); + candidates = {{0, 1.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { assert(false); return true; }, + [] { return true; }, chosen) == vp::selection::cancelled); + assert(chosen == -1); + candidates = {{0, 2.0f, 0.0f}, {1, 1.0f, 0.0f}}; + unsigned inspected = 0; + assert(vp::select_candidate(candidates, [&](llama_token) { ++inspected; return false; }, + [&] { return inspected != 0; }, chosen) == vp::selection::cancelled); + assert(inspected == 1 && chosen == -1); // Cancellation also during candidate rejection. +} +} + +int main() { + ordinary_and_optional(); + atomic_split_and_tail(); + preface_and_aliases(); + utf8_and_escapes(); + depth_and_wire_bounds(); + json_values_and_incomplete_suffix(); + finite_selection_and_cancel(); +} diff --git a/workers/volparossa-ml/native-cpu/output-constraint.cpp b/workers/volparossa-ml/native-cpu/output-constraint.cpp new file mode 100644 index 000000000..39c88b597 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint.cpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "output-constraint.h" +#include "llama-grammar.h" +#include +#include +#include + +namespace vp { +namespace { +constexpr const char * opening = ""; +using grammar_ptr = std::unique_ptr; + +std::string grammar(unsigned count) { + if (count < 1 || count > 32) { throw std::invalid_argument("constraint count"); } + std::string result = R"gbnf(root ::= "" ws "{" ws "\"name\"" ws ":" ws tool-name ws "," ws "\"arguments\"" ws ":" ws obj1 ws "}" ws "" ws +ws ::= [ \t\n\r]* +string ::= "\"" ([^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" unicode))* "\"" +hex ::= [0-9a-fA-F] +unicode ::= [0-9a-cA-Ce-fE-F] hex hex hex | [dD] [0-7] hex hex | [dD] [89aAbB] hex hex "\\u" [dD] [c-fC-F] hex hex +number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)? ([eE] [+-]? [0-9]+)? +scalar ::= string | number | "true" | "false" | "null" +tool-name ::= )gbnf"; + for (unsigned index = 0; index < count; ++index) { + if (index) { result += " | "; } + result += "\"\\\"vp_" + std::to_string(index) + "\\\"\""; + } + result += '\n'; + for (unsigned depth = 1; depth <= tool_json_depth; ++depth) { + const auto suffix = std::to_string(depth); + const auto element = depth == tool_json_depth ? "scalar" : "value" + std::to_string(depth + 1); + result += "value" + suffix + " ::= scalar | obj" + suffix + " | arr" + suffix + "\n"; + result += "obj" + suffix + " ::= \"{\" ws (string ws \":\" ws " + element + + " (ws \",\" ws string ws \":\" ws " + element + ")*)? ws \"}\"\n"; + result += "arr" + suffix + " ::= \"[\" ws (" + element + " (ws \",\" ws " + + element + ")*)? ws \"]\"\n"; + } + return result; +} + +// Validate all bytes, including token-split UTF-8. The bound matches Python's +// ensure_ascii JSON string encoding, including its two surrounding quotes. +bool wire_prefix(const std::string & text, bool & complete) { + std::size_t cost = 2; + complete = true; + for (std::size_t index = 0; index < text.size();) { + const auto first = static_cast(text[index]); + if (!first) { return false; } + if (first < 0x80) { + cost += first == '"' || first == '\\' || first == '\b' || first == '\f' || + first == '\n' || first == '\r' || first == '\t' ? 2 : first < 0x20 || first == 0x7f ? 6 : 1; + ++index; + } else { + const unsigned length = first >= 0xc2 && first <= 0xdf ? 2 : + first >= 0xe0 && first <= 0xef ? 3 : first >= 0xf0 && first <= 0xf4 ? 4 : 0; + if (!length) { return false; } + cost += length == 4 ? 12 : 6; + for (unsigned offset = 1; offset < length; ++offset) { + if (index + offset >= text.size()) { complete = false; break; } + const auto next = static_cast(text[index + offset]); + if (next < 0x80 || next > 0xbf || (offset == 1 && + ((first == 0xe0 && next < 0xa0) || (first == 0xed && next > 0x9f) || + (first == 0xf0 && next < 0x90) || (first == 0xf4 && next > 0x8f)))) { return false; } + } + index += length; + } + if (cost > tool_json_wire_bytes) { return false; } + } + return true; +} + +bool preface(const std::string & text) { + if (text.find("(vocab, count)) {} +tool_constraint::~tool_constraint() = default; + +selection select_candidate(std::vector & candidates, + const std::function & permitted, + const std::function & cancelled, llama_token & selected) { + selected = -1; + if (cancelled()) { return selection::cancelled; } + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [](const auto & candidate) { return !std::isfinite(candidate.logit); }), candidates.end()); + const auto lower = [](const auto & left, const auto & right) { + return left.logit < right.logit || (left.logit == right.logit && left.id > right.id); + }; + std::make_heap(candidates.begin(), candidates.end(), lower); + while (!candidates.empty()) { + if (cancelled()) { return selection::cancelled; } + const auto candidate = candidates.front(); + if (permitted(candidate.id)) { selected = candidate.id; return selection::chosen; } + std::pop_heap(candidates.begin(), candidates.end(), lower); + candidates.pop_back(); + } + return selection::empty; +} + +void tool_constraint::filter(llama_token_data_array & candidates) const { + if (value->active) { + if (!value->parser->vocab || !candidates.data || !candidates.size) { + throw std::invalid_argument("constraint filter"); + } + llama_grammar_apply_impl(*value->parser, &candidates); + } +} + +bool tool_constraint::permits(const std::string & piece, bool eos) const { + if (value->ended || (!eos && piece.empty()) || piece.size() > tool_json_wire_bytes || + value->text.size() + piece.size() > tool_json_wire_bytes) { return false; } + const auto next = value->text + piece; + bool complete = false; + if (!wire_prefix(next, complete)) { return false; } + for (const auto * forbidden : {""}) { + if (next.find(forbidden) != std::string::npos) { return false; } + } + if (eos) { + if (!piece.empty() || !complete) { return false; } + if (value->active) { return completed(*value->parser); } + return next.find_first_not_of(" \t\n\r\f\v") != std::string::npos && + next.find("active) { + try { return viable(*value->advance(piece)); } + catch (const std::runtime_error &) { return false; } + } + const auto start = next.find(opening); + if (start == std::string::npos) { + const auto partial = next.find("advance(next.substr(start))); } + catch (const std::runtime_error &) { return false; } +} + +void tool_constraint::accept(const std::string & piece, bool eos) { + if (!permits(piece, eos)) { throw std::invalid_argument("constraint candidate"); } + if (eos) { value->ended = true; return; } + const auto next = value->text + piece; + const auto start = next.find(opening); + if (value->active) { value->parser = value->advance(piece); } + else if (start != std::string::npos) { + value->parser = value->advance(next.substr(start)); + value->active = true; + } + value->text = next; +} +} diff --git a/workers/volparossa-ml/native-cpu/output-constraint.h b/workers/volparossa-ml/native-cpu/output-constraint.h new file mode 100644 index 000000000..b3d8d42a6 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include "llama.h" +#include +#include +#include +#include + +namespace vp { +// Fixed, optional profile. No caller-provided grammar or schema is accepted. +constexpr unsigned tool_json_depth = 16; +constexpr unsigned tool_json_wire_bytes = 4096; +enum class selection { chosen, empty, cancelled }; +selection select_candidate(std::vector & candidates, + const std::function & permitted, + const std::function & cancelled, llama_token & selected); +class tool_constraint { +public: + tool_constraint(const llama_vocab * vocab, unsigned count); + ~tool_constraint(); + tool_constraint(const tool_constraint &) = delete; + tool_constraint & operator=(const tool_constraint &) = delete; + void filter(llama_token_data_array & candidates) const; + bool permits(const std::string & piece, bool eos) const; + void accept(const std::string & piece, bool eos); +private: + struct implementation; + std::unique_ptr value; +}; +} diff --git a/workers/volparossa-ml/test_conversion_diagnostic.py b/workers/volparossa-ml/test_conversion_diagnostic.py index a5ca5f519..fc11d4629 100644 --- a/workers/volparossa-ml/test_conversion_diagnostic.py +++ b/workers/volparossa-ml/test_conversion_diagnostic.py @@ -18,7 +18,7 @@ class ConversionDiagnosticTests(unittest.TestCase): - def exercise_stage(self, rejected): + def exercise_stage(self, rejected, build_version=2): with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: base = Path(directory) model, build, source = (base / name for name in ('model', 'build', 'source')) @@ -28,7 +28,7 @@ def exercise_stage(self, rejected): path.write_bytes(b'inert') pinned = [{'path': name, 'bytes': 5, 'sha256': hashlib.sha256(b'inert').hexdigest()} for name in ('weights', 'LICENSE')] - manifest = dict(version=2, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, + manifest = dict(version=build_version, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, source_tree=conversion.native.SOURCE_TREE, source_overlay=conversion.native.loader_provenance(), loader_compile_source_verified=True, upstream_original_unchanged=True, abi_version=1, quantization=False, provisionable=True, sanitizers=False, @@ -36,6 +36,8 @@ def exercise_stage(self, rejected): dynamic_dependencies=['libc.so.6'], wrapper_sources={name: conversion.builder.digest(conversion.HERE / 'native-cpu' / name) for name in conversion.builder.WRAPPER_FILES}) + if build_version == 3: + manifest['output_constraints'] = [conversion.native.OUTPUT_CONSTRAINT] (build / 'build.json').write_text(json.dumps(manifest)) progress, visited = {}, [] def boundary(stage, result=None): @@ -88,8 +90,15 @@ def copyfile(src, dst): return original_copy(src, dst) stack.enter_context(mock.patch.object(conversion.shutil, 'copyfile', side_effect=copyfile)) stack.enter_context(mock.patch.object(conversion.provision, 'native_converter_pins', return_value={'notices': []})) + def converted_manifest(value, _require): + self.assertEqual(value['version'], build_version - 1) + if build_version == 3: + self.assertEqual(value['output_constraints'], [conversion.native.OUTPUT_CONSTRAINT]) + else: + self.assertNotIn('output_constraints', value) + boundary('manifest') stack.enter_context(mock.patch.object(conversion.native, 'validate_manifest', - side_effect=lambda *_args: boundary('manifest'))) + side_effect=converted_manifest)) stack.enter_context(mock.patch.object(conversion, 'private_bundle_modes', side_effect=lambda _path: boundary('permissions'))) args = SimpleNamespace(root=str(base / 'output'), source=source, build=build, model=model, @@ -118,6 +127,16 @@ def test_actual_pipeline_records_each_failure_stage_without_model_execution(self finally: sys.path[:] = original_path + def test_new_build_capability_projects_into_new_backend_schema_only(self): + original_path = list(sys.path) + try: + progress, diagnostic, visited = self.exercise_stage('permissions', build_version=3) + self.assertEqual(progress['stage'], 'permissions') + self.assertIn('manifest', visited) + self.assertEqual(diagnostic['failure'], 'contract') + finally: + sys.path[:] = original_path + def test_child_nonzero_timeout_and_cancellation_retain_bounded_final_status(self): signals = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) before = {item: signal.getsignal(item) for item in signals} diff --git a/workers/volparossa-ml/test_native_output_constraint.py b/workers/volparossa-ml/test_native_output_constraint.py new file mode 100644 index 000000000..f88315746 --- /dev/null +++ b/workers/volparossa-ml/test_native_output_constraint.py @@ -0,0 +1,368 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert admission/provenance/ABI tests; no native build or model execution.""" +import copy +import hashlib +import json +from pathlib import Path +import tempfile +import sys +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +from test_conversation import CONVERSATION, conversation +from test_llama_cpu import job, manifest +from test_worker_protocol import WORKER, request +from test_qwen_conversation import NATIVE + + +def selected(count=1): + return dict(job(), output_constraint=native.OUTPUT_CONSTRAINT, + output_constraint_tools=[f"vp_{index}" for index in range(count)]) + + +def selected_data(): + return dict(conversation(), generation_policy="greedy_v1", output_constraint=native.OUTPUT_CONSTRAINT) + + +def build_manifest(version=2): + result = dict(version=version, abi_version=1, kind=native.KIND, + source_commit=native.SOURCE, source_tree=native.SOURCE_TREE, + source_overlay=native.loader_provenance(), loader_compile_source_verified=True, + upstream_original_unchanged=True, cpu="avx2_fma_f16c", provisionable=True, + sanitizers=False, quantization=False) + if version == 3: + result["output_constraints"] = [native.OUTPUT_CONSTRAINT] + return result + + +def bundle(root, version, build_version): + value = manifest() + value["version"] = version + if version == 2: + value["output_constraints"] = [native.OUTPUT_CONSTRAINT] + for field, raw in (("library", b"inert-not-library"), ("gguf", b"inert-not-model")): + (root / value[field]["path"]).write_bytes(raw) + value[field].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + build = dict(build_manifest(build_version), library=value["library"]) + build_raw = json.dumps(build).encode() + (root / "build.json").write_bytes(build_raw) + value["build_manifest_sha256"] = hashlib.sha256(build_raw).hexdigest() + value["verification"]["chat_template_sha256"] = hashlib.sha256(b"unchanged").hexdigest() + raw = json.dumps(value).encode() + (root / "backend.json").write_bytes(raw) + return dict(job(), native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) + + +def library(extension=True): + def allocate(_path, _capacity, _threads, _callback, _opaque, output): + output._obj.value = 123 + return 0 + value = SimpleNamespace( + vp_llama_abi_v1=mock.Mock(return_value=1), + vp_llama_source_v1=mock.Mock(return_value=native.SOURCE.encode()), + vp_llama_open_stage_v1=mock.Mock(return_value=8), + vp_llama_open_v1=mock.Mock(side_effect=allocate), + vp_llama_decode_v1=mock.Mock(return_value=0), + vp_llama_sample_v1=mock.Mock(return_value=0), + vp_llama_close_v1=mock.Mock()) + if extension: + value.vp_llama_output_constraint_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_set_output_constraint_v1 = mock.Mock(return_value=0) + return value + + +class ConstraintAdmissionTests(unittest.TestCase): + def test_legacy_requests_are_not_implicitly_constrained(self): + for value in (request(), job()): + result = WORKER.validate_request(value) + self.assertEqual(result, dict(value, steps=value.get("steps", 8), threads=2, max_seconds=600)) + self.assertNotIn("output_constraint", result) + self.assertNotIn("output_constraint_tools", result) + + def test_only_complete_core_alias_list_is_accepted(self): + for count in (1, 32): + value = selected(count) + self.assertEqual(WORKER.validate_request(value)["output_constraint_tools"], value["output_constraint_tools"]) + for changes in ({"output_constraint": None}, {"output_constraint": True}, + {"output_constraint": "user-grammar"}, {"output_constraint_tools": None}, + {"output_constraint_tools": []}, {"output_constraint_tools": ["vp_1"]}, + {"output_constraint_tools": ["vp_0", "vp_0"]}, + {"output_constraint_tools": ["read_private_file"]}, + {"output_constraint_tools": [True]}, + {"output_constraint_tools": [f"vp_{n}" for n in range(33)]}, + {"mode": "train"}, {"model_profile": WORKER.QWEN_MODEL_PROFILE}): + with self.subTest(changes=changes), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), **changes)) + for field in ("output_constraint", "output_constraint_tools", "inference_backend"): + value = selected() + del value[field] + with self.subTest(field=field), self.assertRaises(WORKER.JobError): + WORKER.validate_request(value) + + def test_aliases_bind_actual_ordered_dataset_before_loading(self): + data = selected_data() + for selected_job, changed in ((selected(2), data), (selected(), dict(data, tools=[]))): + with mock.patch.object(WORKER, "conversation_module", return_value=CONVERSATION), \ + mock.patch.object(native, "verified_bundle", side_effect=AssertionError("must not access bundle")), \ + self.assertRaisesRegex((WORKER.JobError, ValueError), "OUTPUT_CONSTRAINT"): + native.execute(selected_job, mock.Mock(), None, None, {}, None, None, + changed, {}, {}, WORKER, initial_source_weights=None) + + def test_serialized_input_selector_is_closed_and_preserves_bytes(self): + data = selected_data() + raw = CONVERSATION.canonical(data) + self.assertIs(WORKER.validate_dataset(data, "private_conversation", native.PROFILE), data) + self.assertEqual(CONVERSATION.canonical(data), raw) + invalid = [dict(data, output_constraint=value) for value in + (None, True, 1, "", "private-grammar", [], {})] + invalid.extend((dict(data, generation_policy=None), dict(data, tools=[]), + dict(data, output_constraint_tools=["vp_0"]))) + no_policy = dict(data) + del no_policy["generation_policy"] + invalid.append(no_policy) + for changed in invalid: + with self.subTest(data=changed), self.assertRaises(WORKER.JobError): + WORKER.validate_dataset(changed, "private_conversation", native.PROFILE) + for profile in (CONVERSATION.QWEN, WORKER.DEFAULT_MODEL_PROFILE): + with self.assertRaises(WORKER.JobError): + WORKER.validate_dataset(data, "private_conversation", profile) + + def test_request_dataset_constraint_matches_in_both_directions_before_backend(self): + legacy = dict(conversation(), generation_policy="greedy_v1") + cases = ((selected(), legacy), (job(), selected_data()), + (dict(selected(), output_constraint="different"), selected_data())) + for selected_job, data in cases: + with self.subTest(request=selected_job), \ + mock.patch.object(native, "verified_bundle", side_effect=AssertionError("no native load")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + native.execute(selected_job, mock.Mock(), None, None, {}, None, None, + data, {}, {}, WORKER, initial_source_weights=None) + with mock.patch.object(WORKER, "prepare_files", return_value=(None, None, data, {}, {})), \ + mock.patch.object(WORKER, "load_backend", side_effect=AssertionError("no backend import")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + WORKER.execute_job(selected_job, mock.Mock()) + torch_job = WORKER.validate_request(dict(request(), mode="private_conversation", steps=1, + owner_control=True, model_profile=native.PROFILE)) + with mock.patch.object(WORKER, "prepare_files", return_value=(None, None, selected_data(), {}, {})), \ + mock.patch.object(WORKER, "load_backend", side_effect=AssertionError("no silent torch fallback")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + WORKER.execute_job(torch_job, mock.Mock()) + + +class ConstraintProvenanceTests(unittest.TestCase): + def test_old_and_new_closed_manifest_shapes_do_not_mix(self): + legacy = manifest() + old_bytes = json.dumps(legacy).encode() + native.validate_manifest(legacy, WORKER.require) + self.assertEqual(json.dumps(legacy).encode(), old_bytes) + modern = dict(legacy, version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + native.validate_manifest(modern, WORKER.require) + for value in (dict(legacy, output_constraints=[]), dict(legacy, output_constraints=[native.OUTPUT_CONSTRAINT]), + dict(legacy, version=2), dict(modern, version=True), + dict(modern, output_constraints=[]), dict(modern, output_constraints=None), + dict(modern, output_constraints=[native.OUTPUT_CONSTRAINT, native.OUTPUT_CONSTRAINT]), + dict(modern, output_constraints=["private-grammar"]), dict(modern, grammar="secret")): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + native.validate_manifest(value, WORKER.require) + for version in (2, 3): + native.validate_build_provenance(build_manifest(version), WORKER.require) + for value in (dict(build_manifest(), output_constraints=[native.OUTPUT_CONSTRAINT]), + dict(build_manifest(3), output_constraints=[]), dict(build_manifest(3), version=4)): + with self.assertRaises(WORKER.JobError): + native.validate_build_provenance(value, WORKER.require) + + def test_verified_bundle_requires_matching_capabilities_and_request(self): + for version, build_version in ((1, 2), (2, 3), (1, 3), (2, 2)): + for constrained in (False, True): + with self.subTest(version=version, build=build_version, constrained=constrained), \ + tempfile.TemporaryDirectory() as directory: + value = bundle(Path(directory), version, build_version) + if constrained: + value.update(output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0"]) + with mock.patch.object(native.ctypes, "CDLL", side_effect=AssertionError("no library execution")): + arguments = (value, WORKER, SimpleNamespace(chat_template="unchanged"), mock.Mock()) + if build_version != version + 1 or constrained and version == 1: + with self.assertRaises(WORKER.JobError): + native.verified_bundle(*arguments) + else: + _, _, identity = native.verified_bundle(*arguments) + self.assertNotIn("output_constraint", identity) + self.assertEqual(identity["manifest_sha256"], value["native_backend_sha256"]) + + +class ConstraintAbiTests(unittest.TestCase): + def open(self, value, **kwargs): + with mock.patch.object(native.ctypes, "CDLL", return_value=value): + return native.NativeModel(Path("/inert"), 512, 2, mock.Mock(), WORKER.require, **kwargs) + + def test_legacy_abi_without_extension_remains_usable(self): + value = library(False) + model = self.open(value) + model.close() + value.vp_llama_open_v1.assert_called_once() + value.vp_llama_close_v1.assert_called_once() + + def test_capability_cannot_be_claimed_by_old_or_wrong_library(self): + values = [library(False)] + for version in (0, 2, True): + value = library() + value.vp_llama_output_constraint_abi_v1.return_value = version + values.append(value) + for value in values: + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH"): + self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT]) + value.vp_llama_open_v1.assert_not_called() + + def test_declared_capability_does_not_enable_constraint_without_request(self): + value = library() + model = self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT]) + model.close() + value.vp_llama_output_constraint_abi_v1.assert_called_once() + value.vp_llama_set_output_constraint_v1.assert_not_called() + + def test_constraint_is_configured_once_with_mode_and_actual_count_before_decode(self): + value = library() + events = [] + value.vp_llama_set_output_constraint_v1.side_effect = lambda *_args: events.append("configure") or 0 + value.vp_llama_decode_v1.side_effect = lambda *_args: events.append("decode") or 0 + model = self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT], + output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0", "vp_1"]) + model.decode([7]) + self.assertEqual(events, ["configure", "decode"]) + self.assertEqual(value.vp_llama_set_output_constraint_v1.call_args.args[1:], (1, 2)) + value.vp_llama_set_output_constraint_v1.assert_called_once() + model.close() + + def test_configure_failure_or_cancel_closes_handle_without_decode(self): + for cancelled in (False, True): + value, probe = library(), mock.Mock() + value.vp_llama_set_output_constraint_v1.return_value = 3 if not cancelled else 0 + if cancelled: + probe.joined.side_effect = [None, WORKER.JobError("JOB_CANCELLED")] + with mock.patch.object(native.ctypes, "CDLL", return_value=value), \ + self.assertRaisesRegex(WORKER.JobError, "JOB_CANCELLED" if cancelled else "CONFIGURATION_FAILED"): + native.NativeModel(Path("/inert"), 512, 2, probe, WORKER.require, + output_constraints=[native.OUTPUT_CONSTRAINT], + output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0"]) + value.vp_llama_close_v1.assert_called_once() + value.vp_llama_decode_v1.assert_not_called() + + +class ConstraintResultTests(unittest.TestCase): + def test_output_tag_only_echoes_successfully_requested_constraint(self): + data = dict(conversation(), generation_policy="greedy_v1") + profile = WORKER.model_profile(native.PROFILE) + files = {name: {"bytes": profile["files"][name], "sha256": profile["hashes"][name]} + for name in profile["weights"]["files"]} + output = {"sample_index": 0, "text": "Ordinary answer.", "generated_tokens": 3, + "text_truncated": False, "generation": {"stop_reason": "eos"}} + bridge = SimpleNamespace(generation_policy=CONVERSATION.generation_policy, + output_constraint=CONVERSATION.output_constraint, + validate=CONVERSATION.validate, encode=lambda *_args: [1, 2], + decode=lambda *_args: {"type": "assistant", "text": output["text"]}, + capabilities=lambda *_args: {"inert": True}) + baseline = None + for declared, requested in ((False, False), (True, False), (True, True)): + with self.subTest(declared=declared, requested=requested), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + raw = b"inert model file" + (root / "model.gguf").write_bytes(raw) + value = manifest() + value["gguf"].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + if declared: + value.update(version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + model = mock.Mock() + selected_job = WORKER.validate_request(selected() if requested else job()) + with mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ + mock.patch.object(native, "verified_bundle", return_value=(root, value, {})), \ + mock.patch.object(native, "NativeModel", return_value=model) as opened, \ + mock.patch.object(native, "generate", return_value=copy.deepcopy(output)), \ + mock.patch.object(WORKER, "finish_result", side_effect=lambda result, *_args: result): + result = native.execute(selected_job, mock.Mock(), None, None, {}, None, root, + selected_data() if requested else data, + {"sha256": "d" * 64}, files, WORKER, + initial_source_weights=profile["weights"]) + self.assertEqual(result["outputs"], [output]) + self.assertEqual(result["generation_policy"], "greedy_v1") + self.assertEqual(result["conversation"], {"type": "assistant", "text": output["text"]}) + self.assertEqual("output_constraint" in result, requested) + if requested: + self.assertEqual(result["output_constraint"], native.OUTPUT_CONSTRAINT) + self.assertEqual(opened.call_args.kwargs["output_constraint_tools"], ["vp_0"]) + elif declared: + self.assertEqual(opened.call_args.kwargs, {"output_constraints": [native.OUTPUT_CONSTRAINT]}) + else: + self.assertEqual(opened.call_args.kwargs, {}) + legacy_shape = dict(result) + legacy_shape.pop("output_constraint", None) + if baseline is None: + baseline = legacy_shape + self.assertEqual(legacy_shape, baseline, "no other legacy result field changes") + model.close.assert_called_once() + + def test_real_execute_chain_accepts_serialized_selection_and_unchanged_template(self): + data, profile = selected_data(), WORKER.model_profile(native.PROFILE) + raw = CONVERSATION.canonical(data).encode() + files = {name: {"bytes": profile["files"][name], "sha256": profile["hashes"][name]} + for name in profile["weights"]["files"]} + identity = {"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "visibility": "private_local"} + tokenizer = mock.Mock(pad_token_id=151643, eos_token_id=151645) + tokenizer.apply_chat_template.return_value = [1, 2] + transformers = mock.Mock() + transformers.AutoTokenizer.from_pretrained.return_value = tokenizer + output = {"sample_index": 0, "text": '{"name":"vp_0","arguments":{"name":"demo.rs"}}', + "generated_tokens": 3, "text_truncated": False, "generation": {"stop_reason": "eos"}} + with tempfile.TemporaryDirectory() as directory: + root, model = Path(directory), mock.Mock() + model_raw = b"inert model file" + (root / "model.gguf").write_bytes(model_raw) + value = dict(manifest(), version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + value["gguf"].update(bytes=len(model_raw), sha256=hashlib.sha256(model_raw).hexdigest()) + def prepared(_request, *, session, initial_source): + initial_source["weights"] = profile["weights"] + parsed = WORKER.validate_dataset(WORKER.parse_json(raw), "private_conversation", native.PROFILE) + return root, root, parsed, identity, files + with mock.patch.dict(sys.modules, {"volparossa_llama_cpu": native, + "volparossa_qwen_conversation": NATIVE}), \ + mock.patch.object(WORKER, "prepare_files", side_effect=prepared), \ + mock.patch.object(WORKER, "configure_offline"), \ + mock.patch.object(WORKER, "load_backend", return_value=(None, transformers, None, {})), \ + mock.patch.object(native, "verified_bundle", return_value=(root, value, {})), \ + mock.patch.object(native, "NativeModel", return_value=model) as opened, \ + mock.patch.object(native, "generate", return_value=output), \ + mock.patch.object(WORKER, "finish_result", side_effect=lambda result, *_args: result): + result = WORKER.execute_job(WORKER.validate_request(selected()), mock.Mock()) + self.assertEqual(result["output_constraint"], native.OUTPUT_CONSTRAINT) + self.assertEqual(result["conversation"]["type"], "function_call") + self.assertEqual(result["conversation"]["arguments"], {"name": "demo.rs"}) + self.assertEqual(result["dataset"], identity) + self.assertEqual(opened.call_args.kwargs["output_constraint_tools"], ["vp_0"]) + template = tokenizer.apply_chat_template.call_args + legacy = dict(data) + del legacy["output_constraint"] + self.assertEqual(template.args[0], NATIVE.messages(legacy, native.PROFILE)) + self.assertEqual(template.kwargs["tools"], NATIVE.native_tools(legacy)) + model.close.assert_called_once() + + def test_selected_output_depth_counts_containers_not_envelope_or_strings(self): + for depth in (1, 16, 17): + args = {"name": "[[[{{{ literal text }}}]]]"} + for index in range(depth - 1): + args = {"child": args} if index % 2 == 0 else [args] + if type(args) is not dict: + # Preserve the required object root without increasing depth. + args = {"child": args[0]} + raw = '' + json.dumps({"name": "vp_0", "arguments": args}) + '' + output = {"text": raw, "text_truncated": False, "generation": {"stop_reason": "eos"}} + for constrained in (False, True): + data = selected_data() if constrained else dict(conversation(), generation_policy="greedy_v1") + result = CONVERSATION.decode(data, output, native.PROFILE, "a" * 32) + self.assertEqual(result["type"], "incomplete" if constrained and depth > 16 else "function_call") + if result["type"] == "incomplete": + self.assertEqual(result["reason"], "invalid_output") + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index ddd4d4f4f..f9f950592 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -259,7 +259,8 @@ def bounded_integer(value, low, high): def validate_request(value): required = {"version", "id", "mode", "model_root", "dataset_path", "output_root"} optional = {"steps", "threads", "max_seconds", "adapter_root", "owner_control", "model_profile", - "private_generation_diagnostics", "inference_backend", "native_backend_root", "native_backend_sha256"} + "private_generation_diagnostics", "inference_backend", "native_backend_root", "native_backend_sha256", + "output_constraint", "output_constraint_tools"} require(type(value) is dict and required <= value.keys() and value.keys() <= required | optional, "INVALID_REQUEST_FIELDS") require(type(value["version"]) is int and value["version"] == VERSION, "UNSUPPORTED_VERSION") @@ -273,6 +274,15 @@ def validate_request(value): and profile_name == QWEN4B_MODEL_PROFILE and value["mode"] == "private_conversation" and type(value["native_backend_sha256"]) is str and re.fullmatch(r"[0-9a-f]{64}", value["native_backend_sha256"]), "INVALID_NATIVE_BACKEND_SCOPE") + constraint_fields = {"output_constraint", "output_constraint_tools"} + if constraint_fields & value.keys(): + aliases = value.get("output_constraint_tools") + require(constraint_fields <= value.keys() and native_fields <= value.keys() + and value["output_constraint"] == "qwen_tagged_tool_json_v1" + and type(value["output_constraint"]) is str + and type(aliases) is list and 1 <= len(aliases) <= 32 + and aliases == [f"vp_{index}" for index in range(len(aliases))], + "INVALID_NATIVE_OUTPUT_CONSTRAINT") require("private_generation_diagnostics" not in value or (type(value["private_generation_diagnostics"]) is bool and (not value["private_generation_diagnostics"] or @@ -1987,6 +1997,22 @@ def conversation_module(): return module +def conversation_constraint_binding(request, dataset, profile_name): + requested = "output_constraint" in request + require(requested == ("output_constraint" in dataset), "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if not requested: + require("output_constraint_tools" not in request, "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return None + conversation_module().validate(dataset, profile_name) + constraint = conversation_module().output_constraint(dataset, profile_name) + require(request["output_constraint"] == constraint + and request.get("inference_backend") == "llama_cpp_bf16_v1", + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + aliases = [f"vp_{index}" for index in range(len(dataset["tools"]))] + require(request.get("output_constraint_tools") == aliases, "NATIVE_OUTPUT_CONSTRAINT_TOOLS") + return constraint + + def execute_private_infer(request, session, tokenizer, torch, transformers, versions, model_root, output_root, dataset, data_identity, model_files): profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) @@ -2323,6 +2349,8 @@ def execute_job(request, session): preparation = ({"initial_source": initial_source} if request.get("inference_backend") == "llama_cpp_bf16_v1" else {}) model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session, **preparation) + if request["mode"] == "private_conversation": + conversation_constraint_binding(request, dataset, profile_name) session.private_progress("verify_files", "complete") session.check() cohort = None From b8bd8228e90f035fd0da237643014bacd05bdae1 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:32:39 +0200 Subject: [PATCH 11/39] fix(ci): use the hosted native compiler tool path consistently --- .github/workflows/native-cpu-contract.yml | 5 +++-- docs/IMPLEMENTATION_STATUS.md | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-cpu-contract.yml b/.github/workflows/native-cpu-contract.yml index 1541db622..32396c253 100644 --- a/.github/workflows/native-cpu-contract.yml +++ b/.github/workflows/native-cpu-contract.yml @@ -44,7 +44,8 @@ jobs: test "$RUNNER_OS" = Linux test "$(git rev-parse HEAD)" = "$GITHUB_SHA" test -z "$(git status --porcelain)" - for tool in cmake c++ cc git readelf; do command -v "$tool"; done + env -i PATH=/usr/local/bin:/usr/bin:/bin /bin/bash -euo pipefail -c \ + 'for tool in cmake c++ cc git readelf nice python3; do command -v "$tool"; done' - name: Fetch the pinned upstream source only run: | set -euo pipefail @@ -59,7 +60,7 @@ jobs: set -euo pipefail flags=() if test "$NATIVE_MODE" = asan-ubsan; then flags+=(--sanitize); fi - env -i PATH=/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ + env -i PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ python3 -B workers/volparossa-ml/build_llama_cpu.py \ --source "$PWD/build/native-contract/llama" \ --output "$PWD/build/native-contract/output" --execute --checks "${flags[@]}" diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 296a354c7..dfc47aef8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -43,6 +43,15 @@ the unchanged all-type tensor test with its known sanitizer finding. No local Rust, native build or inference ran. These contracts are not proof of an actual OpenCode coding task or protected peer execution. +Initial source-only run `38671269423` on `4e5126ad` stops before compilation or +build-log creation in both modes. The original preflight resolves CMake to +`/usr/local/bin/cmake`, while the scrubbed build PATH excludes `/usr/local/bin`. +The generic builder failure does not retain its internal exception, so missing +CMake is a source-supported diagnosis, not an exported error code. Preflight and +build now use the same fixed hosted PATH, without installing anything. No native +check passed in this attempt. Original job-log ZIP SHA-256: +`f05e248ac6685079055b8a4db631d43b85ded5792be0d53a2374ce446c0e0177`. + #### Earlier native candidate evidence - [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the From 030c16f02dcd85bf7ec6b5f5d15ded13f8e4bc25 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:35:05 +0200 Subject: [PATCH 12/39] style(compute): apply hosted output-constraint formatter feedback --- .../native_cpu/tests/output_constraint.rs | 6 +++-- .../src/compute/private_conversation.rs | 10 +++++++-- .../tests/output_constraint.rs | 22 +++++++++++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs index f1126e2bd..94db9cf5e 100644 --- a/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs +++ b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs @@ -11,10 +11,12 @@ fn constrained_manifest() -> Value { } fn input() -> Vec { - serde_json::to_vec(&json!({"version":1,"visibility":"private_local","instructions":"Reply.", + serde_json::to_vec( + &json!({"version":1,"visibility":"private_local","instructions":"Reply.", "history":[{"type":"message","role":"user","text":"Read."}], "tools":[{"type":"custom","name":"PRIVATE_CANARY","description":"Read."}], - "generation_policy":"greedy_v1","output_constraint":CONSTRAINT})) + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}), + ) .unwrap() } diff --git a/crates/volparossa/src/compute/private_conversation.rs b/crates/volparossa/src/compute/private_conversation.rs index f3a596876..f532f3667 100644 --- a/crates/volparossa/src/compute/private_conversation.rs +++ b/crates/volparossa/src/compute/private_conversation.rs @@ -154,10 +154,16 @@ fn arguments(value: &Value) -> bool { fn constrained_argument_depth(value: &Value, depth: u8) -> bool { match value { Value::Object(values) => { - depth <= 16 && values.values().all(|value| constrained_argument_depth(value, depth + 1)) + depth <= 16 + && values + .values() + .all(|value| constrained_argument_depth(value, depth + 1)) } Value::Array(values) => { - depth <= 16 && values.iter().all(|value| constrained_argument_depth(value, depth + 1)) + depth <= 16 + && values + .iter() + .all(|value| constrained_argument_depth(value, depth + 1)) } _ => true, } diff --git a/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs index 69e170275..cdb16ddf1 100644 --- a/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs +++ b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs @@ -36,7 +36,13 @@ fn constraint_is_explicit_nonnull_qwen4b_greedy_with_offered_tools_only() { serde_json::from_slice::(&decoded.bytes_profile(PROFILE).unwrap()).unwrap(), value ); - for invalid in [Value::Null, json!(true), json!(1), json!([]), json!("other")] { + for invalid in [ + Value::Null, + json!(true), + json!(1), + json!([]), + json!("other"), + ] { let mut bad = value.clone(); bad["output_constraint"] = invalid; assert!(decode_selected(&bad).is_err()); @@ -45,7 +51,10 @@ fn constraint_is_explicit_nonnull_qwen4b_greedy_with_offered_tools_only() { assert!(Input::decode_profile(&serde_json::to_vec(&value).unwrap(), profile).is_err()); } let mut no_policy = value.clone(); - no_policy.as_object_mut().unwrap().remove("generation_policy"); + no_policy + .as_object_mut() + .unwrap() + .remove("generation_policy"); assert!(decode_selected(&no_policy).is_err()); let mut no_tools = value.clone(); no_tools["tools"] = json!([]); @@ -73,7 +82,9 @@ fn constraint_aliases_are_core_derived_bounded_and_contiguous() { .unwrap(); assert_eq!( aliases, - (0..32).map(|index| format!("vp_{index}")).collect::>() + (0..32) + .map(|index| format!("vp_{index}")) + .collect::>() ); assert!( !serde_json::to_string(&aliases) @@ -158,7 +169,10 @@ fn constrained_argument_nesting_limit_is_exact_and_does_not_change_legacy() { for depth in 1..=17 { let text = format!("{{\"name\":\"vp_0\",\"arguments\":{nested}}}"); let result = report(&text); - assert_eq!(result["conversation"]["type"] == "function_call", depth <= 16); + assert_eq!( + result["conversation"]["type"] == "function_call", + depth <= 16 + ); let mut legacy = selected(); legacy.as_object_mut().unwrap().remove("output_constraint"); let input = decode_selected(&legacy).unwrap(); From 9d044921f7e684737f9b10868429de42bc5416c9 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:47:52 +0200 Subject: [PATCH 13/39] fix(compute): combine equivalent native admission rejections --- .../volparossa/src/compute/private_serve.rs | 10 +++--- docs/IMPLEMENTATION_STATUS.md | 34 +++++++++++++++---- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index 33e71721e..8bb82cc48 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -337,14 +337,12 @@ fn respond( wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { wire::error(Some(&request.id), "busy") - } else if conversation.output_constraint().is_some() + } else if (conversation.output_constraint().is_some() && !config .native_backend - .supports_output_constraint(config.model_profile) - { - wire::error(Some(&request.id), "invalid_request") - } else if config.native_backend.enabled() - && !conversation.requires_generation_policy_handshake() + .supports_output_constraint(config.model_profile)) + || (config.native_backend.enabled() + && !conversation.requires_generation_policy_handshake()) { wire::error(Some(&request.id), "invalid_request") } else if let Some(slot) = ExecutionSlot::admit(gate) { diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index dfc47aef8..78939b135 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -36,12 +36,34 @@ tool use, prompt truncation, extra time or alternate model is introduced. through the actual worker/encode/decode path with a synthetic native executor. That test caught and corrected a cross-layer input-field rejection. Thirteen new Rust cases cover manifest, request/result, parser and IPC bindings; execution -is pending on GitHub. Source-only C++ checks exercise the pinned grammar with -synthetic pieces; their native compilation and sanitizer execution are pending. -The new hosted workflow retains failures and runs the constraint checks before -the unchanged all-type tensor test with its known sanitizer finding. No local -Rust, native build or inference ran. These contracts are not proof of an actual -OpenCode coding task or protected peer execution. +remains pending. Hosted Quality run `38671767972` on `030c16f0` passes formatting, +native Python contracts and dependency checks, but strict Clippy rejects two +adjacent identical rejection branches (`if_same_then_else`). They are now one +equivalent disjunction, without suppressing the lint or changing admission. +The earlier run `38671600105` stopped at formatting, before Rust compilation; +`030c16f0` changes only those seven formatting hunks. These contracts are not +proof of an actual OpenCode coding task or protected peer execution. + +[Source-only native run `38671551374`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38671551374) +on `b8bd8228` passes the normal build and all five check targets. ABI, loader, +BF16/F32 and the new output-constraint tests also pass under ASan/UBSan. The +unchanged upstream all-type test then fails on the already recorded unaligned +Q1_0/Q8_0 load at `ggml/src/ggml-cpu/arch/x86/quants.c:590`; the full sanitizer +job remains **failed**. The selected converter and model adapter admit BF16/F32, +not this quantized kernel. This narrower evidence supports the next bounded +development trial, not a clean upstream sanitizer result or release readiness. +No model was loaded and no local Rust, native build or inference ran. + +The normal build's v3 manifest binds the six wrapper sources, exact upstream +tree and library SHA-256 +`3e59444d6f81c753ab1fcd3418ebabfb42f0368b24c76961ee59ddaf339617fa`. +Native sources, Python contracts and workflow are byte-identical between +`b8bd8228` and `030c16f0`. Original normal artifact SHA-256: +`40ecaab5262d043a25b3d30d52a641f8406130103741e00d6857eca925201163`; +sanitizer artifact: +`3c948ae71d8c54870fe1a151b95d699556aa0907f9ccd17b5a9d3d18d71b5272`; +job-log ZIP: +`21b840e5ec8c39d8e7af2d9b23214b6eb1a5b04f324343fd57386a6ab8d21dde`. Initial source-only run `38671269423` on `4e5126ad` stops before compilation or build-log creation in both modes. The original preflight resolves CMake to From 85b5ae51a6c709661d87b0cf9a04254b4387f339 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:42:36 +0200 Subject: [PATCH 14/39] docs(compute): record selected output quality and real read evidence --- docs/IMPLEMENTATION_STATUS.md | 53 ++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 78939b135..2d9c106b9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -34,15 +34,41 @@ tool use, prompt truncation, extra time or alternate model is introduced. 171 targeted inert Python checks pass, including a serialized selected dataset through the actual worker/encode/decode path with a synthetic native executor. -That test caught and corrected a cross-layer input-field rejection. Thirteen -new Rust cases cover manifest, request/result, parser and IPC bindings; execution -remains pending. Hosted Quality run `38671767972` on `030c16f0` passes formatting, -native Python contracts and dependency checks, but strict Clippy rejects two -adjacent identical rejection branches (`if_same_then_else`). They are now one +That test caught and corrected a cross-layer input-field rejection. +[Hosted Quality `38672934132`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38672934132) +on exact core `9d044921` passes formatting, strict Clippy, 60 native Python +contracts and all thirteen new Rust manifest, request/result, parser and IPC +binding cases. The workspace records 3,558 distinct passing tests and seven +ignored tests. Three namespace proofs and one optional CLI identity proof are +explicitly skipped, not successful datapath evidence. Original job-log ZIP +SHA-256: `7760651e167a4700b0967c19dca7c6755b3a71e463799658faac3f372d576571`. + +Earlier Quality `38671767972` on `030c16f0` stopped at strict Clippy's +`if_same_then_else`: two adjacent identical rejection branches became one equivalent disjunction, without suppressing the lint or changing admission. -The earlier run `38671600105` stopped at formatting, before Rust compilation; -`030c16f0` changes only those seven formatting hunks. These contracts are not -proof of an actual OpenCode coding task or protected peer execution. +Run `38671600105` stopped at formatting before Rust compilation; `030c16f0` +changes only those seven formatting hunks. These source checks are not proof +of a complete OpenCode coding task or protected peer execution. + +[Actual Code trial `38675278393`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38675278393) +on Code `6aa6291a` / core `9d044921` passes native build, conversion and model +provisioning with the selected output constraint. One of seven submitted model +requests completes with a function call, and the actual OpenCode runtime +completes one `read` action. Six requests return `execution_failed`; none is +reported as invalid output or an execution-budget error. The only retained core +event is `result_observed`, which does not identify the six failures' causes. +The task ends after 588,147 ms as `opencode_task_incomplete`: no edit, test, +complete native turn or successful coding loop is proved. Its read-state +counters describe observed transitions, not three distinct tool calls. + +Reported runtime, project, provisioning-group, guest-unit/private-data, +QEMU/scratch and owned CI-host cleanup complete. Scoped normalized route/DNS +observations match; raw IPv6 route bytes differ, so this is not byte-identical +host-state evidence. Raw model output remains excluded. Original artifact +`11946278747` ZIP SHA-256: +`487bee78bb4857c6a5b48df7d69df21ceeb608ded9d9264122668480f62afa74`; +original job-log ZIP SHA-256: +`5ff8a4055bf966f192f80b366cbeb8428d77793c6ee6525b3109eecf292af36e`. [Source-only native run `38671551374`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38671551374) on `b8bd8228` passes the normal build and all five check targets. ABI, loader, @@ -58,7 +84,7 @@ The normal build's v3 manifest binds the six wrapper sources, exact upstream tree and library SHA-256 `3e59444d6f81c753ab1fcd3418ebabfb42f0368b24c76961ee59ddaf339617fa`. Native sources, Python contracts and workflow are byte-identical between -`b8bd8228` and `030c16f0`. Original normal artifact SHA-256: +`b8bd8228` and `9d044921`. Original normal artifact SHA-256: `40ecaab5262d043a25b3d30d52a641f8406130103741e00d6857eca925201163`; sanitizer artifact: `3c948ae71d8c54870fe1a151b95d699556aa0907f9ccd17b5a9d3d18d71b5272`; @@ -79,10 +105,11 @@ check passed in this attempt. Original job-log ZIP SHA-256: - [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the existing isolated private-conversation worker. The source-only build of pinned llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. Actual conversion - and provisioning now pass, but the latest trial ends at `compute_deadline` - during post-generation verification: **no usable native turn or successful - OpenCode coding task is proved**. The earlier `invalid_output` result and all - original failed trials remain in the evidence below. + and provisioning pass. The historical unselected-output trials include + `compute_deadline` during post-generation verification and `invalid_output`; + neither proves a usable native turn or successful OpenCode coding task. The + selected-output follow-up above now proves one model-driven read action, + still not a complete coding loop. All original failed trials remain below. This first backend accepts only the original Qwen3-4B-Instruct-2507 profile and explicitly negotiated `greedy_v1`; public jobs, training and other profiles retain their existing backend. It does not establish confidential peer execution. From cc14560463762b3ec471e1cf12fae24caea2d738 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:52:38 +0200 Subject: [PATCH 15/39] fix(compute): preserve closed private task boundary failures --- .../volparossa/src/compute/private_serve.rs | 14 +- .../src/compute/private_serve/tests.rs | 1 + .../private_serve/tests/task_diagnostic.rs | 162 ++++++++++++++++++ .../src/compute/supervise/diagnostic.rs | 45 +++++ docs/IMPLEMENTATION_STATUS.md | 16 ++ .../integration/agent-private-conversation.py | 11 ++ .../test-agent-private-conversation.py | 25 ++- 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index 8bb82cc48..c9d81b74b 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -190,8 +190,7 @@ impl Active { let (activity, signal) = watch::channel(true); let execution = tokio::spawn(async move { let result = private_task::execute_mode(&config, input, signal, mode).await; - slot.finish(&result); - result + finish_execution(&mut slot, result) }); Self { id, @@ -220,6 +219,17 @@ impl Active { } } +fn finish_execution(slot: &mut ExecutionSlot, result: Result) -> Result { + slot.finish(&result); + if let Err(error) = &result { + // Cover failures before worker supervision and after report validation. + // The existing opt-in target emits only closed facts, never error text. + // This observation grants neither cleanup nor a different wire response. + super::supervise::diagnostic::failure("task", error); + } + result +} + impl Drop for Active { fn drop(&mut self) { // Dropping a connection never aborts/detaches ownership of the worker slot: diff --git a/crates/volparossa/src/compute/private_serve/tests.rs b/crates/volparossa/src/compute/private_serve/tests.rs index bdc9f14aa..f87a64977 100644 --- a/crates/volparossa/src/compute/private_serve/tests.rs +++ b/crates/volparossa/src/compute/private_serve/tests.rs @@ -5,6 +5,7 @@ use super::*; use tokio::io::AsyncWriteExt as _; mod output_constraint; +mod task_diagnostic; const FIRST: &str = "01010101010101010101010101010101"; const SECOND: &str = "02020202020202020202020202020202"; diff --git a/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs new file mode 100644 index 000000000..4450ea942 --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs @@ -0,0 +1,162 @@ +//! Closed service-boundary observations, not actual model or sandbox evidence. + +use super::*; + +const CHILD: &str = "VOLPAROSSA_TASK_DIAGNOSTIC_CHILD"; +const INPUT: &[u8] = br#"{ + "version":1,"visibility":"private_local","question":"Hello?","context":"PRIVATE_TASK_CANARY" +}"#; + +fn result_binding_failure() -> anyhow::Error { + // The real validator rejects this synthetic worker report. No model runs. + private_task::validate_mode_report( + &json!({"status":"ok","outputs":[{"text":"PRIVATE_TASK_CANARY"}]}), + INPUT, + ModelProfile::Smol360, + super::super::super::Mode::PrivateInfer, + ) + .unwrap_err() +} + +const PARSER_CHECK: &str = r" +import json, runpy, sys +from pathlib import Path +fixture = runpy.run_path(sys.argv[1]) +value = fixture['service_diagnostic'](Path(sys.argv[2])) +codes = ['compute_private_budget', 'compute_private_result_scope', 'unclassified'] if sys.argv[3] == '1' else [] +assert value == dict(version=1, truncated=False, unrecognized_record=False, events=[dict( + version=1, phase='task', detail=dict(code=code, io_kind='none', exit_code=None, + signal=None, stderr_class=None)) for code in codes]) +assert 'PRIVATE_TASK_CANARY' not in json.dumps(value) +"; + +#[test] +fn service_task_failures_cover_pre_supervisor_and_result_binding_only_when_opted_in() { + if std::env::var_os(CHILD).is_some() { + let subscriber = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap()) + .with_ansi(false) + .with_target(false) + .finish(); + let _subscriber = tracing::subscriber::set_default(subscriber); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let root = tempfile::tempdir().unwrap(); + let mut config = config(root.path()); + config.threads = 0; + let gate = Arc::new(Semaphore::new(1)); + let slot = ExecutionSlot::admit(&gate).unwrap(); + let mut task = Active::start(FIRST.into(), Arc::new(config), INPUT.to_vec(), slot); + let result = task.reap().await; + assert_eq!( + result.as_ref().unwrap_err().to_string(), + "compute_private_budget" + ); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "execution_failed") + ); + assert_eq!(gate.available_permits(), 1); + assert!(!root.path().join("work").exists()); + + for error in [ + result_binding_failure(), + anyhow::anyhow!("PRIVATE_TASK_CANARY /private/task"), + ] { + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(error)); + assert!(slot.cleanup_confirmed); + drop(slot); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "execution_failed") + ); + } + }); + return; + } + for enabled in [false, true] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "compute::private_serve::tests::task_diagnostic::service_task_failures_cover_pre_supervisor_and_result_binding_only_when_opted_in", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, "1") + .env( + "RUST_LOG", + if enabled { + "off,volparossa::compute::private_diagnostic=debug" + } else { + "off" + }, + ) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let text = std::str::from_utf8(&output.stdout).unwrap(); + assert!(!text.contains("PRIVATE_TASK_CANARY") && !text.contains("/private/task")); + let mut log = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut log, &output.stdout).unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let parsed = std::process::Command::new("python3") + .args(["-B", "-c", PARSER_CHECK]) + .arg(fixture) + .arg(log.path()) + .arg(if enabled { "1" } else { "0" }) + .output() + .unwrap(); + assert!(parsed.status.success()); + assert!(parsed.stdout.is_empty() && parsed.stderr.is_empty()); + } +} + +#[test] +fn task_diagnostic_cannot_override_cleanup_cancel_or_success() { + let (activity, _signal) = watch::channel(true); + let mut task = Active { + id: FIRST.into(), + activity, + execution: None, + cancelled: false, + budget_error: true, + }; + for cancelled in [false, true] { + task.cancelled = cancelled; + let gate = Arc::new(Semaphore::new(1)); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(result_binding_failure())); + drop(slot); + let expected = if cancelled { + "cancelled" + } else { + "execution_failed" + }; + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), expected) + ); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(private_task::CleanupUnconfirmed.into())); + assert!(!slot.cleanup_confirmed); + drop(slot); + assert!(gate.is_closed()); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "cleanup_unconfirmed") + ); + } + task.cancelled = false; + let gate = Arc::new(Semaphore::new(1)); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let answer = json!({"synthetic":"unchanged"}); + let result = finish_execution(&mut slot, Ok(answer.clone())); + drop(slot); + assert_eq!(terminal_response(&task, Ok(result), &gate)["result"], answer); +} diff --git a/crates/volparossa/src/compute/supervise/diagnostic.rs b/crates/volparossa/src/compute/supervise/diagnostic.rs index 6e56daeb6..358fb11b0 100644 --- a/crates/volparossa/src/compute/supervise/diagnostic.rs +++ b/crates/volparossa/src/compute/supervise/diagnostic.rs @@ -9,6 +9,38 @@ use std::sync::Mutex; mod generation; const FIXED_CODES: &[&str] = &[ + // Private-service setup/result boundaries outside the worker supervisor. + // Exact equality only: prefixes and path-bearing messages remain unknown. + "compute_private_budget", + "compute_model_memory_headroom", + "compute_runtime_busy", + "compute_lock_invalid", + "compute_output_create", + "compute_sandbox_spawn", + "compute_sandbox_prerequisite", + "compute_runtime_missing", + "compute_native_backend_authorization", + "compute_native_backend_manifest_hash", + "compute_native_backend_manifest_schema", + "compute_native_backend_identity", + "compute_native_backend_artifacts", + "compute_native_backend_conversion", + "compute_native_backend_file", + "compute_native_backend_scope", + "compute_native_backend_policy", + "compute_native_backend_output_constraint", + "compute_private_result_scope", + "compute_private_result_input", + "compute_private_result_model", + "compute_private_result_model_weights", + "compute_private_result_outputs", + "compute_private_result_output", + "compute_native_backend_result_binding", + "compute_native_backend_result_precision", + "compute_native_backend_output_constraint_binding", + "conversation_generation_policy_binding", + "conversation_output_constraint_binding", + "conversation_report_binding", "compute_deadline", "compute_owner_busy", "compute_memory_budget", @@ -566,6 +598,19 @@ assert result == dict(version=1, truncated=False, unrecognized_record=False, sta assert!(result.stdout.is_empty() && result.stderr.is_empty()); } + #[test] + fn fixed_task_codes_require_exact_equality_and_do_not_disclose_context() { + for code in FIXED_CODES { + let error = anyhow::anyhow!("PRIVATE_TASK_CANARY /private/input").context(*code); + let detail = describe(&error); + assert_eq!(detail.code, *code); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("PRIVATE_TASK_CANARY") && !raw.contains("/private/input")); + let altered = anyhow::anyhow!("{code} PRIVATE_TASK_CANARY"); + assert_eq!(describe(&altered).code, "unclassified"); + } + } + #[test] fn diagnostics_keep_operation_and_errno_but_never_arbitrary_error_text() { let error = Error::new(std::io::Error::new( diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2d9c106b9..89bae21ba 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -70,6 +70,22 @@ host-state evidence. Raw model output remains excluded. Original artifact original job-log ZIP SHA-256: `5ff8a4055bf966f192f80b366cbeb8428d77793c6ee6525b3109eecf292af36e`. +The next diagnostic candidate observes failed private-service tasks after the +existing cleanup-slot decision, including errors before worker supervision or +after report validation. It uses the existing opt-in local diagnostic target +and an exact closed list of setup/result-binding codes. Unknown errors remain +unclassified; raw errors, paths, prompts, request identities and model output +are not exported. Wire responses, cleanup/cancellation precedence, retry policy, +model, resources and acceptance are unchanged. This is not a fix or a causal +explanation for the six observed failures. + +The core's closed collector passes 23 Python checks; Code passes 25 smoke +contracts and already uses that collector from its pinned core. Three new Rust +tests cover real pre-supervisor admission failure, report rejection, logging +enabled/disabled, privacy canaries and unchanged cleanup/cancellation behavior; +hosted execution remains pending. No actual trial has been repeated on this +diagnostic candidate. + [Source-only native run `38671551374`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38671551374) on `b8bd8228` passes the normal build and all five check targets. ABI, loader, BF16/F32 and the new output-constraint tests also pass under ASan/UBSan. The diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index 53b91e541..dbec4e177 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -52,6 +52,17 @@ 'not_connected', 'probe_failed'} SERVICE_PHASES = {'execution', 'capture', 'refresh', 'lifetime', 'reap', 'storage', 'task'} SERVICE_CODES = { + 'compute_private_budget', 'compute_model_memory_headroom', 'compute_runtime_busy', 'compute_lock_invalid', + 'compute_output_create', 'compute_sandbox_spawn', 'compute_sandbox_prerequisite', 'compute_runtime_missing', + 'compute_native_backend_authorization', 'compute_native_backend_manifest_hash', + 'compute_native_backend_manifest_schema', 'compute_native_backend_identity', + 'compute_native_backend_artifacts', 'compute_native_backend_conversion', 'compute_native_backend_file', + 'compute_native_backend_scope', 'compute_native_backend_policy', 'compute_native_backend_output_constraint', + 'compute_private_result_scope', 'compute_private_result_input', 'compute_private_result_model', + 'compute_private_result_model_weights', 'compute_private_result_outputs', 'compute_private_result_output', + 'compute_native_backend_result_binding', 'compute_native_backend_result_precision', + 'compute_native_backend_output_constraint_binding', 'conversation_generation_policy_binding', + 'conversation_output_constraint_binding', 'conversation_report_binding', 'unclassified', 'worker_other', 'startup_missing_result', 'result_observed', 'panic', 'join_cancelled', 'compute_deadline', 'compute_owner_busy', 'compute_memory_budget', 'compute_storage_budget', 'compute_process_bound', 'compute_thread_bound', 'compute_process_observation', 'compute_observation_size', diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index f5397d44b..8a7f4e3a3 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -221,7 +221,7 @@ def test_service_diagnostic_is_bounded_and_requires_owner_only_regular_file(self FIX['service_diagnostic'](link) def test_native_output_subtypes_are_fixed_local_observations_not_raw_errors(self): - codes = {code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_')} + codes = {code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_') and not code.endswith('_binding')} self.assertEqual(len(codes), 15) events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] @@ -237,6 +237,29 @@ def test_native_output_subtypes_are_fixed_local_observations_not_raw_errors(self self.assertFalse(result['truncated']) self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_task_setup_and_report_failures_remain_closed_and_bounded(self): + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + prefix = source.split('const FIXED_CODES: &[&str] = &[', 1)[1].split('"compute_deadline"', 1)[0] + codes = re.findall(r'"([A-Za-z_]+)"', prefix) + self.assertEqual(len(codes), 30) + self.assertIn('compute_model_memory_headroom', codes) + self.assertIn('compute_private_result_scope', codes) + with tempfile.TemporaryDirectory(prefix='qwen-fixture-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + for start in range(0, len(codes), 15): + events = [dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in codes[start:start + 15]] + invalid = dict(events[0], detail=dict(events[0]['detail'], + code=events[0]['detail']['code'] + ' PRIVATE_CANARY')) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(value) + '\n' + for value in [*events, invalid])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(self): source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() expected = {'unclassified', 'worker_other', 'startup_missing_result', 'result_observed', 'panic', 'join_cancelled'} From 5607f1177dc230a71414e7468c221b950f43bca6 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:56:08 +0200 Subject: [PATCH 16/39] style(compute): apply hosted task diagnostic formatting --- .../src/compute/private_serve/tests/task_diagnostic.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs index 4450ea942..6d5d54320 100644 --- a/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs +++ b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs @@ -158,5 +158,8 @@ fn task_diagnostic_cannot_override_cleanup_cancel_or_success() { let answer = json!({"synthetic":"unchanged"}); let result = finish_execution(&mut slot, Ok(answer.clone())); drop(slot); - assert_eq!(terminal_response(&task, Ok(result), &gate)["result"], answer); + assert_eq!( + terminal_response(&task, Ok(result), &gate)["result"], + answer + ); } From 11bffbb623ac0009d1fa4ce2ba40b26692e96064 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:27:22 +0200 Subject: [PATCH 17/39] docs(compute): record verified task diagnostics and exact native retry --- docs/IMPLEMENTATION_STATUS.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 89bae21ba..1f14751c1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -79,12 +79,32 @@ are not exported. Wire responses, cleanup/cancellation precedence, retry policy, model, resources and acceptance are unchanged. This is not a fix or a causal explanation for the six observed failures. -The core's closed collector passes 23 Python checks; Code passes 25 smoke -contracts and already uses that collector from its pinned core. Three new Rust -tests cover real pre-supervisor admission failure, report rejection, logging -enabled/disabled, privacy canaries and unchanged cleanup/cancellation behavior; -hosted execution remains pending. No actual trial has been repeated on this -diagnostic candidate. +The core's closed collector passes 23 Python checks. Three new Rust tests cover +real pre-supervisor admission failure, report rejection, logging enabled/disabled, +privacy canaries and unchanged cleanup/cancellation behavior. All three pass in +[Quality `38678859785`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38678859785) +on the direct checkout `5607f1177dc230a71414e7468c221b950f43bca6`, together with +all thirteen output-constraint Rust cases, 60 native Python contracts, formatting, +strict Clippy, the workspace (3,561 distinct passes, seven ignored) and non-mutating +harness. Skipped namespace proofs remain skips, not datapath evidence. Original +job-log ZIP SHA-256: +`60e43c4a0a63a250b4af601245d9e41772135149e47663a730a511866347d1b9`. +Earlier run `38678595442` stopped at one test assertion's formatting; `5607f117` +corrects only that layout. Its skipped Rust steps are not passing evidence. + +Code `e39267343612ed8b594d15db3e161465c25b8c2f` now pins this verified diagnostic +source without changing its model, resource or acceptance settings. The pin +change passes 103 targeted Python checks with two explicit retained-source +skips. [Code source CI `38681259931`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38681259931) +also passes: 405 checks and four explicit skips, including a missing-runner-bwrap +synthetic namespace check. All 114 source blobs in merge checkout `3580a655` +match the candidate. Original log ZIP SHA-256: +`0b9d42a5f0526ada26cf56166b2698d48b83510a3d6ffb5d5950a113d3b9e93c`. + +One [actual follow-up `38681471469`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38681471469) +is dispatched against that exact Code/core pair and the same explicit Qwen4B +native profile. Its result is pending; the completed partial-read failure above +remains the latest actual outcome, not a successful coding loop. [Source-only native run `38671551374`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38671551374) on `b8bd8228` passes the normal build and all five check targets. ABI, loader, From 714ddb63096f71ae0152db16ec38a01494023a08 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:11:49 +0200 Subject: [PATCH 18/39] fix(compute): advise unused native model cache after confirmed cleanup --- .github/workflows/quality.yml | 5 + crates/volparossa/src/compute.rs | 11 +- crates/volparossa/src/compute/native_cpu.rs | 15 + .../src/compute/native_cpu/cache_hint.rs | 135 +++++++++ .../compute/native_cpu/cache_hint/tests.rs | 281 ++++++++++++++++++ .../src/compute/native_cpu/tests.rs | 2 +- 6 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 crates/volparossa/src/compute/native_cpu/cache_hint.rs create mode 100644 crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index ae8d18e33..0ef263077 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -80,6 +80,11 @@ jobs: working-directory: workers/volparossa-ml run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint + - name: Check native CPU cache hints and unchanged admission without loading models + run: | + cargo test --locked -p volparossa compute::native_cpu + cargo test --locked -p volparossa compute::resources::tests + - name: Install pinned dependency-audit tools run: | tool_root="${RUNNER_TEMP}/cargo-tools" diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index 04a5afd28..7b7363daa 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -273,12 +273,21 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result Option { + if profile != ModelProfile::Qwen4bInstruct2507 + || self.inference_backend != Some(KIND) + || self.native_backend_sha256 != options.native_backend_sha256 + { + return None; + } + let root = options.native_backend_root.as_ref()?; + let bytes = self.expected_report.as_ref()?.get("gguf_bytes")?.as_u64()?; + CacheHint::bind(root, bytes) + } + fn select_constraint(&mut self, input: &super::private_conversation::Input) -> Result<()> { if let Some(constraint) = input.output_constraint() { ensure!( diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs new file mode 100644 index 000000000..05a4aad98 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -0,0 +1,135 @@ +//! Best-effort cache advice for one owner-authorized, unchanged native GGUF. +//! This is not memory admission, guaranteed reclaim, or evidence of a cache cause. + +use std::{ + fs::{self, File, Metadata, OpenOptions}, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; + +use super::super::{ModelProfile, private_directory, resources, spare_capacity}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Identity { + device: u64, + inode: u64, + owner: u32, + links: u64, + mode: u32, + bytes: u64, + modified: (i64, i64), + changed: (i64, i64), +} + +impl Identity { + fn read(info: &Metadata) -> Self { + Self { + device: info.dev(), + inode: info.ino(), + owner: info.uid(), + links: info.nlink(), + mode: info.mode(), + bytes: info.len(), + modified: (info.mtime(), info.mtime_nsec()), + changed: (info.ctime(), info.ctime_nsec()), + } + } + + fn valid(self, expected_bytes: u64) -> bool { + self.mode & libc::S_IFMT == libc::S_IFREG + && self.owner == nix::unistd::geteuid().as_raw() + && self.links == 1 + && self.mode & 0o077 == 0 + && self.bytes == expected_bytes + && (1..=9 * 1024 * 1024 * 1024).contains(&self.bytes) + } +} + +pub(in crate::compute) struct CacheHint { + file: File, + root: PathBuf, + root_identity: (u64, u64), + identity: Identity, +} + +impl CacheHint { + // Called only from a request already bound to the owner's manifest digest. + pub(super) fn bind(root: &Path, expected_bytes: u64) -> Option { + private_directory(root).ok()?; + let directory = fs::symlink_metadata(root).ok()?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(root.join("model.gguf")) + .ok()?; + let identity = Identity::read(&file.metadata().ok()?); + if !identity.valid(expected_bytes) { + return None; + } + let hint = Self { + file, + root: root.to_owned(), + root_identity: (directory.dev(), directory.ino()), + identity, + }; + hint.unchanged().then_some(hint) + } + + fn unchanged(&self) -> bool { + let check = || -> Option { + private_directory(&self.root).ok()?; + let directory = fs::symlink_metadata(&self.root).ok()?; + let descriptor = Identity::read(&self.file.metadata().ok()?); + let path = Identity::read(&fs::symlink_metadata(self.root.join("model.gguf")).ok()?); + Some( + (directory.dev(), directory.ino()) == self.root_identity + && descriptor.valid(self.identity.bytes) + && descriptor == self.identity + && path == self.identity, + ) + }; + check() == Some(true) + } + + pub(in crate::compute) fn after_supervisor(self, result: &Result, profile: ModelProfile) { + self.after_supervisor_with( + result, + profile, + spare_capacity::memory_headroom, + |file, bytes| posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED), + ); + } + + fn after_supervisor_with( + self, + result: &Result, + profile: ModelProfile, + headroom: impl FnOnce() -> Option, + advise: impl FnOnce(&File, libc::off_t) -> nix::Result<()>, + ) { + if result.is_err() || !needs_hint(profile, headroom()) || !self.unchanged() { + return; + } + if let Ok(bytes) = libc::off_t::try_from(self.identity.bytes) { + // One bounded file range, no retries, reads, rehash, or global reclaim. + // Advisory success does not prove freed memory. The syscall may take + // time; keep it synchronous under the existing runtime lease. + let _ = advise(&self.file, bytes); + } + } +} + +fn needs_hint(profile: ModelProfile, headroom: Option) -> bool { + profile == ModelProfile::Qwen4bInstruct2507 + && headroom.is_some_and(|available| { + resources::limits(profile) + .admission_headroom + .is_some_and(|required| available < required) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs new file mode 100644 index 000000000..fd80725a8 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -0,0 +1,281 @@ +//! Tiny inert files and injected advice only; no model, native code, or cache syscall. + +use std::{ + cell::Cell, + os::unix::fs::{PermissionsExt as _, symlink}, + time::{Duration, SystemTime}, +}; + +use nix::fcntl::{FcntlArg, fcntl}; + +use super::super::{Options, tests::fixture}; +use super::*; + +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn bound(options: &Options) -> CacheHint { + options + .request(PROFILE) + .unwrap() + .cache_hint(options, PROFILE) + .unwrap() +} + +#[test] +fn hint_requires_known_shortage_without_changing_admission() { + let required = 10 * 1024 * 1024 * 1024 + 512 * 1024 * 1024; + assert_eq!(resources::limits(PROFILE).admission_headroom, Some(required)); + for (available, hint, admitted) in [ + (None, false, false), + (Some(0), true, false), + (Some(required - 1), true, false), + (Some(required), false, true), + (Some(required + 1), false, true), + ] { + assert_eq!(needs_hint(PROFILE, available), hint); + assert_eq!(resources::admits(PROFILE, available), admitted); + } + for profile in [ + ModelProfile::Default135, + ModelProfile::Smol360, + ModelProfile::Smol1700, + ModelProfile::Qwen600, + ] { + assert!(!needs_hint(profile, Some(0))); + } +} + +#[test] +fn hint_binding_requires_the_same_owner_authorized_native_request() { + let (_root, mut options) = fixture(); + let request = options.request(PROFILE).unwrap(); + assert!(request.cache_hint(&options, PROFILE).is_some()); + assert!(request.cache_hint(&options, ModelProfile::Qwen600).is_none()); + assert!(request.cache_hint(&Options::default(), PROFILE).is_none()); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(request.cache_hint(&options, PROFILE).is_none()); + assert!(options.request(PROFILE).is_err()); + assert!( + Options::default() + .request(PROFILE) + .unwrap() + .cache_hint(&options, PROFILE) + .is_none() + ); +} + +#[test] +fn bound_descriptor_is_read_only_cloexec_and_matches_exact_file() { + let (_root, options) = fixture(); + let hint = bound(&options); + assert!(hint.unchanged()); + assert_eq!(hint.identity.bytes, 1); + assert_ne!( + fcntl(&hint.file, FcntlArg::F_GETFD).unwrap() & libc::FD_CLOEXEC, + 0 + ); + assert_eq!( + fcntl(&hint.file, FcntlArg::F_GETFL).unwrap() & libc::O_ACCMODE, + libc::O_RDONLY + ); +} + +#[test] +fn file_identity_requires_regular_private_single_link_owned_exact_size() { + let (_root, options) = fixture(); + let identity = bound(&options).identity; + assert!(identity.valid(1)); + assert!(!identity.valid(2)); + for changed in [ + Identity { + mode: libc::S_IFDIR | 0o600, + ..identity + }, + Identity { + mode: identity.mode | 0o004, + ..identity + }, + Identity { + owner: identity.owner.wrapping_add(1), + ..identity + }, + Identity { + links: 2, + ..identity + }, + Identity { + bytes: 0, + ..identity + }, + Identity { + bytes: 10 * 1024 * 1024 * 1024, + ..identity + }, + ] { + assert!(!changed.valid(changed.bytes)); + } + for changed in [ + Identity { + device: identity.device.wrapping_add(1), + ..identity + }, + Identity { + inode: identity.inode.wrapping_add(1), + ..identity + }, + Identity { + modified: (identity.modified.0 + 1, 0), + ..identity + }, + Identity { + changed: (identity.changed.0 + 1, 0), + ..identity + }, + ] { + assert_ne!(changed, identity); + } +} + +#[test] +fn optional_binding_rejects_symlink_hardlink_nonregular_and_unprivate_parent() { + let (root, _options) = fixture(); + let model = root.path().join("model.gguf"); + fs::rename(&model, root.path().join("original")).unwrap(); + symlink("original", &model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_file(&model).unwrap(); + fs::hard_link(root.path().join("original"), &model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_file(&model).unwrap(); + fs::create_dir(&model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_dir(&model).unwrap(); + fs::rename(root.path().join("original"), &model).unwrap(); + assert!(CacheHint::bind(root.path(), 2).is_none()); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); +} + +#[test] +fn replaced_same_size_file_is_never_advised() { + let (root, options) = fixture(); + let hint = bound(&options); + let model = root.path().join("model.gguf"); + fs::rename(&model, root.path().join("original")).unwrap(); + fs::write(&model, b"x").unwrap(); + fs::set_permissions(&model, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(!hint.unchanged()); + hint.after_supervisor_with( + &Ok(()), + PROFILE, + || Some(0), + |_, _| panic!("replacement"), + ); +} + +#[test] +fn changed_metadata_and_parent_identity_are_never_advised() { + for change in 0..4 { + let (root, options) = fixture(); + let hint = bound(&options); + let model = root.path().join("model.gguf"); + match change { + 0 => fs::set_permissions(&model, fs::Permissions::from_mode(0o400)).unwrap(), + 1 => fs::write(&model, b"xx").unwrap(), + 2 => hint + .file + .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + .unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + assert!(!hint.unchanged()); + hint.after_supervisor_with( + &Ok(()), + PROFILE, + || Some(0), + |_, _| panic!("changed"), + ); + } + let (_root, options) = fixture(); + let mut hint = bound(&options); + hint.root_identity.1 = hint.root_identity.1.wrapping_add(1); + assert!(!hint.unchanged()); +} + +#[test] +fn successful_supervisor_allows_one_exact_range_hint_without_reading_model() { + let (_root, options) = fixture(); + let hint = bound(&options); + let expected = hint.identity; + let calls = Cell::new(0); + let result = Ok("original result"); + hint.after_supervisor_with( + &result, + PROFILE, + || Some(0), + |file, bytes| { + calls.set(calls.get() + 1); + assert_eq!(bytes, 1); + assert_eq!(Identity::read(&file.metadata().unwrap()), expected); + Ok(()) + }, + ); + assert_eq!(calls.get(), 1); + assert_eq!(result.unwrap(), "original result"); +} + +#[test] +fn hint_failure_does_not_change_success_or_claim_reclaimed_memory() { + let (_root, options) = fixture(); + let result = Ok("original result"); + bound(&options).after_supervisor_with( + &result, + PROFILE, + || Some(0), + |_, _| Err(nix::errno::Errno::EIO), + ); + assert_eq!(result.unwrap(), "original result"); +} + +#[test] +fn errors_including_unconfirmed_cleanup_never_sample_or_hint() { + let (_root, options) = fixture(); + for error in [ + anyhow::Error::new(crate::compute::private_task::CleanupUnconfirmed), + anyhow::anyhow!("compute_cancelled"), + anyhow::anyhow!("PRIVATE_ERROR_CANARY /private/path"), + ] { + let original = error.to_string(); + let uncertain_cleanup = error.is::(); + let result: Result<()> = Err(error); + bound(&options).after_supervisor_with( + &result, + PROFILE, + || panic!("error sample"), + |_, _| panic!("error hint"), + ); + let error = result.unwrap_err(); + assert_eq!(error.to_string(), original); + assert_eq!( + error.is::(), + uncertain_cleanup + ); + } +} + +#[test] +fn missing_or_sufficient_headroom_never_hints() { + let (_root, options) = fixture(); + for available in [ + None, + resources::limits(PROFILE).admission_headroom, + Some(u64::MAX), + ] { + bound(&options).after_supervisor_with( + &Ok(()), + PROFILE, + || available, + |_, _| panic!("no measured shortage"), + ); + } +} diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs index a80dda70c..56a91c640 100644 --- a/crates/volparossa/src/compute/native_cpu/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -126,7 +126,7 @@ fn manifest() -> Value { "chat_template_sha256":"c".repeat(64)},"build_manifest_sha256":"d".repeat(64)}) } -fn fixture() -> (tempfile::TempDir, Options) { +pub(super) fn fixture() -> (tempfile::TempDir, Options) { let root = tempfile::tempdir().unwrap(); fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); let raw = serde_json::to_vec(&manifest()).unwrap(); From 51e9c5fb8c9591778558d19ceeada57553b6dd3a Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:21:19 +0200 Subject: [PATCH 19/39] style(compute): apply hosted cache-hint test formatting --- .../compute/native_cpu/cache_hint/tests.rs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs index fd80725a8..f988d8ba4 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -24,7 +24,10 @@ fn bound(options: &Options) -> CacheHint { #[test] fn hint_requires_known_shortage_without_changing_admission() { let required = 10 * 1024 * 1024 * 1024 + 512 * 1024 * 1024; - assert_eq!(resources::limits(PROFILE).admission_headroom, Some(required)); + assert_eq!( + resources::limits(PROFILE).admission_headroom, + Some(required) + ); for (available, hint, admitted) in [ (None, false, false), (Some(0), true, false), @@ -50,7 +53,11 @@ fn hint_binding_requires_the_same_owner_authorized_native_request() { let (_root, mut options) = fixture(); let request = options.request(PROFILE).unwrap(); assert!(request.cache_hint(&options, PROFILE).is_some()); - assert!(request.cache_hint(&options, ModelProfile::Qwen600).is_none()); + assert!( + request + .cache_hint(&options, ModelProfile::Qwen600) + .is_none() + ); assert!(request.cache_hint(&Options::default(), PROFILE).is_none()); options.native_backend_sha256 = Some("0".repeat(64)); assert!(request.cache_hint(&options, PROFILE).is_none()); @@ -165,12 +172,7 @@ fn replaced_same_size_file_is_never_advised() { fs::write(&model, b"x").unwrap(); fs::set_permissions(&model, fs::Permissions::from_mode(0o600)).unwrap(); assert!(!hint.unchanged()); - hint.after_supervisor_with( - &Ok(()), - PROFILE, - || Some(0), - |_, _| panic!("replacement"), - ); + hint.after_supervisor_with(&Ok(()), PROFILE, || Some(0), |_, _| panic!("replacement")); } #[test] @@ -189,12 +191,7 @@ fn changed_metadata_and_parent_identity_are_never_advised() { _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), } assert!(!hint.unchanged()); - hint.after_supervisor_with( - &Ok(()), - PROFILE, - || Some(0), - |_, _| panic!("changed"), - ); + hint.after_supervisor_with(&Ok(()), PROFILE, || Some(0), |_, _| panic!("changed")); } let (_root, options) = fixture(); let mut hint = bound(&options); From 3630ff7905f401f26245f2e6cf4f0f01d7950549 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:40:56 +0200 Subject: [PATCH 20/39] fix(compute): satisfy cache hint Clippy checks without changing behavior --- crates/volparossa/src/compute/native_cpu/cache_hint.rs | 2 +- .../volparossa/src/compute/native_cpu/cache_hint/tests.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs index 05a4aad98..b7b9dc9ec 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -42,7 +42,7 @@ impl Identity { self.mode & libc::S_IFMT == libc::S_IFREG && self.owner == nix::unistd::geteuid().as_raw() && self.links == 1 - && self.mode & 0o077 == 0 + && self.mode.trailing_zeros() >= 6 && self.bytes == expected_bytes && (1..=9 * 1024 * 1024 * 1024).contains(&self.bytes) } diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs index f988d8ba4..5b8c84796 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -218,7 +218,7 @@ fn successful_supervisor_allows_one_exact_range_hint_without_reading_model() { }, ); assert_eq!(calls.get(), 1); - assert_eq!(result.unwrap(), "original result"); + assert!(matches!(result, Ok("original result"))); } #[test] @@ -231,7 +231,7 @@ fn hint_failure_does_not_change_success_or_claim_reclaimed_memory() { || Some(0), |_, _| Err(nix::errno::Errno::EIO), ); - assert_eq!(result.unwrap(), "original result"); + assert!(matches!(result, Ok("original result"))); } #[test] @@ -251,7 +251,9 @@ fn errors_including_unconfirmed_cleanup_never_sample_or_hint() { || panic!("error sample"), |_, _| panic!("error hint"), ); - let error = result.unwrap_err(); + let Err(error) = &result else { + panic!("original error replaced"); + }; assert_eq!(error.to_string(), original); assert_eq!( error.is::(), From a2216ec9a4f56e382b3a58b0bad6c8ed2e322c07 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:56:43 +0200 Subject: [PATCH 21/39] feat: observe native memory admission without changing guard --- .github/workflows/quality.yml | 35 +- crates/volparossa/src/compute.rs | 15 +- crates/volparossa/src/compute/native_cpu.rs | 30 +- .../src/compute/native_cpu/cache_hint.rs | 140 +++++-- .../compute/native_cpu/cache_hint/tests.rs | 204 +++++++++++ crates/volparossa/src/compute/resources.rs | 126 ++++++- .../volparossa/src/compute/spare_capacity.rs | 48 +-- .../src/compute/spare_capacity/memory.rs | 315 ++++++++++++++++ .../compute/spare_capacity/memory/tests.rs | 341 ++++++++++++++++++ .../integration/agent-private-conversation.py | 130 ++++++- .../test-agent-private-conversation.py | 175 +++++++++ 11 files changed, 1473 insertions(+), 86 deletions(-) create mode 100644 crates/volparossa/src/compute/spare_capacity/memory.rs create mode 100644 crates/volparossa/src/compute/spare_capacity/memory/tests.rs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 816a12c59..19dba916b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -90,11 +90,42 @@ jobs: python3 -B tests/integration/test-weather-client-runner.py - name: Check formatting and shell sources + id: formatting run: | python3 -B scripts/test-diagnose-backport-lock.py - cargo fmt --all -- --check + if cargo fmt --all -- --check; then + : + else + format_status=$? + # Temporary source-only aid for this candidate; the gate still fails. + # No Rust formatter or compiler runs on the development host. + if test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa && \ + test "$RUNNER_ENVIRONMENT" = github-hosted && \ + test "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" = feature/native-cpu-inference; then + format_dir="$RUNNER_TEMP/native-memory-format" + test ! -e "$format_dir" + mkdir -m 0700 "$format_dir" + git rev-parse HEAD > "$format_dir/source.txt" + cargo fmt --all + git diff --no-ext-diff --binary -- crates/volparossa/src/compute.rs \ + crates/volparossa/src/compute/ > "$format_dir/format.patch" + printf 'native_memory_format=true\n' >> "$GITHUB_OUTPUT" + fi + exit "$format_status" + fi ./scripts/check-shell.sh + - name: Preserve native memory candidate formatting diff + if: ${{ failure() && steps.formatting.outputs.native_memory_format == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-memory-format-${{ github.run_id }} + path: | + ${{ runner.temp }}/native-memory-format/source.txt + ${{ runner.temp }}/native-memory-format/format.patch + if-no-files-found: error + retention-days: 7 + - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint @@ -103,6 +134,8 @@ jobs: run: | cargo test --locked -p volparossa compute::native_cpu cargo test --locked -p volparossa compute::resources::tests + cargo test --locked -p volparossa compute::spare_capacity::memory::tests + python3 -B tests/integration/test-agent-private-conversation.py - name: Check bounded bidirectional streaming before audit tool builds # In-memory duplex and real TLS only; no host sockets or namespace setup. diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index f4421ab2e..b11c7cbbd 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -239,7 +239,10 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result) -> Result Option { + self.cache_hint_observed(options, profile).hint + } + + pub(super) fn cache_hint_observed(&self, options: &Options, profile: ModelProfile) -> Binding { if profile != ModelProfile::Qwen4bInstruct2507 || self.inference_backend != Some(KIND) || self.native_backend_sha256 != options.native_backend_sha256 { - return None; + return Binding::unavailable("not_applicable"); + } + let Some(root) = options.native_backend_root.as_ref() else { + return Binding::unavailable("not_applicable"); + }; + let Some(bytes) = self + .expected_report + .as_ref() + .and_then(|report| report.get("gguf_bytes")) + .and_then(Value::as_u64) + else { + return Binding::unavailable("not_applicable"); + }; + match CacheHint::bind_observed(root, bytes) { + Ok(hint) => Binding { + hint: Some(hint), + reason: "bound", + }, + Err(reason) => Binding::unavailable(reason), } - let root = options.native_backend_root.as_ref()?; - let bytes = self.expected_report.as_ref()?.get("gguf_bytes")?.as_u64()?; - CacheHint::bind(root, bytes) } fn select_constraint(&mut self, input: &super::private_conversation::Input) -> Result<()> { diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs index b7b9dc9ec..73cc1d2a9 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -9,8 +9,9 @@ use std::{ use anyhow::Result; use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; +use serde_json::{Value, json}; -use super::super::{ModelProfile, private_directory, resources, spare_capacity}; +use super::super::{ModelProfile, private_directory, resources, spare_capacity::memory}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Identity { @@ -55,19 +56,111 @@ pub(in crate::compute) struct CacheHint { identity: Identity, } +pub(in crate::compute) struct Binding { + pub(super) hint: Option, + pub(super) reason: &'static str, +} + +impl Binding { + pub(super) fn unavailable(reason: &'static str) -> Self { + Self { hint: None, reason } + } + + pub(in crate::compute) fn after_supervisor( + self, + result: &Result, + profile: ModelProfile, + diagnostic: bool, + ) { + let observe = diagnostic && memory::enabled(); + let record = self.observe_with( + result, + profile, + observe, + memory::sample, + |file, bytes| posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED), + ); + if observe { + memory::emit(&record); + } + } + + fn observe_with( + self, + result: &Result, + profile: ModelProfile, + observe: bool, + mut sample: impl FnMut(bool) -> memory::Sample, + advise: impl FnOnce(&File, libc::off_t) -> nix::Result<()>, + ) -> Value { + let mut record = json!({"version":1,"phase":"cache_hint","binding":self.reason, + "outcome":"supervisor_error","supervisor_ok":result.is_ok(), + "before":null,"after":null,"syscall_error":null}); + if result.is_err() { + return record; + } + let Some(hint) = self.hint else { + record["outcome"] = json!("not_bound"); + return record; + }; + let before = sample(observe); + if observe { + record["before"] = before.snapshot(); + } + let outcome = if before.headroom.is_none() { + "headroom_unknown" + } else if !needs_hint(profile, before.headroom) { + "headroom_sufficient" + } else if !hint.unchanged() { + "identity_changed" + } else if let Ok(bytes) = libc::off_t::try_from(hint.identity.bytes) { + // Preserve one synchronous, unchanged-file advice call under the lease. + // The following observation is not proof that this hint freed memory. + let advice = advise(&hint.file, bytes); + if observe { + record["after"] = sample(true).snapshot(); + } + if let Err(error) = advice { + record["syscall_error"] = json!(match error { + nix::errno::Errno::EBADF => "bad_fd", + nix::errno::Errno::EINVAL => "invalid", + nix::errno::Errno::ENOSYS | nix::errno::Errno::EOPNOTSUPP => "unsupported", + nix::errno::Errno::EINTR => "interrupted", + _ => "other", + }); + "error" + } else { + "advised" + } + } else { + "range_invalid" + }; + record["outcome"] = json!(outcome); + record + } +} + impl CacheHint { // Called only from a request already bound to the owner's manifest digest. + #[cfg(test)] pub(super) fn bind(root: &Path, expected_bytes: u64) -> Option { - private_directory(root).ok()?; - let directory = fs::symlink_metadata(root).ok()?; + Self::bind_observed(root, expected_bytes).ok() + } + + pub(super) fn bind_observed( + root: &Path, + expected_bytes: u64, + ) -> std::result::Result { + private_directory(root).map_err(|_| "root_invalid")?; + let directory = fs::symlink_metadata(root).map_err(|_| "root_invalid")?; let file = OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) .open(root.join("model.gguf")) - .ok()?; - let identity = Identity::read(&file.metadata().ok()?); + .map_err(|_| "open_failed")?; + let identity = Identity::read(&file.metadata().map_err(|_| "file_invalid")?); if !identity.valid(expected_bytes) { - return None; + return Err("file_invalid"); } let hint = Self { file, @@ -75,7 +168,11 @@ impl CacheHint { root_identity: (directory.dev(), directory.ino()), identity, }; - hint.unchanged().then_some(hint) + if hint.unchanged() { + Ok(hint) + } else { + Err("path_changed") + } } fn unchanged(&self) -> bool { @@ -94,15 +191,7 @@ impl CacheHint { check() == Some(true) } - pub(in crate::compute) fn after_supervisor(self, result: &Result, profile: ModelProfile) { - self.after_supervisor_with( - result, - profile, - spare_capacity::memory_headroom, - |file, bytes| posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED), - ); - } - + #[cfg(test)] fn after_supervisor_with( self, result: &Result, @@ -110,15 +199,18 @@ impl CacheHint { headroom: impl FnOnce() -> Option, advise: impl FnOnce(&File, libc::off_t) -> nix::Result<()>, ) { - if result.is_err() || !needs_hint(profile, headroom()) || !self.unchanged() { - return; - } - if let Ok(bytes) = libc::off_t::try_from(self.identity.bytes) { - // One bounded file range, no retries, reads, rehash, or global reclaim. - // Advisory success does not prove freed memory. The syscall may take - // time; keep it synchronous under the existing runtime lease. - let _ = advise(&self.file, bytes); + let mut headroom = Some(headroom); + Binding { + hint: Some(self), + reason: "bound", } + .observe_with( + result, + profile, + false, + |_| memory::Sample::for_test(headroom.take().expect("one sample")()), + advise, + ); } } diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs index 5b8c84796..3a68064d7 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -278,3 +278,207 @@ fn missing_or_sufficient_headroom_never_hints() { ); } } + +fn observed(options: &Options) -> Binding { + options + .request(PROFILE) + .unwrap() + .cache_hint_observed(options, PROFILE) +} + +fn collect(records: &[Value]) { + use std::io::Write as _; + let mut log = tempfile::NamedTempFile::new().unwrap(); + for record in records { + let line = format!("private_memory_diagnostic {record}\n"); + assert!(line.len() <= 4096); + assert!(!line.contains("CANARY")); + log.write_all(line.as_bytes()).unwrap(); + } + let script = r" +import json, runpy, sys +from pathlib import Path +module = runpy.run_path(sys.argv[1]) +path = Path(sys.argv[2]) +original = [json.loads(line.split(' ', 1)[1]) for line in path.read_text().splitlines()] +legacy = module['service_diagnostic'](path) +assert legacy == dict(version=1, truncated=False, unrecognized_record=False, events=[]) +current = module['service_diagnostic'](path, memory_enabled=True) +assert current == dict(version=2, truncated=False, unrecognized_record=False, + events=[], memory=original) +"; + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let parsed = std::process::Command::new("python3") + .args(["-B", "-c", script]) + .arg(fixture) + .arg(log.path()) + .output() + .unwrap(); + assert!(parsed.status.success()); + assert!(parsed.stdout.is_empty() && parsed.stderr.is_empty()); +} + +#[test] +fn cache_observation_preserves_cleanup_and_cancel_priority_without_private_error_text() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for error in [ + anyhow::Error::new(crate::compute::private_task::CleanupUnconfirmed), + anyhow::anyhow!("compute_cancelled"), + anyhow::anyhow!("PRIVATE_CACHE_CANARY /private/path"), + ] { + let original = error.to_string(); + let result: Result<()> = Err(error); + let record = observed(&options).observe_with( + &result, + PROFILE, + true, + |_| panic!("sample before confirmed cleanup"), + |_, _| panic!("hint before confirmed cleanup"), + ); + assert_eq!(result.unwrap_err().to_string(), original); + assert_eq!(record["outcome"], "supervisor_error"); + assert_eq!(record["supervisor_ok"], false); + records.push(record); + } + collect(&records); +} + +#[test] +fn unavailable_hint_and_unknown_or_sufficient_headroom_remain_non_authoritative() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for reason in [ + "not_applicable", + "root_invalid", + "open_failed", + "file_invalid", + "path_changed", + ] { + let record = Binding::unavailable(reason).observe_with( + &Ok(()), + PROFILE, + true, + |_| panic!("unbound sample"), + |_, _| panic!("unbound advice"), + ); + assert_eq!(record["outcome"], "not_bound"); + records.push(record); + } + for (available, outcome) in [ + (None, "headroom_unknown"), + ( + resources::limits(PROFILE).admission_headroom, + "headroom_sufficient", + ), + (Some(u64::MAX), "headroom_sufficient"), + ] { + let calls = Cell::new(0); + let record = observed(&options).observe_with( + &Ok(()), + PROFILE, + true, + |stats| { + assert!(stats); + calls.set(calls.get() + 1); + memory::Sample::for_test(available) + }, + |_, _| panic!("unproven shortage"), + ); + assert_eq!(calls.get(), 1); + assert_eq!(record["outcome"], outcome); + records.push(record); + } + collect(&records); +} + +#[test] +fn advice_observation_reports_one_syscall_and_closed_errno_without_reclaim_claim() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for (advice, expected) in [ + (Ok(()), None), + (Err(nix::errno::Errno::EBADF), Some("bad_fd")), + (Err(nix::errno::Errno::EINVAL), Some("invalid")), + (Err(nix::errno::Errno::ENOSYS), Some("unsupported")), + (Err(nix::errno::Errno::EOPNOTSUPP), Some("unsupported")), + (Err(nix::errno::Errno::EINTR), Some("interrupted")), + (Err(nix::errno::Errno::EIO), Some("other")), + ] { + let samples = Cell::new(0); + let calls = Cell::new(0); + let original = Ok("original result"); + let record = observed(&options).observe_with( + &original, + PROFILE, + true, + |stats| { + assert!(stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(if samples.get() == 1 { + Some(0) + } else { + None + }) + }, + |_, bytes| { + assert_eq!(bytes, 1); + assert_eq!(samples.get(), 1); + calls.set(calls.get() + 1); + advice + }, + ); + assert_eq!(samples.get(), 2); + assert_eq!(calls.get(), 1); + assert!(matches!(original, Ok("original result"))); + assert_eq!( + record["outcome"], + if expected.is_some() { "error" } else { "advised" } + ); + assert_eq!(record["syscall_error"], json!(expected)); + assert_eq!(record["after"]["missing_stage"], "meminfo"); + records.push(record); + } + collect(&records); +} + +#[test] +fn changed_hint_identity_is_observed_without_advice_or_second_sample() { + let (root, options) = fixture(); + let binding = observed(&options); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(()), + PROFILE, + true, + |_| { + calls.set(calls.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| panic!("changed identity"), + ); + assert_eq!(record["outcome"], "identity_changed"); + assert_eq!(calls.get(), 1); + collect(&[record]); +} + +#[test] +fn opt_out_does_not_read_stats_or_add_an_after_hint_sample() { + let (_root, options) = fixture(); + let samples = Cell::new(0); + let record = observed(&options).observe_with( + &Ok(()), + PROFILE, + false, + |stats| { + assert!(!stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| Ok(()), + ); + assert_eq!(samples.get(), 1); + assert!(record["before"].is_null() && record["after"].is_null()); +} diff --git a/crates/volparossa/src/compute/resources.rs b/crates/volparossa/src/compute/resources.rs index 380206f19..c0d854b71 100644 --- a/crates/volparossa/src/compute/resources.rs +++ b/crates/volparossa/src/compute/resources.rs @@ -46,12 +46,26 @@ pub(super) fn admits(profile: ModelProfile, available: Option) -> bool { .is_none_or(|required| available.is_some_and(|bytes| bytes >= required)) } -pub(super) fn admission(profile: ModelProfile) -> Result<()> { - if limits(profile).admission_headroom.is_some() { - ensure!( - admits(profile, super::spare_capacity::memory_headroom()), - "compute_model_memory_headroom" - ); +pub(super) fn admission(profile: ModelProfile, diagnostic: bool) -> Result<()> { + admission_with(profile, diagnostic, super::spare_capacity::memory::sample) +} + +fn admission_with( + profile: ModelProfile, + diagnostic: bool, + sample: impl FnOnce(bool) -> super::spare_capacity::memory::Sample, +) -> Result<()> { + if let Some(required) = limits(profile).admission_headroom { + let observe = diagnostic && super::spare_capacity::memory::enabled(); + let sample = sample(observe); + let admitted = admits(profile, sample.headroom); + if observe { + super::spare_capacity::memory::emit(&serde_json::json!({ + "version":1,"phase":"admission","required_bytes":required, + "admitted":admitted,"sample":sample.snapshot() + })); + } + ensure!(admitted, "compute_model_memory_headroom"); } Ok(()) } @@ -60,6 +74,106 @@ pub(super) fn admission(profile: ModelProfile) -> Result<()> { mod tests { use super::*; + #[derive(Clone)] + struct Log(std::sync::Arc>>); + + impl std::io::Write for Log { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn admission_observation_requires_both_explicit_native_scope_and_debug_opt_in() { + let profile = ModelProfile::Qwen4bInstruct2507; + for diagnostic in [false, true] { + for enabled in [false, true] { + let log = Log(std::sync::Arc::default()); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_level(false) + .with_max_level(if enabled { + tracing::Level::DEBUG + } else { + tracing::Level::ERROR + }) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + let result = admission_with(profile, diagnostic, |stats| { + assert_eq!(stats, diagnostic && enabled); + super::super::spare_capacity::memory::Sample::for_test(None) + }); + assert_eq!( + result.unwrap_err().to_string(), + "compute_model_memory_headroom" + ); + }); + let raw = log.0.lock().unwrap(); + if diagnostic && enabled { + let text = std::str::from_utf8(&raw).unwrap(); + let record: serde_json::Value = serde_json::from_str( + text.trim() + .strip_prefix("private_memory_diagnostic ") + .unwrap(), + ) + .unwrap(); + assert_eq!(record["version"], 1); + assert_eq!(record["phase"], "admission"); + assert_eq!(record["required_bytes"], 11_274_289_152_u64); + assert_eq!(record["admitted"], false); + assert_eq!(record["sample"]["missing_stage"], "meminfo"); + assert!(raw.len() <= 4096); + } else { + assert!(raw.is_empty()); + } + } + } + } + + #[test] + fn memory_diagnostic_sampling_uses_the_same_unbounded_value_once() { + let profile = ModelProfile::Qwen4bInstruct2507; + let required = limits(profile).admission_headroom.unwrap(); + for diagnostic in [false, true] { + for value in [ + None, + Some(0), + Some(required - 1), + Some(required), + Some(u64::MAX), + ] { + let calls = std::cell::Cell::new(0); + let result = admission_with(profile, diagnostic, |_| { + calls.set(calls.get() + 1); + super::super::spare_capacity::memory::Sample::for_test(value) + }); + assert_eq!(calls.get(), 1); + assert_eq!(result.is_ok(), admits(profile, value)); + if let Err(error) = result { + assert_eq!(error.to_string(), "compute_model_memory_headroom"); + } + } + } + } + + #[test] + fn profiles_without_admission_never_gain_a_memory_sample() { + for profile in [ModelProfile::Default135, ModelProfile::Smol360] { + for diagnostic in [false, true] { + admission_with(profile, diagnostic, |_| panic!("unrequested sample")).unwrap(); + } + } + } + #[test] fn existing_profiles_keep_their_exact_resource_contracts() { for profile in [ModelProfile::Default135, ModelProfile::Smol360] { diff --git a/crates/volparossa/src/compute/spare_capacity.rs b/crates/volparossa/src/compute/spare_capacity.rs index f7c1a4800..2c793536e 100644 --- a/crates/volparossa/src/compute/spare_capacity.rs +++ b/crates/volparossa/src/compute/spare_capacity.rs @@ -13,6 +13,8 @@ use std::{ use super::device_capacity::{DeviceBudget, DeviceObservation}; use serde::Serialize; +pub(super) mod memory; + const MAX_FILE_BYTES: u64 = 16 * 1024; const MAX_CGROUP_DEPTH: usize = 64; const MIN_MEMORY_BYTES: u64 = 512 * 1024 * 1024; @@ -277,54 +279,16 @@ fn capped_memory(available: u64, limit: &str, current: Option<&str>) -> Option Option { - let host = available_memory(&system_text(Path::new("/proc/meminfo")).ok()?)?; - let relative = unified_path(&system_text(Path::new("/proc/self/cgroup")).ok()?)?; - cgroup_headroom(host, &relative, system_text) + memory::sample(false).headroom } +#[cfg(test)] fn cgroup_headroom( - mut available: u64, + available: u64, relative: &Path, read: impl Fn(&Path) -> Result, ) -> Option { - let root = Path::new("/sys/fs/cgroup"); - let mut path = root.join(relative); - loop { - match read(&path.join("memory.max")) { - Ok(limit) => { - let current = if limit.trim() == "max" { - None - } else { - Some(read(&path.join("memory.current")).ok()?) - }; - available = capped_memory(available, &limit, current.as_deref())?; - } - Err(ErrorKind::NotFound) if path == root => { - // The actual unified hierarchy root has no memory.max. Its - // supported controllers distinguish that case from absent data. - let controllers = read(&path.join("cgroup.controllers")).ok()?; - if !controllers.split_whitespace().any(|name| name == "memory") { - return None; - } - } - Err(ErrorKind::NotFound) => { - // With memory disabled on the parent this child has no own cap; - // continue upward, where an ancestor can still impose one. - let parent = path.parent().filter(|parent| parent.starts_with(root))?; - let controllers = read(&parent.join("cgroup.subtree_control")).ok()?; - if controllers.split_whitespace().any(|name| name == "memory") { - return None; - } - } - Err(_) => return None, - } - if path == root { - return Some(available); - } - if !path.pop() || !path.starts_with(root) { - return None; - } - } + memory::cgroup_sample(available, relative, &read, false).headroom } #[cfg(test)] diff --git a/crates/volparossa/src/compute/spare_capacity/memory.rs b/crates/volparossa/src/compute/spare_capacity/memory.rs new file mode 100644 index 000000000..4310d56cf --- /dev/null +++ b/crates/volparossa/src/compute/spare_capacity/memory.rs @@ -0,0 +1,315 @@ +//! The original admission sample, with opt-in closed observations of its inputs. +//! Reads are sequential, not an atomic snapshot, reservation or reclaim proof. + +use std::{io::ErrorKind, path::Path}; + +use serde_json::{Value, json}; + +const JSON_MAX: u64 = (1 << 53) - 1; +const STAT_KEYS: [&str; 6] = [ + "anon", + "file", + "shmem", + "inactive_file", + "active_file", + "kernel", +]; + +#[derive(Clone, Debug)] +struct Stat { + state: &'static str, + values: [Option; 6], +} + +impl Default for Stat { + fn default() -> Self { + Self { + state: "unobserved", + values: [None; 6], + } + } +} + +impl Stat { + fn observe(path: &Path, read: &impl Fn(&Path) -> Result) -> Self { + let Ok(raw) = read(&path.join("memory.stat")) else { + return Self { + state: "unavailable", + ..Self::default() + }; + }; + let mut result = Self { + state: "observed", + ..Self::default() + }; + for line in raw.lines() { + let mut fields = line.split_whitespace(); + let Some(key) = fields.next() else { continue }; + let Some(index) = STAT_KEYS.iter().position(|candidate| *candidate == key) else { + continue; + }; + let value = fields.next().and_then(super::unsigned); + if result.values[index].is_some() || value.is_none() || fields.next().is_some() { + return Self { + state: "invalid", + ..Self::default() + }; + } + result.values[index] = value; + } + if result.values.iter().any(Option::is_none) { + return Self { + state: "invalid", + ..Self::default() + }; + } + result + } + + fn overflow(&self) -> bool { + self.values.iter().flatten().any(|value| *value > JSON_MAX) + } + + fn snapshot(&self) -> Value { + let overflow = self.overflow(); + let values = if overflow { [None; 6] } else { self.values }; + json!({"state":if overflow { "unrepresentable" } else { self.state }, + "anon_bytes":values[0],"file_bytes":values[1],"shmem_bytes":values[2], + "inactive_file_bytes":values[3],"active_file_bytes":values[4],"kernel_bytes":values[5]}) + } +} + +#[derive(Clone, Debug)] +pub(in crate::compute) struct Sample { + pub(in crate::compute) headroom: Option, + host: Option, + own: Option, + ancestor: Option, + own_current: Option, + own_max: Option, + ancestor_current: Option, + ancestor_max: Option, + missing: &'static str, + io_kind: &'static str, + own_stat: Stat, + ancestor_stat: Stat, +} + +impl Default for Sample { + fn default() -> Self { + Self { + headroom: None, + host: None, + own: None, + ancestor: None, + own_current: None, + own_max: None, + ancestor_current: None, + ancestor_max: None, + missing: "none", + io_kind: "none", + own_stat: Stat::default(), + ancestor_stat: Stat::default(), + } + } +} + +impl Sample { + #[cfg(test)] + pub(in crate::compute) fn for_test(headroom: Option) -> Self { + Self { + headroom, + host: headroom, + missing: if headroom.is_some() { "none" } else { "meminfo" }, + io_kind: if headroom.is_some() { "none" } else { "invalid_data" }, + ..Self::default() + } + } + + fn failed(mut self, stage: &'static str, error: ErrorKind) -> Self { + self.headroom = None; + self.missing = stage; + self.io_kind = match error { + ErrorKind::NotFound => "not_found", + ErrorKind::PermissionDenied => "permission", + ErrorKind::InvalidData => "invalid_data", + _ => "other", + }; + self + } + + fn record_cap(&mut self, limit: &str, current: Option<&str>, own: bool) -> bool { + // capped_memory already validated these strings. This records that + // decision's inputs; optional statistics never participate in it. + let maximum = super::unsigned(limit.trim()); + let used = current.and_then(|raw| super::unsigned(raw.trim())); + if let (Some(maximum), Some(used)) = (maximum, used) { + let free = maximum.saturating_sub(used); + if own { + self.own = Some(free); + self.own_current = Some(used); + self.own_max = Some(maximum); + } else if self.ancestor.is_none_or(|previous| free < previous) { + self.ancestor = Some(free); + self.ancestor_current = Some(used); + self.ancestor_max = Some(maximum); + return true; + } + } + false + } + + pub(in crate::compute) fn snapshot(&self) -> Value { + let values = [ + self.headroom, + self.host, + self.own, + self.ancestor, + self.own_current, + self.own_max, + self.ancestor_current, + self.ancestor_max, + ]; + let overflow = values.iter().flatten().any(|value| *value > JSON_MAX) + || self.own_stat.overflow() + || self.ancestor_stat.overflow(); + let values = values.map(|value| value.filter(|number| *number <= JSON_MAX)); + let limiting = self.headroom.map_or("unknown", |minimum| { + let scopes = [ + (self.host, "host"), + (self.own, "self"), + (self.ancestor, "ancestor"), + ]; + let mut matching = scopes + .into_iter() + .filter(|(value, _)| *value == Some(minimum)); + let first = matching.next().map_or("unknown", |(_, scope)| scope); + if matching.next().is_some() { + "multiple" + } else { + first + } + }); + json!({"headroom_bytes":values[0],"host_available_bytes":values[1], + "self_headroom_bytes":values[2],"ancestor_headroom_bytes":values[3], + "self_current_bytes":values[4],"self_max_bytes":values[5], + "ancestor_current_bytes":values[6],"ancestor_max_bytes":values[7], + "limiting_scope":limiting,"missing_stage":self.missing,"io_kind":self.io_kind, + "numeric_overflow":overflow,"self_stat":self.own_stat.snapshot(), + "ancestor_stat":self.ancestor_stat.snapshot()}) + } +} + +pub(in crate::compute) fn enabled() -> bool { + tracing::enabled!(target: "volparossa::compute::private_diagnostic", tracing::Level::DEBUG) +} + +pub(in crate::compute) fn emit(record: &Value) { + tracing::debug!(target: "volparossa::compute::private_diagnostic", + "private_memory_diagnostic {record}"); +} + +pub(in crate::compute) fn sample(stats: bool) -> Sample { + sample_with(stats, &super::system_text) +} + +fn sample_with(stats: bool, read: &impl Fn(&Path) -> Result) -> Sample { + let host = match read(Path::new("/proc/meminfo")) { + Ok(raw) => match super::available_memory(&raw) { + Some(value) => value, + None => return Sample::default().failed("meminfo", ErrorKind::InvalidData), + }, + Err(error) => return Sample::default().failed("meminfo", error), + }; + let partial = Sample { + host: Some(host), + ..Sample::default() + }; + let relative = match read(Path::new("/proc/self/cgroup")) { + Ok(raw) => match super::unified_path(&raw) { + Some(value) => value, + None => return partial.failed("cgroup_path", ErrorKind::InvalidData), + }, + Err(error) => return partial.failed("cgroup_path", error), + }; + cgroup_sample(host, &relative, read, stats) +} + +pub(super) fn cgroup_sample( + host: u64, + relative: &Path, + read: &impl Fn(&Path) -> Result, + stats: bool, +) -> Sample { + let root = Path::new("/sys/fs/cgroup"); + let own_path = root.join(relative); + let mut path = own_path.clone(); + let mut ancestor_path = None; + let mut available = host; + let mut observed = Sample { + host: Some(host), + ..Sample::default() + }; + loop { + match read(&path.join("memory.max")) { + Ok(limit) => { + let current = if limit.trim() == "max" { + None + } else { + match read(&path.join("memory.current")) { + Ok(raw) => Some(raw), + Err(error) => return observed.failed("current", error), + } + }; + let Some(next) = super::capped_memory(available, &limit, current.as_deref()) else { + let stage = if super::unsigned(limit.trim()).is_none() { + "max" + } else { + "current" + }; + return observed.failed(stage, ErrorKind::InvalidData); + }; + available = next; + if observed.record_cap(&limit, current.as_deref(), path == own_path) { + ancestor_path = Some(path.clone()); + } + } + Err(ErrorKind::NotFound) => { + let control = if path == root { + path.join("cgroup.controllers") + } else { + let Some(parent) = path.parent().filter(|parent| parent.starts_with(root)) + else { + return observed.failed("cgroup_path", ErrorKind::InvalidData); + }; + parent.join("cgroup.subtree_control") + }; + let controllers = match read(&control) { + Ok(raw) => raw, + Err(error) => return observed.failed("controller", error), + }; + let memory = controllers.split_whitespace().any(|name| name == "memory"); + if memory != (path == root) { + return observed.failed("controller", ErrorKind::InvalidData); + } + } + Err(error) => return observed.failed("max", error), + } + if path == root { + observed.headroom = Some(available); + if stats { + observed.own_stat = Stat::observe(&own_path, read); + if let Some(ancestor) = ancestor_path { + observed.ancestor_stat = Stat::observe(&ancestor, read); + } + } + return observed; + } + if !path.pop() || !path.starts_with(root) { + return observed.failed("cgroup_path", ErrorKind::InvalidData); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/spare_capacity/memory/tests.rs b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs new file mode 100644 index 000000000..5852b0980 --- /dev/null +++ b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs @@ -0,0 +1,341 @@ +//! Injected kernel text only: no host pressure, cgroup writes or model execution. + +use std::{cell::RefCell, collections::BTreeMap, path::PathBuf}; + +use super::*; + +type Files = BTreeMap>; + +const ROOT: &str = "/sys/fs/cgroup"; +const GROUP: &str = "PRIVATE_CGROUP_CANARY/child"; +const STAT: &str = "anon 40\nfile 50\nshmem 3\ninactive_file 20\nactive_file 30\nkernel 10\n"; + +fn files() -> Files { + BTreeMap::from([ + ( + PathBuf::from("/proc/meminfo"), + Ok("MemAvailable: 1 kB\n".into()), + ), + ( + PathBuf::from("/proc/self/cgroup"), + Ok(format!("0::/{GROUP}\n")), + ), + ( + Path::new(ROOT).join(GROUP).join("memory.max"), + Ok("900".into()), + ), + ( + Path::new(ROOT).join(GROUP).join("memory.current"), + Ok("100".into()), + ), + ( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.max"), + Ok("700".into()), + ), + ( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.current"), + Ok("200".into()), + ), + ( + Path::new(ROOT).join("cgroup.controllers"), + Ok("cpu memory io".into()), + ), + ]) +} + +fn read(files: &Files, path: &Path) -> Result { + files.get(path).cloned().unwrap_or(Err(ErrorKind::NotFound)) +} + +// Frozen decision algorithm from 3630ff79, independent of the new observer. +fn original(files: &Files, calls: &RefCell>) -> Option { + let read = |path: &Path| { + calls.borrow_mut().push(path.to_owned()); + read(files, path) + }; + let mut available = super::super::available_memory(&read(Path::new("/proc/meminfo")).ok()?)?; + let relative = super::super::unified_path(&read(Path::new("/proc/self/cgroup")).ok()?)?; + let root = Path::new(ROOT); + let mut path = root.join(relative); + loop { + match read(&path.join("memory.max")) { + Ok(limit) => { + let current = if limit.trim() == "max" { + None + } else { + Some(read(&path.join("memory.current")).ok()?) + }; + available = super::super::capped_memory(available, &limit, current.as_deref())?; + } + Err(ErrorKind::NotFound) if path == root => { + let controllers = read(&path.join("cgroup.controllers")).ok()?; + if !controllers.split_whitespace().any(|name| name == "memory") { + return None; + } + } + Err(ErrorKind::NotFound) => { + let parent = path.parent().filter(|parent| parent.starts_with(root))?; + let controllers = read(&parent.join("cgroup.subtree_control")).ok()?; + if controllers.split_whitespace().any(|name| name == "memory") { + return None; + } + } + Err(_) => return None, + } + if path == root { + return Some(available); + } + if !path.pop() || !path.starts_with(root) { + return None; + } + } +} + +fn differential(files: &Files) { + let original_calls = RefCell::new(Vec::new()); + let expected = original(files, &original_calls); + for stats in [false, true] { + let calls = RefCell::new(Vec::new()); + let observed = sample_with(stats, &|path| { + calls.borrow_mut().push(path.to_owned()); + read(files, path) + }); + assert_eq!(observed.headroom, expected); + let calls = calls.into_inner(); + if stats && expected.is_some() { + assert!(calls.starts_with(&original_calls.borrow())); + assert!( + calls[original_calls.borrow().len()..] + .iter() + .all(|path| path.file_name().unwrap() == "memory.stat") + ); + } else { + assert_eq!(calls, *original_calls.borrow()); + } + } +} + +#[test] +fn admission_sampler_preserves_original_decisions_and_read_order() { + let variants = [ + Ok("max".into()), + Ok("0".into()), + Ok("100".into()), + Ok(u64::MAX.to_string()), + Ok("+1".into()), + Ok("PRIVATE_VALUE_CANARY".into()), + Err(ErrorKind::NotFound), + Err(ErrorKind::PermissionDenied), + ]; + for own in &variants { + for used in &variants { + for parent in &variants { + let mut files = files(); + files.insert( + Path::new(ROOT).join(GROUP).join("memory.max"), + own.clone(), + ); + files.insert( + Path::new(ROOT).join(GROUP).join("memory.current"), + used.clone(), + ); + files.insert( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.max"), + parent.clone(), + ); + differential(&files); + } + } + } +} + +#[test] +fn admission_sampler_keeps_original_text_and_controller_rejections() { + for (path, variants) in [ + ( + "/proc/meminfo", + vec![ + "", + "MemAvailable: +1 kB", + "MemAvailable: 1 MB", + "MemAvailable: 1 kB extra", + "MemAvailable: 1 kB\nMemAvailable: 2 kB", + "MemAvailable: 18446744073709551615 kB", + ], + ), + ( + "/proc/self/cgroup", + vec![ + "", + "0::relative", + "0::/a/../b", + "0::/a/./b", + "0::/a//b", + "0::/a\n0::/b", + "1:memory:/scope", + "0::/", + ], + ), + ( + "/sys/fs/cgroup/cgroup.controllers", + vec!["", "cpu", "memory", "cpu memory io"], + ), + ] { + for raw in variants { + let mut files = files(); + files.insert(PathBuf::from(path), Ok(raw.into())); + differential(&files); + } + } + for controller in ["", "cpu io", "memory", "cpu memory io"] { + let mut files = files(); + files.remove(&Path::new(ROOT).join(GROUP).join("memory.max")); + files.insert( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/cgroup.subtree_control"), + Ok(controller.into()), + ); + differential(&files); + } + let mut deep = files(); + deep.insert( + PathBuf::from("/proc/self/cgroup"), + Ok(format!("0::/{}", vec!["a"; 65].join("/"))), + ); + differential(&deep); + let files = files(); + for path in files.keys().cloned().collect::>() { + for error in [ + ErrorKind::NotFound, + ErrorKind::PermissionDenied, + ErrorKind::InvalidData, + ErrorKind::Other, + ] { + let mut changed = files.clone(); + changed.insert(path.clone(), Err(error)); + differential(&changed); + } + } +} + +#[test] +fn closed_snapshot_identifies_minimum_and_ties_without_cgroup_names() { + for (host, own, parent, scope) in [ + (100, 800, 500, "host"), + (900, 100, 500, "self"), + (900, 800, 100, "ancestor"), + (100, 100, 500, "multiple"), + ] { + let sample = Sample { + headroom: Some(host.min(own).min(parent)), + host: Some(host), + own: Some(own), + ancestor: Some(parent), + ..Sample::default() + }; + assert_eq!(sample.snapshot()["limiting_scope"], scope); + } + let sample = sample_with(true, &|path| read(&files(), path)); + let value = sample.snapshot(); + assert_eq!(value["headroom_bytes"], 500); + assert_eq!(value["self_current_bytes"], 100); + assert_eq!(value["self_max_bytes"], 900); + assert_eq!(value["ancestor_current_bytes"], 200); + assert_eq!(value["ancestor_max_bytes"], 700); + assert_eq!(value["limiting_scope"], "ancestor"); + assert!(!value.to_string().contains("CANARY")); +} + +#[test] +fn missing_samples_preserve_only_closed_failure_stage_and_io_kind() { + for (path, stage) in [ + ("/proc/meminfo", "meminfo"), + ("/proc/self/cgroup", "cgroup_path"), + ( + "/sys/fs/cgroup/PRIVATE_CGROUP_CANARY/child/memory.max", + "max", + ), + ( + "/sys/fs/cgroup/PRIVATE_CGROUP_CANARY/child/memory.current", + "current", + ), + ("/sys/fs/cgroup/cgroup.controllers", "controller"), + ] { + let mut files = files(); + files.insert(PathBuf::from(path), Err(ErrorKind::PermissionDenied)); + let sample = sample_with(true, &|path| read(&files, path)); + let value = sample.snapshot(); + assert_eq!(sample.headroom, None); + assert_eq!(value["missing_stage"], stage); + assert_eq!(value["io_kind"], "permission"); + assert_eq!(value["limiting_scope"], "unknown"); + assert!(value["headroom_bytes"].is_null()); + assert!(!value.to_string().contains("CANARY")); + } +} + +#[test] +fn optional_stat_reads_only_self_and_selected_ancestor_after_the_decision_sample() { + let mut files = files(); + let own = Path::new(ROOT).join(GROUP).join("memory.stat"); + let ancestor = Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.stat"); + files.insert(own.clone(), Ok(format!("{STAT}PRIVATE_STAT_CANARY 999\n"))); + files.insert(ancestor.clone(), Ok(STAT.into())); + let calls = RefCell::new(Vec::new()); + let sample = sample_with(true, &|path| { + calls.borrow_mut().push(path.to_owned()); + read(&files, path) + }); + let calls = calls.into_inner(); + assert_eq!(&calls[calls.len() - 2..], &[own, ancestor]); + assert_eq!(sample.headroom, Some(500)); + assert_eq!(sample.snapshot()["self_stat"]["file_bytes"], 50); + assert_eq!(sample.snapshot()["ancestor_stat"]["anon_bytes"], 40); + assert!(!sample.snapshot().to_string().contains("CANARY")); + differential(&files); +} + +#[test] +fn absent_invalid_or_overflowing_stats_never_change_headroom() { + for (raw, state) in [ + (Err(ErrorKind::PermissionDenied), "unavailable"), + (Ok(String::new()), "invalid"), + (Ok(format!("{STAT}anon 1\n")), "invalid"), + (Ok(STAT.replace("anon 40", "anon +40")), "invalid"), + (Ok(STAT.replace("file 50", "file 50 extra")), "invalid"), + ( + Ok(STAT.replace("file 50", &format!("file {}", u64::MAX))), + "unrepresentable", + ), + ] { + let mut files = files(); + files.insert(Path::new(ROOT).join(GROUP).join("memory.stat"), raw); + let sample = sample_with(true, &|path| read(&files, path)); + assert_eq!(sample.headroom, Some(500)); + let snapshot = sample.snapshot(); + let stat = &snapshot["self_stat"]; + assert_eq!(stat["state"], state); + assert!( + stat.as_object() + .unwrap() + .iter() + .all(|(key, value)| key == "state" || value.is_null()) + ); + assert_eq!(snapshot["numeric_overflow"], state == "unrepresentable"); + } +} + +#[test] +fn json_bound_does_not_change_internal_u64_admission_value() { + for value in [JSON_MAX, JSON_MAX + 1, u64::MAX] { + let sample = Sample::for_test(Some(value)); + let snapshot = sample.snapshot(); + assert_eq!(sample.headroom, Some(value)); + assert_eq!(snapshot["numeric_overflow"], value > JSON_MAX); + assert_eq!(snapshot["headroom_bytes"].is_null(), value > JSON_MAX); + assert_eq!(snapshot["missing_stage"], "none"); + assert!(crate::compute::resources::admits( + crate::compute::ModelProfile::Qwen4bInstruct2507, + sample.headroom + )); + } +} diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index dbec4e177..34174b694 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -96,6 +96,15 @@ 'interrupted', 'invalid_data', 'other'} SERVICE_STDERR_CLASSES = {None, 'none', 'bubblewrap', 'user_namespace', 'proc_mount', 'exec_denied', 'python_startup', 'other'} +MEMORY_MARKER = b'private_memory_diagnostic ' +MEMORY_RECORD_BYTES = 4096 +MEMORY_JSON_MAX = 2**53 - 1 +MEMORY_REQUIRED_BYTES = 11274289152 +MEMORY_COUNTERS = {'headroom_bytes', 'host_available_bytes', 'self_headroom_bytes', + 'ancestor_headroom_bytes', 'self_current_bytes', 'self_max_bytes', + 'ancestor_current_bytes', 'ancestor_max_bytes'} +MEMORY_STAT_COUNTERS = {'anon_bytes', 'file_bytes', 'shmem_bytes', 'inactive_file_bytes', + 'active_file_bytes', 'kernel_bytes'} EXPORT_NAMES = {'agent-private-conversation-smoke.json', 'host-state-before.json', 'host-state-after.json', 'current-phase', 'guest-exit-status', 'runner.stdout', 'runner.stderr'} @@ -401,8 +410,111 @@ def execution_state(value): return value -def service_diagnostic(path): +def memory_snapshot(value): + """Closed, sequential observations; no reservation or cache-reclaim claim.""" + require(type(value) is dict and set(value) == MEMORY_COUNTERS | {'limiting_scope', + 'missing_stage', 'io_kind', 'numeric_overflow', 'self_stat', 'ancestor_stat'}, 'memory snapshot keys') + for key in MEMORY_COUNTERS: + require(value[key] is None or (type(value[key]) is int and 0 <= value[key] <= MEMORY_JSON_MAX), + 'memory snapshot counter') + for key, domain in ( + ('limiting_scope', {'host', 'self', 'ancestor', 'multiple', 'unknown'}), + ('missing_stage', {'none', 'meminfo', 'cgroup_path', 'max', 'current', 'controller'}), + ('io_kind', {'none', 'not_found', 'permission', 'invalid_data', 'other'})): + require(type(value[key]) is str and value[key] in domain, 'memory snapshot vocabulary') + require(type(value['numeric_overflow']) is bool, 'memory overflow flag') + for key in ('self_stat', 'ancestor_stat'): + detail = value[key] + require(type(detail) is dict and set(detail) == MEMORY_STAT_COUNTERS | {'state'} + and type(detail['state']) is str and detail['state'] in { + 'unobserved', 'observed', 'unavailable', 'invalid', 'unrepresentable'}, 'memory stat shape') + for name in MEMORY_STAT_COUNTERS: + require((type(detail[name]) is int and 0 <= detail[name] <= MEMORY_JSON_MAX) + if detail['state'] == 'observed' else detail[name] is None, 'memory stat counter') + if value['missing_stage'] != 'none': + require(value['headroom_bytes'] is None and value['limiting_scope'] == 'unknown', + 'missing memory observation') + else: + require(value['io_kind'] == 'none', 'complete memory observation IO') + if not value['numeric_overflow']: + candidates = {name: value[key] for name, key in ( + ('host', 'host_available_bytes'), ('self', 'self_headroom_bytes'), + ('ancestor', 'ancestor_headroom_bytes')) if value[key] is not None} + require('host' in candidates and value['headroom_bytes'] == min(candidates.values()), + 'memory headroom minimum') + limiting = [name for name, count in candidates.items() if count == value['headroom_bytes']] + require(value['limiting_scope'] == (limiting[0] if len(limiting) == 1 else 'multiple'), + 'memory limiting scope') + return value + + +def memory_diagnostic(value): + require(type(value) is dict and type(value.get('version')) is int and value['version'] == 1, + 'memory diagnostic version') + if value.get('phase') == 'admission': + require(set(value) == {'version', 'phase', 'required_bytes', 'admitted', 'sample'} + and type(value['required_bytes']) is int and value['required_bytes'] == MEMORY_REQUIRED_BYTES + and type(value['admitted']) is bool, 'memory admission shape') + sample = memory_snapshot(value['sample']) + if sample['headroom_bytes'] is not None: + require(value['admitted'] == (sample['headroom_bytes'] >= value['required_bytes']), + 'memory admission decision') + elif sample['missing_stage'] != 'none' or not sample['numeric_overflow']: + require(value['admitted'] is False, 'unknown memory admission') + return value + require(set(value) == {'version', 'phase', 'binding', 'outcome', 'supervisor_ok', + 'before', 'after', 'syscall_error'} and value.get('phase') == 'cache_hint', 'memory cache shape') + require(type(value['binding']) is str and value['binding'] in { + 'bound', 'not_applicable', 'root_invalid', 'open_failed', 'file_invalid', 'path_changed'} + and type(value['outcome']) is str and value['outcome'] in { + 'supervisor_error', 'not_bound', 'headroom_unknown', 'headroom_sufficient', + 'identity_changed', 'range_invalid', 'advised', 'error'} + and type(value['supervisor_ok']) is bool, 'memory cache vocabulary') + outcome = value['outcome'] + if outcome in {'supervisor_error', 'not_bound'}: + require(value['supervisor_ok'] == (outcome == 'not_bound') + and (outcome != 'not_bound' or value['binding'] != 'bound') + and value['before'] is None and value['after'] is None and value['syscall_error'] is None, + 'memory cache unattempted') + return value + require(value['supervisor_ok'] is True and value['binding'] == 'bound', 'memory cache scope') + before = memory_snapshot(value['before']) + headroom = before['headroom_bytes'] + if outcome == 'headroom_unknown': + require(headroom is None and before['missing_stage'] != 'none', 'unknown cache headroom') + elif outcome == 'headroom_sufficient': + require(before['missing_stage'] == 'none' and ((headroom is not None and headroom >= MEMORY_REQUIRED_BYTES) + or (headroom is None and before['numeric_overflow'])), 'sufficient cache headroom') + else: + require(before['missing_stage'] == 'none' and headroom is not None and headroom < MEMORY_REQUIRED_BYTES, + 'cache hint requires known low headroom') + if outcome in {'advised', 'error'}: + memory_snapshot(value['after']) + require(value['syscall_error'] is None if outcome == 'advised' else + type(value['syscall_error']) is str and value['syscall_error'] in { + 'bad_fd', 'invalid', 'unsupported', 'interrupted', 'other'}, 'cache syscall result') + else: + require(value['after'] is None and value['syscall_error'] is None, 'skipped cache hint') + return value + + +def memory_json(raw): + def unique(pairs): + value = {} + for key, item in pairs: + require(key not in value, 'duplicate memory field') + value[key] = item + return value + + def invalid_constant(_): + raise ValueError('non-finite memory number') + + return json.loads(raw, object_pairs_hook=unique, parse_constant=invalid_constant) + + +def service_diagnostic(path, *, memory_enabled=False): """Local observations only; fixed vocabulary, no raw log, prompt, path or request ID.""" + require(type(memory_enabled) is bool, 'explicit memory diagnostic opt-in') if not path.exists(): return None with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream: @@ -415,9 +527,23 @@ def service_diagnostic(path): if truncated: lines = lines[:-1] # Never accept a partial final record. events, states, state_seen, unknown = [], [], False, False + memory = [] marker = b'private_execution_diagnostic ' state_marker = b'private_execution_state ' for line in lines: + if memory_enabled and MEMORY_MARKER in line: + try: + require(line.count(MEMORY_MARKER) == 1, 'ambiguous memory marker') + payload = line.split(MEMORY_MARKER, 1)[1] + require(len(MEMORY_MARKER) + len(payload) + 1 <= MEMORY_RECORD_BYTES, 'memory record bound') + observation = memory_diagnostic(memory_json(payload)) + if len(memory) < 16: + memory.append(observation) + else: + truncated = True + except (ValueError, KeyError, TypeError, RecursionError): + unknown = True + continue if state_marker in line: state_seen = True try: @@ -452,6 +578,8 @@ def service_diagnostic(path): result = dict(version=1, events=events, truncated=truncated, unrecognized_record=unknown) if state_seen: result['states'] = states + if memory_enabled: + result.update(version=2, memory=memory) return result diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index 8a7f4e3a3..3d9eb5a6a 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -40,6 +40,29 @@ def assistant_response(text): prompt_tokens=279, generated_tokens=19, output=dict(type='assistant', text=text)) +def memory_snapshot_fixture(): + """Synthetic finite collector input; not a kernel measurement.""" + detail = dict(state='observed', anon_bytes=1, file_bytes=2, shmem_bytes=3, + inactive_file_bytes=4, active_file_bytes=5, kernel_bytes=6) + return dict(headroom_bytes=10 * FIX['GIB'], host_available_bytes=20 * FIX['GIB'], + self_headroom_bytes=10 * FIX['GIB'], ancestor_headroom_bytes=11 * FIX['GIB'], + self_current_bytes=2 * FIX['GIB'], self_max_bytes=12 * FIX['GIB'], + ancestor_current_bytes=3 * FIX['GIB'], ancestor_max_bytes=14 * FIX['GIB'], + limiting_scope='self', missing_stage='none', io_kind='none', numeric_overflow=False, + self_stat=copy.deepcopy(detail), ancestor_stat=copy.deepcopy(detail)) + + +def memory_admission_fixture(): + return dict(version=1, phase='admission', required_bytes=FIX['MEMORY_REQUIRED_BYTES'], + admitted=False, sample=memory_snapshot_fixture()) + + +def memory_cache_fixture(outcome='advised'): + return dict(version=1, phase='cache_hint', binding='bound', outcome=outcome, + supervisor_ok=True, before=memory_snapshot_fixture(), after=memory_snapshot_fixture(), + syscall_error=None if outcome == 'advised' else 'other') + + def example_receipt(): """Deliberately fabricated validator input, never exported as runtime proof.""" provisioner = runpy.run_path(str(FIX['ML'] / 'provision.py')) @@ -83,6 +106,158 @@ def example_receipt(): class FixtureTests(unittest.TestCase): + def collect_memory(self, records, enabled=True, raw_suffix=b''): + with tempfile.TemporaryDirectory(prefix='memory-collector-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + data = b''.join(b'DEBUG ' + FIX['MEMORY_MARKER'] + + (record if type(record) is bytes else json.dumps(record).encode()) + b'\n' for record in records) + path.write_bytes(data + raw_suffix) + path.chmod(0o600) + return FIX['service_diagnostic'](path, memory_enabled=enabled) + + def test_memory_requires_explicit_opt_in_and_preserves_legacy_byte_shape(self): + record = memory_admission_fixture() + expected = dict(version=1, events=[], truncated=False, unrecognized_record=False) + self.assertEqual(json.dumps(self.collect_memory([record, b'PRIVATE_CANARY'], enabled=False)), + json.dumps(expected)) + current = self.collect_memory([]) + self.assertEqual(current, dict(expected, version=2, memory=[])) + event = dict(version=1, phase='task', detail=dict(code='compute_model_memory_headroom', + io_kind='none', exit_code=None, signal=None, stderr_class=None)) + suffix = b'DEBUG private_execution_diagnostic ' + json.dumps(event).encode() + b'\n' + legacy = self.collect_memory([record], enabled=False, raw_suffix=suffix) + current = self.collect_memory([record], raw_suffix=suffix) + self.assertEqual(legacy, dict(expected, events=[event])) + self.assertEqual(current, dict(legacy, version=2, memory=[record])) + state = dict(version=1, last_phase=None, substage=None, + capacity=dict(decision='pause', constraint='memory', cpu_some_avg10=None, + io_some_avg10=None, memory_bytes=None), controls=None, peak_rss_bytes=0) + suffix += b'DEBUG private_execution_state ' + json.dumps(state).encode() + b'\n' + legacy = self.collect_memory([record], enabled=False, raw_suffix=suffix) + current = self.collect_memory([record], raw_suffix=suffix) + self.assertEqual(legacy['states'], [state]) + self.assertEqual(current, dict(legacy, version=2, memory=[record])) + for enabled in (1, 'true', None): + with self.assertRaises(ValueError): self.collect_memory([record], enabled=enabled) + + def test_memory_admission_and_cache_hint_are_observations_not_reclaim_claims(self): + records = [memory_admission_fixture(), memory_cache_fixture(), memory_cache_fixture('error')] + result = self.collect_memory(records) + self.assertEqual(result['memory'], records) + self.assertFalse(result['unrecognized_record']) + self.assertEqual(records[1]['before'], records[1]['after']) + self.assertNotIn('acceptance', json.dumps(result)) + self.assertNotIn('reclaimed', json.dumps(result)) + + def test_memory_snapshot_closed_counter_types_and_stat_states(self): + validate = FIX['memory_snapshot'] + for field in FIX['MEMORY_COUNTERS']: + for changed in (-1, True, 1.5, 2**53, float('nan'), 'PRIVATE_CANARY'): + value = memory_snapshot_fixture(); value[field] = changed + with self.subTest(field=field, changed=changed), self.assertRaises(ValueError): + validate(value) + for state in ('unobserved', 'unavailable', 'invalid', 'unrepresentable'): + value = memory_snapshot_fixture() + value['self_stat'] = dict.fromkeys(FIX['MEMORY_STAT_COUNTERS']) | {'state': state} + self.assertEqual(validate(value), value) + value['self_stat']['file_bytes'] = 1 + with self.assertRaises(ValueError): validate(value) + for key in FIX['MEMORY_STAT_COUNTERS']: + for changed in (None, True, -1, 2**53, 'PRIVATE_CANARY'): + value = memory_snapshot_fixture(); value['ancestor_stat'][key] = changed + with self.subTest(key=key, changed=changed), self.assertRaises(ValueError): validate(value) + for key, changed in [('numeric_overflow', 1), ('limiting_scope', 'PRIVATE_CANARY'), + ('missing_stage', 'PRIVATE_CANARY'), ('io_kind', 'PRIVATE_CANARY'), + ('self_stat', {'private_path': 'PRIVATE_CANARY'}), ('private_id', 'PRIVATE_CANARY')]: + value = memory_snapshot_fixture(); value[key] = changed + with self.subTest(key=key), self.assertRaises(ValueError): validate(value) + + def test_memory_minimum_ties_and_partial_observations_remain_distinct(self): + value = memory_snapshot_fixture() + value['ancestor_headroom_bytes'] = value['headroom_bytes']; value['limiting_scope'] = 'multiple' + self.assertEqual(FIX['memory_snapshot'](value), value) + for key, changed in [('headroom_bytes', value['headroom_bytes'] + 1), ('limiting_scope', 'self')]: + with self.assertRaises(ValueError): FIX['memory_snapshot'](dict(value, **{key: changed})) + partial = dict(value, headroom_bytes=None, missing_stage='current', io_kind='permission', limiting_scope='unknown') + self.assertEqual(FIX['memory_snapshot'](partial), partial) + with self.assertRaises(ValueError): FIX['memory_snapshot'](dict(partial, headroom_bytes=0)) + event = memory_admission_fixture(); event['sample'] = partial + self.assertEqual(FIX['memory_diagnostic'](event), event) + with self.assertRaises(ValueError): FIX['memory_diagnostic'](dict(event, admitted=True)) + + def test_memory_unrepresentable_headroom_does_not_rewrite_internal_admission(self): + sample = memory_snapshot_fixture() + sample.update(headroom_bytes=None, host_available_bytes=None, self_headroom_bytes=None, + ancestor_headroom_bytes=None, numeric_overflow=True) + for admitted in (False, True): + event = dict(memory_admission_fixture(), sample=sample, admitted=admitted) + self.assertEqual(FIX['memory_diagnostic'](event), event) + value = dict(memory_cache_fixture(), outcome='headroom_sufficient', before=sample, + after=None, syscall_error=None) + self.assertEqual(FIX['memory_diagnostic'](value), value) + sample.update(missing_stage='max', limiting_scope='unknown', io_kind='invalid_data') + event = dict(memory_admission_fixture(), sample=sample, admitted=True) + with self.assertRaises(ValueError): FIX['memory_diagnostic'](event) + value['outcome'] = 'headroom_unknown' + self.assertEqual(FIX['memory_diagnostic'](value), value) + + def test_memory_cache_hint_outcomes_bind_supervision_identity_and_low_headroom(self): + validate = FIX['memory_diagnostic'] + for outcome in ('identity_changed', 'range_invalid'): + event = dict(memory_cache_fixture(), outcome=outcome, after=None) + self.assertEqual(validate(event), event) + unknown = memory_snapshot_fixture() + unknown.update(headroom_bytes=None, missing_stage='meminfo', limiting_scope='unknown', io_kind='not_found') + event = dict(memory_cache_fixture(), outcome='headroom_unknown', before=unknown, after=None) + self.assertEqual(validate(event), event) + sufficient = memory_snapshot_fixture() + sufficient.update(self_headroom_bytes=12 * FIX['GIB'], headroom_bytes=11 * FIX['GIB'], limiting_scope='ancestor') + event = dict(memory_cache_fixture(), outcome='headroom_sufficient', before=sufficient, after=None) + self.assertEqual(validate(event), event) + for binding in ('not_applicable', 'root_invalid', 'open_failed', 'file_invalid', 'path_changed'): + event = dict(memory_cache_fixture(), binding=binding, outcome='not_bound', before=None, after=None) + self.assertEqual(validate(event), event) + event = dict(memory_cache_fixture(), outcome='supervisor_error', supervisor_ok=False, before=None, after=None) + self.assertEqual(validate(event), event) + for changes in ({'supervisor_ok': False}, {'binding': 'not_applicable'}, {'after': None}, + {'before': sufficient}, {'before': unknown}, {'syscall_error': 'other'}, + {'outcome': 'not_bound'}, {'outcome': 'headroom_unknown'}, {'outcome': 'PRIVATE_CANARY'}): + with self.subTest(changes=changes), self.assertRaises(ValueError): + validate(dict(memory_cache_fixture(), **changes)) + for error in ('bad_fd', 'invalid', 'unsupported', 'interrupted', 'other'): + event = dict(memory_cache_fixture('error'), syscall_error=error) + self.assertEqual(validate(event), event) + with self.assertRaises(ValueError): validate(dict(memory_cache_fixture('error'), syscall_error=None)) + + def test_memory_json_rejects_unknown_duplicate_nonfinite_and_private_values(self): + valid = memory_admission_fixture(); raw = json.dumps(valid).encode() + invalid = [dict(valid, request_id='PRIVATE_CANARY'), dict(valid, version=True), + dict(valid, required_bytes=True), dict(valid, required_bytes=1), dict(valid, admitted=True), + raw[:-1] + b', "version":1}', raw.replace(b'"numeric_overflow": false', b'"numeric_overflow": NaN'), + raw.replace(b'"anon_bytes": 1', b'"anon_bytes": 1, "anon_bytes": 2'), + b'PRIVATE_CANARY', b'[]', b'{', b'{"phase":"PRIVATE_CANARY"}', + b'[' * 1100 + b'0' + b']' * 1100, + raw + FIX['MEMORY_MARKER'] + b'PRIVATE_CANARY'] + for value in invalid: + with self.subTest(value=str(value)[:60]): + result = self.collect_memory([value]) + self.assertEqual(result['memory'], []) + self.assertTrue(result['unrecognized_record']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + + def test_memory_record_and_collection_bounds_include_marker_and_newline(self): + raw = json.dumps(memory_admission_fixture()).encode() + padding = FIX['MEMORY_RECORD_BYTES'] - len(FIX['MEMORY_MARKER']) - len(raw) - 1 + accepted = self.collect_memory([raw + b' ' * padding]) + self.assertEqual(len(accepted['memory']), 1) + rejected = self.collect_memory([raw + b' ' * (padding + 1)]) + self.assertEqual(rejected['memory'], []); self.assertTrue(rejected['unrecognized_record']) + result = self.collect_memory([memory_admission_fixture()] * 17) + self.assertEqual(len(result['memory']), 16); self.assertTrue(result['truncated']) + result = self.collect_memory([memory_admission_fixture()], raw_suffix=b'PRIVATE_CANARY' * 8192) + self.assertEqual(len(result['memory']), 1); self.assertTrue(result['truncated']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_generation_state_closed_metadata_unknown_flags_and_forward_token_distinction(self): value = dict(prompt_tokens=12288, threads=2, interop_threads=1, cpu=dict(isa='AVX2', avx2=True, avx512_bf16=False, amx_bf16=None, amx_tile=None, From 0091e75f49552ab5c5a0934cab6ae08463c45b17 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:03:05 +0200 Subject: [PATCH 22/39] style: apply hosted native memory formatter output --- .github/workflows/quality.yml | 33 +------------------ .../src/compute/native_cpu/cache_hint.rs | 10 ++---- .../compute/native_cpu/cache_hint/tests.rs | 12 +++---- .../src/compute/spare_capacity/memory.rs | 12 +++++-- .../compute/spare_capacity/memory/tests.rs | 5 +-- docs/IMPLEMENTATION_STATUS.md | 6 ++++ 6 files changed, 27 insertions(+), 51 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 19dba916b..7dd3b1ed2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -90,42 +90,11 @@ jobs: python3 -B tests/integration/test-weather-client-runner.py - name: Check formatting and shell sources - id: formatting run: | python3 -B scripts/test-diagnose-backport-lock.py - if cargo fmt --all -- --check; then - : - else - format_status=$? - # Temporary source-only aid for this candidate; the gate still fails. - # No Rust formatter or compiler runs on the development host. - if test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa && \ - test "$RUNNER_ENVIRONMENT" = github-hosted && \ - test "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" = feature/native-cpu-inference; then - format_dir="$RUNNER_TEMP/native-memory-format" - test ! -e "$format_dir" - mkdir -m 0700 "$format_dir" - git rev-parse HEAD > "$format_dir/source.txt" - cargo fmt --all - git diff --no-ext-diff --binary -- crates/volparossa/src/compute.rs \ - crates/volparossa/src/compute/ > "$format_dir/format.patch" - printf 'native_memory_format=true\n' >> "$GITHUB_OUTPUT" - fi - exit "$format_status" - fi + cargo fmt --all -- --check ./scripts/check-shell.sh - - name: Preserve native memory candidate formatting diff - if: ${{ failure() && steps.formatting.outputs.native_memory_format == 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: native-memory-format-${{ github.run_id }} - path: | - ${{ runner.temp }}/native-memory-format/source.txt - ${{ runner.temp }}/native-memory-format/format.patch - if-no-files-found: error - retention-days: 7 - - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs index 73cc1d2a9..227ee408b 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -73,13 +73,9 @@ impl Binding { diagnostic: bool, ) { let observe = diagnostic && memory::enabled(); - let record = self.observe_with( - result, - profile, - observe, - memory::sample, - |file, bytes| posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED), - ); + let record = self.observe_with(result, profile, observe, memory::sample, |file, bytes| { + posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED) + }); if observe { memory::emit(&record); } diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs index 3a68064d7..637b6a480 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -416,11 +416,7 @@ fn advice_observation_reports_one_syscall_and_closed_errno_without_reclaim_claim |stats| { assert!(stats); samples.set(samples.get() + 1); - memory::Sample::for_test(if samples.get() == 1 { - Some(0) - } else { - None - }) + memory::Sample::for_test(if samples.get() == 1 { Some(0) } else { None }) }, |_, bytes| { assert_eq!(bytes, 1); @@ -434,7 +430,11 @@ fn advice_observation_reports_one_syscall_and_closed_errno_without_reclaim_claim assert!(matches!(original, Ok("original result"))); assert_eq!( record["outcome"], - if expected.is_some() { "error" } else { "advised" } + if expected.is_some() { + "error" + } else { + "advised" + } ); assert_eq!(record["syscall_error"], json!(expected)); assert_eq!(record["after"]["missing_stage"], "meminfo"); diff --git a/crates/volparossa/src/compute/spare_capacity/memory.rs b/crates/volparossa/src/compute/spare_capacity/memory.rs index 4310d56cf..7989dd80e 100644 --- a/crates/volparossa/src/compute/spare_capacity/memory.rs +++ b/crates/volparossa/src/compute/spare_capacity/memory.rs @@ -120,8 +120,16 @@ impl Sample { Self { headroom, host: headroom, - missing: if headroom.is_some() { "none" } else { "meminfo" }, - io_kind: if headroom.is_some() { "none" } else { "invalid_data" }, + missing: if headroom.is_some() { + "none" + } else { + "meminfo" + }, + io_kind: if headroom.is_some() { + "none" + } else { + "invalid_data" + }, ..Self::default() } } diff --git a/crates/volparossa/src/compute/spare_capacity/memory/tests.rs b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs index 5852b0980..cfdcb13b8 100644 --- a/crates/volparossa/src/compute/spare_capacity/memory/tests.rs +++ b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs @@ -131,10 +131,7 @@ fn admission_sampler_preserves_original_decisions_and_read_order() { for used in &variants { for parent in &variants { let mut files = files(); - files.insert( - Path::new(ROOT).join(GROUP).join("memory.max"), - own.clone(), - ); + files.insert(Path::new(ROOT).join(GROUP).join("memory.max"), own.clone()); files.insert( Path::new(ROOT).join(GROUP).join("memory.current"), used.clone(), diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 77d6d81b2..35d87c124 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -41,6 +41,12 @@ tests pass, including an independent rerun. Sixteen new Rust tests still require hosted compilation and execution. These observations are not a causal fix or completion of the coding task or protected peer execution. +Initial hosted check `38702380046` stops at formatting, before Rust tests. Its +source-bound formatter artifact supplies five layout-only corrections in four +files; the temporary artifact step is removed after applying them. Artifact ZIP +SHA-256: `e4471084d545a26e9aa1850cee46a270cf1cbe06e93590dfb6f22201662065cb`. +No local Rust compilation or formatting runs. + #### Tool-call output constraint candidate - [ ] Continue the existing native OpenCode integration after actual Code run From 60a7386bd5dedf614a08be497b41963f842c9512 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:24:56 +0200 Subject: [PATCH 23/39] fix: satisfy native memory diagnostic lint gates --- .../src/compute/native_cpu/cache_hint.rs | 4 +-- .../compute/native_cpu/cache_hint/tests.rs | 5 +++- docs/IMPLEMENTATION_STATUS.md | 26 ++++++++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs index 227ee408b..6945f5e2d 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -112,11 +112,11 @@ impl Binding { } else if let Ok(bytes) = libc::off_t::try_from(hint.identity.bytes) { // Preserve one synchronous, unchanged-file advice call under the lease. // The following observation is not proof that this hint freed memory. - let advice = advise(&hint.file, bytes); + let syscall = advise(&hint.file, bytes); if observe { record["after"] = sample(true).snapshot(); } - if let Err(error) = advice { + if let Err(error) = syscall { record["syscall_error"] = json!(match error { nix::errno::Errno::EBADF => "bad_fd", nix::errno::Errno::EINVAL => "invalid", diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs index 637b6a480..6f7924704 100644 --- a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -337,7 +337,10 @@ fn cache_observation_preserves_cleanup_and_cancel_priority_without_private_error |_| panic!("sample before confirmed cleanup"), |_, _| panic!("hint before confirmed cleanup"), ); - assert_eq!(result.unwrap_err().to_string(), original); + let Err(preserved_error) = result else { + panic!("supervisor failure was replaced by success"); + }; + assert_eq!(preserved_error.to_string(), original); assert_eq!(record["outcome"], "supervisor_error"); assert_eq!(record["supervisor_ok"], false); records.push(record); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 35d87c124..905cd7ad0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -37,9 +37,8 @@ estimates nor proof of reclaim. No cgroup names, paths, prompts or model output are exported. The existing cleanup, single advisory syscall, result, model and resource limits remain unchanged. The collector keeps its default v1 shape and adds a bounded v2 memory record list only when requested. All 31 inert collector -tests pass, including an independent rerun. Sixteen new Rust tests still require -hosted compilation and execution. These observations are not a causal fix or -completion of the coding task or protected peer execution. +tests pass, including an independent rerun. These observations are not a causal +fix or completion of the coding task or protected peer execution. Initial hosted check `38702380046` stops at formatting, before Rust tests. Its source-bound formatter artifact supplies five layout-only corrections in four @@ -47,6 +46,25 @@ files; the temporary artifact step is removed after applying them. Artifact ZIP SHA-256: `e4471084d545a26e9aa1850cee46a270cf1cbe06e93590dfb6f22201662065cb`. No local Rust compilation or formatting runs. +[Quality `38702928794`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38702928794) +on `0091e75f` passes formatting, 60 native Python contracts and the targeted +Rust suites: 28 native/cache-hint, seven admission and seven sampler tests, +including all sixteen new cases. The collector's 31 checks pass on the hosted +runner as well. The original merge checkout has the exact candidate tree. +Strict Clippy then rejects a similar variable name and a literal `unwrap_err` +in a test; workspace tests and subsequent gates are skipped. The correction +renames only that binding and explicitly matches the expected error while +retaining the original error-text assertion. Full Quality is still required. +Original job-log ZIP SHA-256: +`47fce1d4bc03c78984c4e3e78b2a8c8cfaacb7e8e927df28203c6f35f526b748`. + +Code `8b214da1` pins `0091e75f`, opts into these closed native observations and +retains the existing 256 KiB public-export ceiling. Torch's diagnostic calls +and 64 KiB guest-receipt limit remain unchanged. Its exact-source +[CI `38703873328`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38703873328) +passes 410 tests with four explicit skips. No new model trial has run; Code +must first pin the corrected core and pass the required source checks. + #### Tool-call output constraint candidate - [ ] Continue the existing native OpenCode integration after actual Code run @@ -471,7 +489,7 @@ The immediate milestones build on the retained candidates and evidence below: | Native Overmorrow — `volparossa`, `volparossa-weather` | Complete actual core delivery through Android's initial, refresh and background/resume lifecycle; then verify expiry behavior with real products. | The installed-app lifecycle, including expiry, passes with synthetic products (Weather PR19). After Weather PR24's bounded observation correction, the actual installed-app trial returns success, but the guest driver exits unsuccessfully during finalization. Core PR248 adds closed diagnostics for that later failure; joined acceptance and actual expiry, physical devices and iOS remain open. | | Persistent private storage maintenance — `volparossa`, then Image/Cloud | Resume owner-authorized maintenance after restart, then publish, repair and restore through actual consumers after provider replacement. | The base lifecycle passes, but the latest completed trial stops during Cloud FINALIZE's signed-challenge exchange, before local READY. PR232 adds receiving-side activity diagnostics; these are not a demonstrated fix. Consumer repair, restore, remote cleanup and reciprocal capacity remain unproven. Cloud PR234 waits for this shared integration. | | Automatic private uploads — `volparossa`, Image/Cloud | Enroll a new encrypted upload under finite service authority, then resume maintenance and restore it after provider loss. | Core PR233 adds an absolute authorization ceiling; Image PR7 forwards it without rebasing. Cloud PR14 implements finite shared admission budgets, durable reservation/enrollment, restart reconciliation and an encrypted empty catalog for a first upload. Actual OpenCloud SDK upload/list/read checks pass with a synthetic core; this does not prove peer maintenance. Join these candidates to the real upload-to-repair-to-restore trial after the existing storage milestone; its pinned consumers stay unchanged. | -| Native coding execution — `volparossa`, `volparossa-code` | Complete an actual OpenCode read, edit and independently verified test through the core. | One native model-generated read completes, but six subsequent requests are refused by memory admission. Core PR212 and Code PR11 now identify that refusal; safe release of unused model-file cache after confirmed worker cleanup is the next candidate, not a proven cause or fix. Model, task and memory limits remain unchanged. This development executor is not confidential peer execution or completion of the shared-brain requirement. | +| Native coding execution — `volparossa`, `volparossa-code` | Complete an actual OpenCode read, edit and independently verified test through the core. | The latest actual trial still completes only one read before six memory-admission refusals. It does not show whether cache advice ran or why headroom remained insufficient. Core PR212 and Code PR11 add closed observations of the existing admission sample and advisory outcome; targeted Rust tests pass, but the two Clippy corrections still require full Quality before another model trial. Model, task and memory limits remain unchanged. This development executor is not confidential peer execution or completion of the shared-brain requirement. | | Protected remote compute — `volparossa`, then Code/Browser | Verify quote exchange on the production route, then execute a private task on another node with verified protection. | PR246 and PR247 are merged. The quote-only slice passes appraisal, guest, agent/control/CLI and quote software checks. Production authority, current vendor appraisal, approved measured workloads and actual protected execution remain open. Suitable confidential-computing hardware is still unavailable; local coding and storage integration continue without substituting for this requirement. | ### Latest completed integration evidence — 15 October From 126e96ae6a9f1b27cc3ba09393c1071369cbe8f3 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:03:09 +0200 Subject: [PATCH 24/39] fix(compute): advise unchanged native source shards after verified cleanup --- .github/workflows/quality.yml | 1 + crates/volparossa/src/compute.rs | 7 + crates/volparossa/src/compute/native_cpu.rs | 1 + .../compute/native_cpu/source_weight_cache.rs | 257 ++++++++ .../native_cpu/source_weight_cache/tests.rs | 614 ++++++++++++++++++ ...est-native-source-weight-cache-contract.py | 59 ++ 6 files changed, 939 insertions(+) create mode 100644 crates/volparossa/src/compute/native_cpu/source_weight_cache.rs create mode 100644 crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs create mode 100644 tests/integration/test-native-source-weight-cache-contract.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7dd3b1ed2..0ff481dea 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -105,6 +105,7 @@ jobs: cargo test --locked -p volparossa compute::resources::tests cargo test --locked -p volparossa compute::spare_capacity::memory::tests python3 -B tests/integration/test-agent-private-conversation.py + python3 -B tests/integration/test-native-source-weight-cache-contract.py - name: Check bounded bidirectional streaming before audit tool builds # In-memory duplex and real TLS only; no host sockets or namespace setup. diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index b11c7cbbd..c0d16205e 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -286,6 +286,12 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result) -> Result Self { + Self { + device: info.dev(), + inode: info.ino(), + owner: info.uid(), + links: info.nlink(), + mode: info.mode(), + bytes: info.len(), + modified: (info.mtime(), info.mtime_nsec()), + changed: (info.ctime(), info.ctime_nsec()), + } + } + + fn valid(self, expected_bytes: u64) -> bool { + self.mode & libc::S_IFMT == libc::S_IFREG + && self.owner == nix::unistd::geteuid().as_raw() + && self.links == 1 + && self.mode & 0o077 == 0 + && self.bytes == expected_bytes + && (1..=4 * 1024 * 1024 * 1024).contains(&self.bytes) + } +} + +struct Shard { + file: File, + name: &'static str, + identity: Identity, +} + +impl Shard { + fn bind(root: &Path, spec: &ModelFileSpec) -> std::result::Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(root.join(spec.name)) + .map_err(|_| "open_failed")?; + let identity = Identity::read(&file.metadata().map_err(|_| "file_invalid")?); + if !identity.valid(spec.bytes) { + return Err("file_invalid"); + } + Ok(Self { + file, + name: spec.name, + identity, + }) + } + + fn unchanged(&self, root: &Path) -> bool { + let check = || -> Option { + let descriptor = Identity::read(&self.file.metadata().ok()?); + let path = Identity::read(&fs::symlink_metadata(root.join(self.name)).ok()?); + Some(descriptor == self.identity && path == self.identity) + }; + check() == Some(true) + } +} + +struct Hints { + root: PathBuf, + root_identity: (u64, u64), + shards: [Shard; 3], +} + +impl Hints { + // Production callers pass only PROFILE.sharded_weight_files()[1..]. There is + // no user filename, remote index, glob, recursive traversal or content read. + fn bind(root: &Path, specs: &[ModelFileSpec; 3]) -> std::result::Result { + private_directory(root).map_err(|_| "root_invalid")?; + let directory = fs::symlink_metadata(root).map_err(|_| "root_invalid")?; + let hints = Self { + root: root.to_owned(), + root_identity: (directory.dev(), directory.ino()), + shards: [ + Shard::bind(root, &specs[0])?, + Shard::bind(root, &specs[1])?, + Shard::bind(root, &specs[2])?, + ], + }; + if hints.parent_unchanged() && hints.shards.iter().all(|shard| shard.unchanged(root)) { + Ok(hints) + } else { + Err("path_changed") + } + } + + fn parent_unchanged(&self) -> bool { + private_directory(&self.root).is_ok() + && fs::symlink_metadata(&self.root) + .is_ok_and(|info| (info.dev(), info.ino()) == self.root_identity) + } +} + +pub(in crate::compute) struct Binding { + hints: Option, + reason: &'static str, +} + +impl Binding { + pub(in crate::compute) fn bind( + request: &Request, + options: &Options, + profile: ModelProfile, + model_root: &Path, + ) -> Self { + let unavailable = |reason| Self { + hints: None, + reason, + }; + if profile != PROFILE + || request.inference_backend != Some(KIND) + || request.native_backend_sha256 != options.native_backend_sha256 + || options.native_backend_root.is_none() + || request.expected_report.is_none() + { + return unavailable("not_applicable"); + } + let Some([_, first, second, third]) = PROFILE.sharded_weight_files() else { + return unavailable("not_applicable"); + }; + match Hints::bind(model_root, &[*first, *second, *third]) { + Ok(hints) => Self { + hints: Some(hints), + reason: "bound", + }, + Err(reason) => unavailable(reason), + } + } + + pub(in crate::compute) fn after_supervisor(self, result: &Result, diagnostic: bool) { + // Separate opt-in: the existing memory diagnostic target/marker/schema + // remains unchanged and old collectors ignore this new marker entirely. + let observe = diagnostic + && tracing::enabled!(target: "volparossa::compute::private_source_weight_cache_diagnostic", tracing::Level::DEBUG); + let record = self.observe_with(result, observe, memory::sample, |file, bytes| { + posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED) + }); + if observe { + tracing::debug!(target: "volparossa::compute::private_source_weight_cache_diagnostic", + "private_source_weight_cache_diagnostic {record}"); + } + } + + fn observe_with( + self, + result: &Result, + observe: bool, + mut sample: impl FnMut(bool) -> memory::Sample, + mut advise: impl FnMut(&File, libc::off_t) -> nix::Result<()>, + ) -> Value { + let mut record = json!({"version":1,"phase":"source_weights","binding":self.reason, + "outcome":"supervisor_error","supervisor_ok":result.is_ok(), + "before":null,"after":null,"shards":[]}); + let Ok(result) = result else { return record }; + let Some(hints) = self.hints else { + record["outcome"] = json!("not_bound"); + return record; + }; + // The real caller has already validated this after joining every worker + // lifetime. Retain an explicit closed provenance gate at the advice edge. + if private_task::validate_model_weights(&result["model"], PROFILE).is_err() { + record["outcome"] = json!("provenance_unconfirmed"); + return record; + } + let before = sample(observe); + if observe { + record["before"] = before.snapshot(); + } + let Some(headroom) = before.headroom else { + record["outcome"] = json!("headroom_unknown"); + return record; + }; + if resources::admits(PROFILE, Some(headroom)) { + record["outcome"] = json!("headroom_sufficient"); + return record; + } + if !hints.parent_unchanged() + || !hints.shards.iter().all(|shard| shard.unchanged(&hints.root)) + { + record["outcome"] = json!("identity_changed"); + return record; + } + let mut shards = Vec::with_capacity(3); + for (index, shard) in hints.shards.iter().enumerate() { + let mut item = + json!({"ordinal":index+1,"outcome":"identity_changed","syscall_error":null}); + if hints.parent_unchanged() && shard.unchanged(&hints.root) { + if let Ok(bytes) = libc::off_t::try_from(shard.identity.bytes) { + match advise(&shard.file, bytes) { + Ok(()) => item["outcome"] = json!("advised"), + Err(error) => { + item["outcome"] = json!("error"); + item["syscall_error"] = json!(syscall_kind(error)); + } + } + } else { + item["outcome"] = json!("range_invalid"); + } + } + shards.push(item); + } + record["outcome"] = json!("shards_checked"); + record["shards"] = json!(shards); + if observe { + // One sequential observation, never an admission or reclaim proof. + record["after"] = sample(true).snapshot(); + } + record + } +} + +fn syscall_kind(error: nix::errno::Errno) -> &'static str { + match error { + nix::errno::Errno::EBADF => "bad_fd", + nix::errno::Errno::EINVAL => "invalid", + nix::errno::Errno::ENOSYS | nix::errno::Errno::EOPNOTSUPP => "unsupported", + nix::errno::Errno::EINTR => "interrupted", + _ => "other", + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs new file mode 100644 index 000000000..4f54aa2ad --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs @@ -0,0 +1,614 @@ +//! Inert metadata/advice tests. Tiny fixtures do not prove real model execution. + +use std::{ + cell::Cell, + os::unix::fs::{PermissionsExt as _, symlink}, + time::{Duration, SystemTime}, +}; + +use nix::fcntl::{FcntlArg, fcntl}; + +use super::super::tests::fixture as native_fixture; +use super::*; + +fn specs() -> [ModelFileSpec; 3] { + let fixed: &[ModelFileSpec; 3] = PROFILE.sharded_weight_files().unwrap()[1..] + .try_into() + .unwrap(); + fixed.map(|spec| ModelFileSpec { bytes: 1, ..spec }) +} + +fn fixture() -> (tempfile::TempDir, Binding) { + let root = tempfile::tempdir().unwrap(); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + for spec in specs() { + fs::write(root.path().join(spec.name), b"x").unwrap(); + fs::set_permissions(root.path().join(spec.name), fs::Permissions::from_mode(0o600)) + .unwrap(); + } + let hints = Hints::bind(root.path(), &specs()).unwrap(); + ( + root, + Binding { + hints: Some(hints), + reason: "bound", + }, + ) +} + +fn model_result() -> Value { + let files = PROFILE.sharded_weight_files().unwrap(); + let model_files: serde_json::Map = files + .iter() + .map(|file| { + ( + file.name.to_owned(), + json!({"bytes":file.bytes,"sha256":file.sha256}), + ) + }) + .collect(); + json!({"model":{"files":model_files,"weights":{ + "layout":"safetensors_shards_concat_v1", + "bytes":PROFILE.spec().weights_bytes,"sha256":PROFILE.spec().weights_sha256, + "files":files[1..].iter().map(|file|file.name).collect::>() + }}}) +} + +fn observe(binding: Binding, result: &Result) -> Value { + binding.observe_with( + result, + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| Ok(()), + ) +} + +#[test] +fn source_binding_is_native_owner_authorized_and_exactly_three_fixed_shards() { + let (_native, mut options) = native_fixture(); + let request = options.request(PROFILE).unwrap(); + let (root, _small) = fixture(); + // Sparse length metadata only; no allocation/read of model bytes or model claim. + for spec in &PROFILE.sharded_weight_files().unwrap()[1..] { + OpenOptions::new() + .write(true) + .open(root.path().join(spec.name)) + .unwrap() + .set_len(spec.bytes) + .unwrap(); + } + let binding = Binding::bind(&request, &options, PROFILE, root.path()); + let hints = binding.hints.unwrap(); + assert_eq!(hints.shards.len(), 3); + for (shard, spec) in hints + .shards + .iter() + .zip(&PROFILE.sharded_weight_files().unwrap()[1..]) + { + assert_eq!(shard.name, spec.name); + assert_eq!(shard.identity.bytes, spec.bytes); + } + assert!(!root.path().join("model.safetensors.index.json").exists()); + assert!( + Binding::bind(&request, &options, ModelProfile::Qwen600, root.path()) + .hints + .is_none() + ); + assert!( + Binding::bind(&request, &Options::default(), PROFILE, root.path()) + .hints + .is_none() + ); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!( + Binding::bind(&request, &options, PROFILE, root.path()) + .hints + .is_none() + ); + let legacy = Options::default().request(PROFILE).unwrap(); + assert!( + Binding::bind(&legacy, &options, PROFILE, root.path()) + .hints + .is_none() + ); +} + +#[test] +fn source_descriptors_are_readonly_cloexec_nonblocking_and_exact() { + let (root, binding) = fixture(); + let hints = binding.hints.unwrap(); + assert!(hints.parent_unchanged()); + for shard in hints.shards { + assert!(shard.unchanged(root.path())); + assert_ne!( + fcntl(&shard.file, FcntlArg::F_GETFD).unwrap() & libc::FD_CLOEXEC, + 0 + ); + let flags = fcntl(&shard.file, FcntlArg::F_GETFL).unwrap(); + assert_eq!(flags & libc::O_ACCMODE, libc::O_RDONLY); + assert_ne!(flags & libc::O_NONBLOCK, 0); + assert_ne!(flags & libc::O_NOFOLLOW, 0); + } +} + +#[test] +fn source_identity_rejects_foreign_owner_permissions_links_type_and_size() { + let (_root, binding) = fixture(); + let identity = binding.hints.unwrap().shards[0].identity; + assert!(identity.valid(1)); + assert!(!identity.valid(2)); + for changed in [ + Identity { + owner: identity.owner.wrapping_add(1), + ..identity + }, + Identity { + mode: identity.mode | 0o040, + ..identity + }, + Identity { + mode: identity.mode | 0o004, + ..identity + }, + Identity { + mode: libc::S_IFDIR | 0o600, + ..identity + }, + Identity { + links: 2, + ..identity + }, + Identity { + bytes: 0, + ..identity + }, + Identity { + bytes: 5 * 1024 * 1024 * 1024, + ..identity + }, + ] { + assert!(!changed.valid(changed.bytes)); + } +} + +#[test] +fn source_bind_rejects_symlink_hardlink_nonregular_and_unprivate_root() { + for change in 0..6 { + let (root, _binding) = fixture(); + let path = root.path().join(specs()[1].name); + let original = root.path().join("original"); + match change { + 0 => { + fs::rename(&path, &original).unwrap(); + symlink("original", &path).unwrap(); + } + 1 => fs::hard_link(&path, &original).unwrap(), + 2 => { + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + } + 3 => fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(), + 4 => fs::write(&path, b"xx").unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + assert!(Hints::bind(root.path(), &specs()).is_err()); + } + let (root, _binding) = fixture(); + let alias = root.path().join("alias"); + symlink(root.path(), &alias).unwrap(); + assert!(Hints::bind(&alias, &specs()).is_err()); +} + +#[test] +fn source_changes_after_binding_skip_every_advice() { + for change in 0..6 { + let (root, binding) = fixture(); + let path = root.path().join(specs()[0].name); + match change { + 0 => { + fs::rename(&path, root.path().join("old")).unwrap(); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + 1 => fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).unwrap(), + 2 => fs::hard_link(&path, root.path().join("other-link")).unwrap(), + 3 => fs::write(&path, b"xx").unwrap(), + 4 => binding.hints.as_ref().unwrap().shards[0] + .file + .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + .unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| panic!("changed source advised"), + ); + assert_eq!(record["outcome"], "identity_changed"); + assert_eq!(record["shards"], json!([])); + assert!(record["after"].is_null()); + } +} + +#[test] +fn source_parent_replacement_cannot_rebind_retained_descriptors() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("model"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + for spec in specs() { + fs::write(root.join(spec.name), b"x").unwrap(); + fs::set_permissions(root.join(spec.name), fs::Permissions::from_mode(0o600)).unwrap(); + } + let hints = Hints::bind(&root, &specs()).unwrap(); + let saved = directory.path().join("saved"); + fs::rename(&root, &saved).unwrap(); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + // Same retained files at the original lexical names, but a different parent. + for spec in specs() { + fs::rename(saved.join(spec.name), root.join(spec.name)).unwrap(); + } + assert!(!hints.parent_unchanged()); + let record = Binding { + hints: Some(hints), + reason: "bound", + } + .observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| panic!("replaced parent advised"), + ); + assert_eq!(record["outcome"], "identity_changed"); +} + +#[test] +fn source_unbound_files_do_not_sample_or_advise() { + let record = Binding { + hints: None, + reason: "file_invalid", + } + .observe_with( + &Ok(model_result()), + true, + |_| panic!("unbound sample"), + |_, _| panic!("unbound advice"), + ); + assert_eq!(record["outcome"], "not_bound"); + assert_eq!(record["binding"], "file_invalid"); + assert_eq!(record["shards"], json!([])); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); +} + +#[test] +fn source_parent_change_during_advice_skips_remaining_descriptors() { + let (root, binding) = fixture(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + Ok(()) + }, + ); + assert_eq!(calls.get(), 1); + assert_eq!(record["shards"][0]["outcome"], "advised"); + assert_eq!(record["shards"][1]["outcome"], "identity_changed"); + assert_eq!(record["shards"][2]["outcome"], "identity_changed"); +} + +#[test] +fn source_sufficient_after_gguf_never_uses_previous_shortage() { + let (_root, binding) = fixture(); + let required = resources::limits(PROFILE).admission_headroom.unwrap(); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(required)), + |_, _| panic!("stale shortage used"), + ); + assert_eq!(record["outcome"], "headroom_sufficient"); + assert_eq!(record["shards"], json!([])); + assert!(record["after"].is_null()); + assert!(resources::admits(PROFILE, Some(required))); +} + +#[test] +fn source_corrupted_report_pin_cannot_authorize_advice() { + let (_root, binding) = fixture(); + let mut report = model_result(); + report["model"]["files"][specs()[0].name]["sha256"] = json!("0".repeat(64)); + let record = binding.observe_with( + &Ok(report), + true, + |_| panic!("corrupted provenance sample"), + |_, _| panic!("corrupted provenance advice"), + ); + assert_eq!(record["outcome"], "provenance_unconfirmed"); + assert_eq!(record["shards"], json!([])); +} + +#[test] +fn source_binding_failure_keeps_finite_nonprivate_reason() { + let record = observe( + Binding { + hints: None, + reason: "open_failed", + }, + &Ok(model_result()), + ); + assert_eq!(record["binding"], "open_failed"); + assert_eq!(record["outcome"], "not_bound"); +} + +#[test] +fn source_advice_requires_successful_supervisor_and_checked_provenance() { + for outcome in ["supervisor_error", "provenance_unconfirmed"] { + let (_root, binding) = fixture(); + let result = if outcome == "supervisor_error" { + Err(super::super::super::private_task::CleanupUnconfirmed.into()) + } else { + Ok(json!({"model":{"files":{},"weights":{}}})) + }; + let record = binding.observe_with( + &result, + true, + |_| panic!("unverified sample"), + |_, _| panic!("unverified advice"), + ); + assert_eq!(record["outcome"], outcome); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); + assert_eq!(record["shards"], json!([])); + } +} + +#[test] +fn source_advice_requires_known_low_headroom_with_unchanged_admission() { + let required = resources::limits(PROFILE).admission_headroom.unwrap(); + assert_eq!(required, 11_274_289_152); + for (headroom, outcome) in [ + (None, "headroom_unknown"), + (Some(required), "headroom_sufficient"), + (Some(u64::MAX), "headroom_sufficient"), + ] { + let (_root, binding) = fixture(); + let samples = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| { + samples.set(samples.get() + 1); + memory::Sample::for_test(headroom) + }, + |_, _| panic!("no known shortage"), + ); + assert_eq!(record["outcome"], outcome); + assert_eq!(samples.get(), 1); + assert!(record["after"].is_null()); + assert_eq!(record["shards"], json!([])); + assert_eq!(resources::admits(PROFILE, headroom), headroom.is_some()); + } +} + +#[test] +fn source_one_shot_advises_exact_three_without_changing_original_result() { + let (_root, binding) = fixture(); + let identities = binding + .hints + .as_ref() + .unwrap() + .shards + .each_ref() + .map(|shard| shard.identity); + let result = Ok(model_result()); + let original = result.as_ref().unwrap().clone(); + let mut calls = 0; + let mut samples = 0; + let record = binding.observe_with( + &result, + true, + |stats| { + assert!(stats); + samples += 1; + memory::Sample::for_test(Some(0)) + }, + |file, bytes| { + assert_eq!(bytes, 1); + assert_eq!(Identity::read(&file.metadata().unwrap()), identities[calls]); + calls += 1; + Ok(()) + }, + ); + assert_eq!(calls, 3); + assert_eq!(samples, 2); + assert_eq!(record["outcome"], "shards_checked"); + assert_eq!( + record["shards"], + json!([ + {"ordinal":1,"outcome":"advised","syscall_error":null}, + {"ordinal":2,"outcome":"advised","syscall_error":null}, + {"ordinal":3,"outcome":"advised","syscall_error":null} + ]) + ); + assert_eq!(result.unwrap(), original); + assert!(!resources::admits(PROFILE, Some(0))); +} + +#[test] +fn source_syscall_failure_is_finite_without_retry_or_result_override() { + let (_root, binding) = fixture(); + let result = Ok(model_result()); + let original = result.as_ref().unwrap().clone(); + let calls = Cell::new(0); + let record = binding.observe_with( + &result, + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + Err(nix::errno::Errno::EINTR) + }, + ); + assert_eq!(calls.get(), 3); + for item in record["shards"].as_array().unwrap() { + assert_eq!(item["outcome"], "error"); + assert_eq!(item["syscall_error"], "interrupted"); + } + assert_eq!(result.unwrap(), original); + for (error, expected) in [ + (nix::errno::Errno::EBADF, "bad_fd"), + (nix::errno::Errno::EINVAL, "invalid"), + (nix::errno::Errno::ENOSYS, "unsupported"), + (nix::errno::Errno::EOPNOTSUPP, "unsupported"), + (nix::errno::Errno::EACCES, "other"), + ] { + assert_eq!(syscall_kind(error), expected); + } +} + +#[test] +fn source_rechecks_remaining_files_between_advisory_calls() { + let (root, binding) = fixture(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + if calls.get() == 1 { + fs::set_permissions( + root.path().join(specs()[1].name), + fs::Permissions::from_mode(0o400), + ) + .unwrap(); + } + Ok(()) + }, + ); + assert_eq!(calls.get(), 2); + assert_eq!(record["shards"][0]["outcome"], "advised"); + assert_eq!(record["shards"][1]["outcome"], "identity_changed"); + assert_eq!(record["shards"][2]["outcome"], "advised"); +} + +#[test] +fn source_observation_is_bounded_and_does_not_export_private_identities() { + let (root, binding) = fixture(); + let record = observe(binding, &Ok(model_result())); + let raw = serde_json::to_string(&record).unwrap(); + assert!("private_source_weight_cache_diagnostic ".len() + raw.len() + 1 <= 4096); + assert!(!raw.contains(root.path().to_str().unwrap())); + for forbidden in ["safetensors", "sha256", "inode", "owner", "reclaimed", "admitted"] { + assert!(!raw.contains(forbidden)); + } + assert_eq!(record.as_object().unwrap().len(), 8); + assert_eq!(record["version"], 1); + assert_eq!(record["phase"], "source_weights"); + assert_eq!(record["binding"], "bound"); + assert_eq!(record["supervisor_ok"], true); +} + +#[test] +fn source_disabled_observation_preserves_advice_but_no_extra_sampling() { + let (_root, binding) = fixture(); + let samples = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + false, + |stats| { + assert!(!stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| Ok(()), + ); + assert_eq!(samples.get(), 1); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); +} + +#[derive(Clone)] +struct Log(std::sync::Arc>>); + +impl std::io::Write for Log { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[test] +fn source_logging_requires_scope_and_its_own_explicit_debug_opt_in() { + for diagnostic in [false, true] { + for filter in [ + "off", + "off,volparossa::compute::private_diagnostic=debug", + "off,volparossa::compute::private_source_weight_cache_diagnostic=debug", + ] { + let log = Log(std::sync::Arc::default()); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_level(false) + .with_env_filter(filter) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + let binding = Binding { + hints: None, + reason: "not_applicable", + }; + // Never invokes a real advisory or sampler in this logging test. + binding.after_supervisor(&Err(anyhow::anyhow!("PRIVATE_CANARY")), diagnostic); + }); + let raw = log.0.lock().unwrap(); + if diagnostic && filter.contains("private_source_weight_cache_diagnostic=") { + let text = std::str::from_utf8(&raw).unwrap(); + let record: Value = serde_json::from_str( + text.trim() + .strip_prefix("private_source_weight_cache_diagnostic ") + .unwrap(), + ) + .unwrap(); + assert_eq!(record["outcome"], "supervisor_error"); + assert_eq!(record["supervisor_ok"], false); + assert!(!text.contains("PRIVATE_CANARY")); + } else { + assert!(raw.is_empty()); + } + } + } +} + +#[test] +fn source_advice_stays_after_gguf_and_successful_join_inside_runtime_lease() { + let source = include_str!("../../../compute.rs"); + let lease = source.find("let _lease = runtime_lease").unwrap(); + let admission = source.find("resources::admission(options.model_profile").unwrap(); + let binding = source.find("let source_weight_cache = ").unwrap(); + let spawn = source[binding..].find("let child = sandbox::command").unwrap() + binding; + let join = source.find("let result = supervise::run(child").unwrap(); + let gguf = source.find("cache_hint.after_supervisor(&result").unwrap(); + let advice = source.find("source_weight_cache.after_supervisor(&result").unwrap(); + assert!(lease < admission && admission < binding && binding < spawn); + assert!(spawn < join && join < gguf && gguf < advice); + assert!( + source[advice..].starts_with( + "source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}" + ) + ); +} diff --git a/tests/integration/test-native-source-weight-cache-contract.py b/tests/integration/test-native-source-weight-cache-contract.py new file mode 100644 index 000000000..eaabd5766 --- /dev/null +++ b/tests/integration/test-native-source-weight-cache-contract.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Inert legacy-export and source-wiring checks; no Rust, model or cache syscall.""" +import json +import os +from pathlib import Path +import runpy +import tempfile +import unittest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +LEGACY = runpy.run_path(str(HERE / 'agent-private-conversation.py')) +MARKER = b'private_source_weight_cache_diagnostic ' + + +class SourceWeightCacheContract(unittest.TestCase): + def collect(self, raw, enabled): + with tempfile.TemporaryDirectory(prefix='source-cache-contract-') as temporary: + path = Path(temporary) / 'service.log' + with path.open('xb') as stream: + stream.write(raw) + os.chmod(path, 0o600) + return LEGACY['service_diagnostic'](path, memory_enabled=enabled) + + def test_historical_export_versions_ignore_separate_source_marker_exactly(self): + event = dict(version=1, phase='task', detail=dict(code='compute_model_memory_headroom', + io_kind='none', exit_code=None, signal=None, stderr_class=None)) + original = b'private_execution_diagnostic ' + json.dumps(event).encode() + b'\n' + # Even an unknown future payload cannot contaminate the historical fields. + extra = MARKER + b'{"version":1,"phase":"source_weights","PRIVATE_CANARY":true}\n' + for enabled in (False, True): + with self.subTest(memory_enabled=enabled): + expected = self.collect(original, enabled) + self.assertEqual(self.collect(original + extra, enabled), expected) + self.assertEqual(expected['version'], 2 if enabled else 1) + self.assertFalse(expected['unrecognized_record']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(expected)) + + def test_observation_target_and_marker_are_separate_from_old_export(self): + source = (ROOT / 'crates/volparossa/src/compute/native_cpu/source_weight_cache.rs').read_text() + self.assertIn('volparossa::compute::private_source_weight_cache_diagnostic', source) + self.assertIn(MARKER.decode() + '{record}', source) + self.assertNotIn('private_memory_diagnostic ', source) + self.assertNotIn('private_execution_diagnostic ', source) + + def test_existing_gguf_call_remains_before_new_advice_under_same_lease(self): + source = (ROOT / 'crates/volparossa/src/compute.rs').read_text() + order = [source.index(fragment) for fragment in ( + 'let _lease = runtime_lease', 'resources::admission(options.model_profile', + 'let source_weight_cache = ', 'let child = sandbox::command(options)', + 'let result = supervise::run(child', + 'cache_hint.after_supervisor(&result, options.model_profile, memory_diagnostic);', + 'source_weight_cache.after_supervisor(&result, memory_diagnostic);')] + self.assertEqual(order, sorted(order)) + self.assertIn('source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}', source) + + +if __name__ == '__main__': + unittest.main() From 5984797e55467d8e2d513f3a188589ec945d6dd3 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:11:46 +0200 Subject: [PATCH 25/39] style(compute): apply hosted formatter output to source-cache candidate --- .../compute/native_cpu/source_weight_cache.rs | 5 ++- .../native_cpu/source_weight_cache/tests.rs | 37 +++++++++++++------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs index 5e93c9e9c..ea2ede414 100644 --- a/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs @@ -209,7 +209,10 @@ impl Binding { return record; } if !hints.parent_unchanged() - || !hints.shards.iter().all(|shard| shard.unchanged(&hints.root)) + || !hints + .shards + .iter() + .all(|shard| shard.unchanged(&hints.root)) { record["outcome"] = json!("identity_changed"); return record; diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs index 4f54aa2ad..cfa281b23 100644 --- a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs @@ -23,8 +23,11 @@ fn fixture() -> (tempfile::TempDir, Binding) { fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); for spec in specs() { fs::write(root.path().join(spec.name), b"x").unwrap(); - fs::set_permissions(root.path().join(spec.name), fs::Permissions::from_mode(0o600)) - .unwrap(); + fs::set_permissions( + root.path().join(spec.name), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); } let hints = Hints::bind(root.path(), &specs()).unwrap(); ( @@ -506,7 +509,14 @@ fn source_observation_is_bounded_and_does_not_export_private_identities() { let raw = serde_json::to_string(&record).unwrap(); assert!("private_source_weight_cache_diagnostic ".len() + raw.len() + 1 <= 4096); assert!(!raw.contains(root.path().to_str().unwrap())); - for forbidden in ["safetensors", "sha256", "inode", "owner", "reclaimed", "admitted"] { + for forbidden in [ + "safetensors", + "sha256", + "inode", + "owner", + "reclaimed", + "admitted", + ] { assert!(!raw.contains(forbidden)); } assert_eq!(record.as_object().unwrap().len(), 8); @@ -598,17 +608,22 @@ fn source_logging_requires_scope_and_its_own_explicit_debug_opt_in() { fn source_advice_stays_after_gguf_and_successful_join_inside_runtime_lease() { let source = include_str!("../../../compute.rs"); let lease = source.find("let _lease = runtime_lease").unwrap(); - let admission = source.find("resources::admission(options.model_profile").unwrap(); + let admission = source + .find("resources::admission(options.model_profile") + .unwrap(); let binding = source.find("let source_weight_cache = ").unwrap(); - let spawn = source[binding..].find("let child = sandbox::command").unwrap() + binding; + let spawn = source[binding..] + .find("let child = sandbox::command") + .unwrap() + + binding; let join = source.find("let result = supervise::run(child").unwrap(); let gguf = source.find("cache_hint.after_supervisor(&result").unwrap(); - let advice = source.find("source_weight_cache.after_supervisor(&result").unwrap(); + let advice = source + .find("source_weight_cache.after_supervisor(&result") + .unwrap(); assert!(lease < admission && admission < binding && binding < spawn); assert!(spawn < join && join < gguf && gguf < advice); - assert!( - source[advice..].starts_with( - "source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}" - ) - ); + assert!(source[advice..].starts_with( + "source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}" + )); } From 3146d2b40b674fa7ef3839ee7225b6fa45906310 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:23:17 +0200 Subject: [PATCH 26/39] test: remove redundant source cache fixture qualification --- .../src/compute/native_cpu/source_weight_cache/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs index cfa281b23..087efcb46 100644 --- a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs @@ -355,7 +355,7 @@ fn source_advice_requires_successful_supervisor_and_checked_provenance() { for outcome in ["supervisor_error", "provenance_unconfirmed"] { let (_root, binding) = fixture(); let result = if outcome == "supervisor_error" { - Err(super::super::super::private_task::CleanupUnconfirmed.into()) + Err(private_task::CleanupUnconfirmed.into()) } else { Ok(json!({"model":{"files":{},"weights":{}}})) }; From 2dd38983a504d2b3a8ee0e5805e95a44234c621d Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:47:33 +0200 Subject: [PATCH 27/39] fix: satisfy source cache lint guards without behavior changes --- .../src/compute/native_cpu/source_weight_cache.rs | 2 +- .../src/compute/native_cpu/source_weight_cache/tests.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs index ea2ede414..3273708d5 100644 --- a/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs @@ -50,7 +50,7 @@ impl Identity { self.mode & libc::S_IFMT == libc::S_IFREG && self.owner == nix::unistd::geteuid().as_raw() && self.links == 1 - && self.mode & 0o077 == 0 + && self.mode.trailing_zeros() >= 6 && self.bytes == expected_bytes && (1..=4 * 1024 * 1024 * 1024).contains(&self.bytes) } diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs index 087efcb46..3bffd5133 100644 --- a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs @@ -440,7 +440,7 @@ fn source_one_shot_advises_exact_three_without_changing_original_result() { {"ordinal":3,"outcome":"advised","syscall_error":null} ]) ); - assert_eq!(result.unwrap(), original); + assert_eq!(result.as_ref().ok(), Some(&original)); assert!(!resources::admits(PROFILE, Some(0))); } @@ -464,7 +464,7 @@ fn source_syscall_failure_is_finite_without_retry_or_result_override() { assert_eq!(item["outcome"], "error"); assert_eq!(item["syscall_error"], "interrupted"); } - assert_eq!(result.unwrap(), original); + assert_eq!(result.as_ref().ok(), Some(&original)); for (error, expected) in [ (nix::errno::Errno::EBADF, "bad_fd"), (nix::errno::Errno::EINVAL, "invalid"), @@ -507,7 +507,7 @@ fn source_observation_is_bounded_and_does_not_export_private_identities() { let (root, binding) = fixture(); let record = observe(binding, &Ok(model_result())); let raw = serde_json::to_string(&record).unwrap(); - assert!("private_source_weight_cache_diagnostic ".len() + raw.len() + 1 <= 4096); + assert!("private_source_weight_cache_diagnostic ".len() + raw.len() < 4096); assert!(!raw.contains(root.path().to_str().unwrap())); for forbidden in [ "safetensors", From 3327115e07d2c4fa976b2d0c625729a68df0aaf6 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:10:47 +0200 Subject: [PATCH 28/39] Release verified native source cache before GGUF preparation --- .github/workflows/quality.yml | 2 +- docs/IMPLEMENTATION_STATUS.md | 23 +- .../test_native_output_constraint.py | 3 +- .../test_native_source_lifecycle.py | 310 ++++++++++++++++++ workers/volparossa-ml/worker.py | 148 ++++++++- 5 files changed, 470 insertions(+), 16 deletions(-) create mode 100644 workers/volparossa-ml/test_native_source_lifecycle.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0ff481dea..57d3bfcb7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -97,7 +97,7 @@ jobs: - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml - run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint + run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint test_native_source_lifecycle - name: Check native CPU cache hints and unchanged admission without loading models run: | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 905cd7ad0..556cb3080 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -18,7 +18,28 @@ Last updated: 2026-10-15 ### Explicit native CPU inference candidate -#### Current memory-admission follow-up +#### Source verification lifecycle candidate + +[Actual trial `38726624377`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38726624377) +on Code `4fc36928` / core `2dd38983` remains incomplete: one completed read, +a second admitted execution cancelled by the unchanged memory guard, then five +memory-admission refusals, with no edit or test. Its last observed substage is +`tokenizer_load/complete`; no later `prompt_encode` record is retained. Runtime, +project, guest and VM cleanup are confirmed; protected peer execution and general +coding quality remain unproved. The [original audit](https://github.com/VOLPAROSSA/volparossa-code/pull/11#issuecomment-6191437311) +retains the source bindings and artifact digest. Aggregate file-cache counters do +not identify individual files or prove reclaimability. + +The new candidate retains only the three source-shard descriptors used by the +complete hash verification. After successful source, configuration and dataset +checks and an owner-control check, it attempts at most three file-bound cache +advisories before GGUF preparation. Metadata binds those descriptors; it does not +replace content hashes. Existing post-supervisor hints and all resource guards +remain unchanged. Twelve inert cases exercise the real small-file hash and +descriptor sequence, failure rejection and cleanup; they do not prove reclaimed +memory or a completed model trial. + +#### Earlier memory-admission follow-up [Actual Code trial `38695131367`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38695131367) on Code `7f451e67` / core `3630ff79` completes one native OpenCode read, then diff --git a/workers/volparossa-ml/test_native_output_constraint.py b/workers/volparossa-ml/test_native_output_constraint.py index f88315746..92c2658c7 100644 --- a/workers/volparossa-ml/test_native_output_constraint.py +++ b/workers/volparossa-ml/test_native_output_constraint.py @@ -320,7 +320,8 @@ def test_real_execute_chain_accepts_serialized_selection_and_unchanged_template( (root / "model.gguf").write_bytes(model_raw) value = dict(manifest(), version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) value["gguf"].update(bytes=len(model_raw), sha256=hashlib.sha256(model_raw).hexdigest()) - def prepared(_request, *, session, initial_source): + def prepared(_request, *, session, initial_source, source_cache): + self.assertIsInstance(source_cache, WORKER.NativeSourceCache) initial_source["weights"] = profile["weights"] parsed = WORKER.validate_dataset(WORKER.parse_json(raw), "private_conversation", native.PROFILE) return root, root, parsed, identity, files diff --git a/workers/volparossa-ml/test_native_source_lifecycle.py b/workers/volparossa-ml/test_native_source_lifecycle.py new file mode 100644 index 000000000..e803707c3 --- /dev/null +++ b/workers/volparossa-ml/test_native_source_lifecycle.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Real tiny hashes and descriptor lifetimes, never models or cache reclamation.""" +import contextlib +import errno +import hashlib +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +from test_llama_cpu import WORKER, controlled_pipe, control, manifest, source_fixture + + +def fixture(root): + selected, profile = source_fixture(root) + for path in Path(selected['model_root']).iterdir(): + path.chmod(0o600) + return selected, profile + + +def bundle(root, selected, template): + root.mkdir(mode=0o700) + value = manifest() + for field, raw in (('library', b'inert library bytes'), ('gguf', b'inert GGUF bytes')): + (root / value[field]['path']).write_bytes(raw) + value[field].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + provenance = dict(version=2, abi_version=1, kind=native.KIND, source_commit=native.SOURCE, + source_tree=native.SOURCE_TREE, source_overlay=native.loader_provenance(), + loader_compile_source_verified=True, upstream_original_unchanged=True, + cpu='avx2_fma_f16c', library=value['library'], provisionable=True, + sanitizers=False, quantization=False) + raw = json.dumps(provenance).encode() + (root / 'build.json').write_bytes(raw) + value['build_manifest_sha256'] = hashlib.sha256(raw).hexdigest() + value['verification']['chat_template_sha256'] = hashlib.sha256(template.encode()).hexdigest() + raw = json.dumps(value).encode() + (root / 'backend.json').write_bytes(raw) + selected.update(native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) + + +@contextlib.contextmanager +def prepared(): + with tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + with mock.patch.object(WORKER, 'model_profile', return_value=profile): + with WORKER.NativeSourceCache(selected) as cache: + result = WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + yield cache, selected, profile, result + + +class NativeSourceLifecycleTests(unittest.TestCase): + def assert_closed(self, descriptors): + for descriptor in descriptors: + with self.assertRaises(OSError) as error: + os.fstat(descriptor) + self.assertEqual(error.exception.errno, errno.EBADF) + + def test_real_execute_hash_chain_advises_only_verified_shards_before_backend_and_gguf(self): + self.execute_chain() + + def test_real_execute_keeps_result_when_advice_is_unavailable(self): + for error in (OSError(errno.EPERM, 'private error'), AttributeError('unsupported')): + with self.subTest(error=type(error).__name__): + self.execute_chain(error) + + def execute_chain(self, advice_error=None): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + selected, profile = fixture(root) + template = 'inert template' + bundle(root / 'native', selected, template) + events, descriptors, flags = [], [], [] + tokenizer = SimpleNamespace(pad_token_id=151643, eos_token_id=151645, chat_template=template) + transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=lambda *a, **k: tokenizer)) + original_hash, original_open = WORKER.file_hash, os.open + + def hashed(path, *args, **kwargs): + value = original_hash(path, *args, **kwargs) + events.append(('hashed', path.name)) + return value + + def opened(path, bits, *args, **kwargs): + flags.append((Path(path).name, bits)) + return original_open(path, bits, *args, **kwargs) + + def advise(descriptor, offset, size, hint): + name = next(name for name in profile['weight_shards'] + if (root / 'source' / name).stat().st_ino == os.fstat(descriptor).st_ino) + events.append(('advised', name)) + descriptors.append(descriptor) + self.assertEqual((offset, size, hint), (0, profile['files'][name], os.POSIX_FADV_DONTNEED)) + self.assertFalse(os.get_inheritable(descriptor)) + self.assertEqual(os.lseek(descriptor, 0, os.SEEK_CUR), size) + if advice_error is not None: + raise advice_error + + def load_backend(*args): + events.append(('backend', 'begin')) + self.assert_closed(descriptors) + return mock.Mock(), transformers, mock.Mock(), {} + + def execute(request, session, token, *_args, **kwargs): + self.assertEqual(kwargs['initial_source_weights'], profile['weights']) + _, value, _ = native.verified_bundle(request, WORKER, token, session) + self.assertEqual(value['gguf']['bytes'], len(b'inert GGUF bytes')) + return 'inert bundle verified; no library loaded' + + session = mock.Mock() + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER, 'file_hash', side_effect=hashed), \ + mock.patch.object(WORKER.os, 'open', side_effect=opened), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=advise), \ + mock.patch.object(WORKER, 'configure_offline'), \ + mock.patch.object(WORKER, 'load_backend', side_effect=load_backend), \ + mock.patch.dict(WORKER.sys.modules, {'volparossa_llama_cpu': SimpleNamespace(execute=execute)}), \ + mock.patch.object(native.ctypes, 'CDLL', side_effect=AssertionError('no native loading')): + self.assertEqual(WORKER.execute_job(selected, session), 'inert bundle verified; no library loaded') + source_events = [('hashed', name) for name in profile['files']] + self.assertCountEqual(events[:len(source_events)], source_events) + self.assertEqual(events[len(source_events):], + [('advised', name) for name in profile['weight_shards']] + + [('backend', 'begin'), ('hashed', native.LIBRARY), ('hashed', 'model.gguf')]) + self.assertEqual(len(descriptors), 3) + for name, bits in flags: + if name in profile['weight_shards']: + self.assertEqual(bits & (os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC), + os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + + def test_hash_config_and_dataset_failures_never_advise_and_close_retained_fds(self): + for failure in ('individual', 'aggregate', 'config', 'dataset', 'missing', 'symlink', 'fifo'): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + if failure == 'individual': + path = Path(selected['model_root']) / profile['weight_shards'][-1] + path.write_bytes(b'x' * path.stat().st_size) + elif failure == 'aggregate': + profile['weights']['sha256'] = '0' * 64 + elif failure == 'config': + path = Path(selected['model_root']) / 'config.json' + path.write_bytes(b'{"auto_map":{}}') + profile['files'][path.name] = path.stat().st_size + profile['hashes'][path.name] = hashlib.sha256(path.read_bytes()).hexdigest() + elif failure == 'dataset': + Path(selected['dataset_path']).write_bytes(b'[]') + else: + path = Path(selected['model_root']) / profile['weight_shards'][-1] + path.unlink() + if failure == 'symlink': path.symlink_to(Path(selected['model_root']) / profile['weight_shards'][0]) + if failure == 'fifo': os.mkfifo(path, 0o600) + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + cache = WORKER.NativeSourceCache(selected) + descriptors = [] + with self.assertRaises((WORKER.JobError, OSError)), cache: + try: + WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + finally: + descriptors = [cache.parent] + [entry[0] for entry in cache.shards] + self.assertFalse(cache.prepared) + cache.advise(mock.Mock()) + advice.assert_not_called() + self.assert_closed(descriptors) + + def test_pending_cancel_and_deadline_propagate_without_advice_or_reset(self): + for failure in ('cancel', 'deadline'): + with self.subTest(failure=failure), prepared() as (cache, *_), \ + controlled_pipe() as (session, writer, _), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + started = session.started + if failure == 'cancel': + os.write(writer, control(1, 'cancel')) + elapsed = 0 + else: + elapsed = session.request['max_seconds'] * 1000 + 1 + with mock.patch.object(session, 'elapsed', return_value=elapsed), \ + self.assertRaisesRegex(WORKER.JobError, 'JOB_CANCELLED|JOB_DEADLINE_EXCEEDED'): + cache.advise(session) + self.assertEqual(session.started, started) + advice.assert_not_called() + + def test_success_uses_exactly_three_once_and_advice_errors_do_not_replace_result(self): + for failure in (None, OSError(errno.EPERM, 'private exception must not be exported'), AttributeError('unsupported')): + with self.subTest(failure=type(failure).__name__), prepared() as (cache, _, _, result), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=failure) as advice: + original = result + cache.advise(mock.Mock()) + cache.advise(mock.Mock()) + self.assertIs(result, original) + self.assertEqual(advice.call_count, 3) + + def test_incomplete_verification_cannot_advise_even_with_three_descriptors(self): + with prepared() as (cache, *_), mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + cache.prepared = False + self.assertEqual(len(cache.shards), 3) + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_changed_file_parent_mode_link_or_inventory_prevents_advice(self): + for mutation in ('replace', 'rewrite', 'symlink', 'hardlink', 'mode', 'parent_mode', 'parent_replace', 'extra'): + with self.subTest(mutation=mutation), prepared() as (cache, selected, profile, _), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + root = Path(selected['model_root']) + path = root / profile['weight_shards'][0] + if mutation == 'replace': + raw = path.read_bytes() + path.unlink() + path.write_bytes(raw) + path.chmod(0o600) + elif mutation == 'rewrite': + # Metadata binding, not a second content read; make the + # identity change deterministic even on coarse-time filesystems. + path.write_bytes(b'x' * (path.stat().st_size + 1)) + elif mutation == 'symlink': + path.unlink() + path.symlink_to(root / profile['weight_shards'][1]) + elif mutation == 'hardlink': + os.link(path, root.parent / 'extra-link') + elif mutation == 'mode': + path.chmod(0o644) + elif mutation == 'parent_mode': + root.chmod(0o755) + elif mutation == 'parent_replace': + root.rename(root.parent / 'old-source') + root.mkdir(mode=0o700) + else: + (root / 'extra').write_bytes(b'not in the fixed inventory') + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_unsafe_binding_skips_advice_without_weakening_existing_validation(self): + for mutation in ('file_mode', 'parent_mode', 'foreign_file', 'foreign_parent'): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + root = Path(selected['model_root']) + path = root / profile['weight_shards'][0] + if mutation == 'file_mode': path.chmod(0o644) + if mutation == 'parent_mode': root.chmod(0o755) + foreign_inode = (path if mutation == 'foreign_file' else root).stat().st_ino + real_fstat, real_lstat = os.fstat, Path.lstat + + def metadata(info): + if mutation.startswith('foreign') and info.st_ino == foreign_inode: + fields = ('st_dev', 'st_ino', 'st_uid', 'st_mode', 'st_nlink', 'st_size', 'st_mtime_ns', 'st_ctime_ns') + return SimpleNamespace(**dict({name: getattr(info, name) for name in fields}, st_uid=os.geteuid()+1)) + return info + + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'fstat', side_effect=lambda fd: metadata(real_fstat(fd))), \ + mock.patch.object(Path, 'lstat', lambda path: metadata(real_lstat(path))), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + with WORKER.NativeSourceCache(selected) as cache: + result = WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + cache.advise(mock.Mock()) + self.assertEqual(result[4][profile['weight_shards'][0]]['sha256'], profile['hashes'][profile['weight_shards'][0]]) + advice.assert_not_called() + + def test_original_non_native_prepare_does_not_open_or_advise_retained_descriptors(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + for name in ('inference_backend', 'native_backend_root', 'native_backend_sha256'): + del selected[name] + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'dup', side_effect=AssertionError('legacy must not retain')), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=AssertionError('legacy must not advise')): + self.assertEqual(len(WORKER.prepare_files(selected)), 5) + + def test_close_error_does_not_mask_original_failure_or_skip_remaining_descriptors(self): + owned, closed = [], [] + original_close = os.close + + def close(descriptor): + original_close(descriptor) + if descriptor in owned and descriptor not in closed: + closed.append(descriptor) + if len(closed) == 1: + raise OSError(errno.EIO, 'close may already have released the descriptor') + + with mock.patch.object(WORKER.os, 'close', side_effect=close): + with self.assertRaisesRegex(WORKER.JobError, '^ORIGINAL_FAILURE$'): + with prepared() as (cache, *_): + owned.extend([cache.parent] + [entry[0] for entry in cache.shards]) + raise WORKER.JobError('ORIGINAL_FAILURE') + self.assertCountEqual(closed, owned) + self.assert_closed(owned) + + def test_failed_dup_is_not_retried_or_a_new_verification_failure(self): + with mock.patch.object(WORKER.os, 'dup', side_effect=OSError(errno.EMFILE, 'bounded failure')) as duplicate, \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice, prepared() as (cache, _, _, result): + self.assertEqual(len(result), 5) + self.assertEqual(duplicate.call_count, 1) + self.assertEqual(cache.shards, []) + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_scope_rejected_before_any_new_file_access(self): + with tempfile.TemporaryDirectory() as directory: + selected, _ = fixture(Path(directory)) + for change in ({'inference_backend': None}, {'mode': 'train'}, {'model_profile': WORKER.QWEN_MODEL_PROFILE}): + with self.subTest(change=change), \ + mock.patch.object(WORKER.os, 'open', side_effect=AssertionError('no files before scope')), \ + self.assertRaisesRegex(WORKER.JobError, '^NATIVE_SOURCE_PROVENANCE$'): + WORKER.NativeSourceCache(dict(selected, **change)) + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index f9f950592..84e043579 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -786,13 +786,122 @@ def plain_path(value, directory): return path, metadata -def file_hash(path, expected_size=None, maximum=None, aggregate=None, *, session=None): +class NativeSourceCache: + """One bounded hint for already verified, no-longer-consumed source shards. + + Keep the actual hashing descriptors, not reopened lookalikes. This is neither + a memory measurement nor admission credit, and emits no additional records. + """ + SHARDS = tuple(f"model-{index:05d}-of-00003.safetensors" for index in range(1, 4)) + + def __init__(self, request): + require(request.get("inference_backend") == "llama_cpp_bf16_v1" + and request.get("model_profile") == QWEN4B_MODEL_PROFILE + and request.get("mode") == "private_conversation", "NATIVE_SOURCE_PROVENANCE") + self.root = Path(request["model_root"]) + profile = model_profile(QWEN4B_MODEL_PROFILE) + self.files = dict(profile["files"]) + self.parent, self.identity, self.shards = None, None, [] + self.used, self.prepared = False, False + self.valid = tuple(profile["weight_shards"]) == self.SHARDS + + @staticmethod + def identity_of(info): + return (info.st_dev, info.st_ino, info.st_uid, info.st_mode, info.st_nlink, + info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + def __enter__(self): + try: + if self.valid and self.root.is_absolute() and self.root.resolve(strict=True) == self.root: + self.parent = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_NONBLOCK | os.O_CLOEXEC) + info = os.fstat(self.parent) + self.identity = self.identity_of(info) + self.valid = (stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() + and stat.S_IMODE(info.st_mode) == 0o700 and self.parent_unchanged()) + else: + self.valid = False + except OSError: + self.valid = False + return self + + def __exit__(self, *_error): + descriptors = [entry[0] for entry in self.shards] + if self.parent is not None: + descriptors.append(self.parent) + self.shards, self.parent, self.valid = [], None, False + for descriptor in descriptors: + # Never retry close: even a failed close may have released this FD. + with contextlib.suppress(OSError): + os.close(descriptor) + + def parent_unchanged(self): + return (self.parent is not None and self.root.resolve(strict=True) == self.root + and self.identity_of(os.fstat(self.parent)) == self.identity + and self.identity_of(self.root.lstat()) == self.identity + and set(os.listdir(self.parent)) == set(self.files)) + + def retain(self, path, descriptor, before): + try: + index = len(self.shards) + after = os.fstat(descriptor) + identity = self.identity_of(after) + if (not self.valid or index >= 3 or path != self.root / self.SHARDS[index] + or not self.parent_unchanged() or not stat.S_ISREG(after.st_mode) + or after.st_uid != os.geteuid() or stat.S_IMODE(after.st_mode) & 0o077 + or after.st_nlink != 1 or after.st_size != self.files[self.SHARDS[index]] + or not 0 < after.st_size <= 4 * 1024 ** 3 + or self.identity_of(before) != identity or self.identity_of(path.lstat()) != identity): + self.valid = False + return + # dup retains the exact file description used for hashing; no content + # is reread, and Python creates the duplicate non-inheritable. + self.shards.append((os.dup(descriptor), self.SHARDS[index], identity)) + except OSError: + self.valid = False + + def unchanged(self, entry): + descriptor, name, identity = entry + return (self.parent_unchanged() and self.identity_of(os.fstat(descriptor)) == identity + and self.identity_of((self.root / name).lstat()) == identity) + + def advise(self, session): + if self.used: + return + self.used = True + # Owner control/deadline exceptions must propagate, never be mistaken for + # an unavailable best-effort advisory. No pause or deadline is reset. + session.check() + try: + if (not self.valid or not self.prepared or len(self.shards) != 3 + or not all(map(self.unchanged, self.shards))): + return + except OSError: + return + for entry in self.shards: + session.check() + try: + if not self.unchanged(entry): + return + os.posix_fadvise(entry[0], 0, entry[2][5], os.POSIX_FADV_DONTNEED) + if not self.unchanged(entry): + return + except (OSError, AttributeError): + # Unsupported/failed advice is not verification failure or proof + # of reclaim. Each descriptor receives at most one attempt. + continue + + +def file_hash(path, expected_size=None, maximum=None, aggregate=None, *, session=None, source_cache=None): # Hash every original byte, but service owner controls on this execution # thread between bounded reads. A pause is never acknowledged while a read # or digest update is running, and does not reset the original job deadline. if session is not None: session.check() - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + flags = os.O_RDONLY | os.O_NOFOLLOW + if source_cache is not None: + flags |= os.O_NONBLOCK | os.O_CLOEXEC + descriptor = os.open(path, flags) with os.fdopen(descriptor, "rb") as source: metadata = os.fstat(source.fileno()) require(stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1, "INVALID_ARTIFACT_FILE") @@ -813,6 +922,8 @@ def file_hash(path, expected_size=None, maximum=None, aggregate=None, *, session if session is not None: session.check() require(length == metadata.st_size, "ARTIFACT_CHANGED") + if source_cache is not None: + source_cache.retain(path, source.fileno(), metadata) return {"bytes": length, "sha256": digest.hexdigest()} @@ -825,7 +936,7 @@ def read_bounded(path, maximum): return value -def verify_sharded_weights(model_root, profile, verified_files=None, *, session=None): +def verify_sharded_weights(model_root, profile, verified_files=None, *, session=None, source_cache=None): """Hash the original shard bytes in pinned order, never the index as weights. The existing identity wire field remains a SHA256 over actual weight bytes. @@ -843,7 +954,8 @@ def verify_sharded_weights(model_root, profile, verified_files=None, *, session= and set(index["weight_map"].values()) == set(shards), "INVALID_WEIGHT_SHARD_INDEX") aggregate, total = hashlib.sha256(), 0 for name in shards: - checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate, session=session) + checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate, session=session, + **({"source_cache": source_cache} if source_cache is not None else {})) require(checked["sha256"] == profile["hashes"][name], "MODEL_WEIGHTS_CHANGED_ON_DISK") if verified_files is not None: verified_files[name] = checked @@ -854,7 +966,7 @@ def verify_sharded_weights(model_root, profile, verified_files=None, *, session= return identity -def prepare_files(request, session=None, *, initial_source=None): +def prepare_files(request, session=None, *, initial_source=None, source_cache=None): # Native inference consumes a converted GGUF, not these source shards. Keep # their actually measured initial identity without changing the existing # five-value API or the ordinary PyTorch before/after verification path. @@ -863,6 +975,8 @@ def prepare_files(request, session=None, *, initial_source=None): and request.get("inference_backend") == "llama_cpp_bf16_v1" and request.get("model_profile") == QWEN4B_MODEL_PROFILE and request.get("mode") == "private_conversation", "NATIVE_SOURCE_PROVENANCE") + require(source_cache is None or (initial_source is not None and type(source_cache) is NativeSourceCache), + "NATIVE_SOURCE_PROVENANCE") profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) model_root, model_metadata = plain_path(request["model_root"], True) @@ -881,7 +995,8 @@ def prepare_files(request, session=None, *, initial_source=None): files = {name: file_hash(model_root / name, expected_size=size, session=session) for name, size in profile["files"].items() if name not in sharded} if sharded: - measured_weights = verify_sharded_weights(model_root, profile, files, session=session) + measured_weights = verify_sharded_weights(model_root, profile, files, session=session, + **({"source_cache": source_cache} if source_cache is not None else {})) if initial_source is not None: initial_source["weights"] = dict(measured_weights, files=list(measured_weights["files"])) require(all(files[name]["sha256"] == expected for name, expected in profile["hashes"].items()), @@ -898,6 +1013,8 @@ def prepare_files(request, session=None, *, initial_source=None): if request["mode"] in ("private_infer", "private_conversation"): identity = {"sha256": hashlib.sha256(raw_dataset).hexdigest(), "bytes": len(raw_dataset), "visibility": "private_local"} + if source_cache is not None: + source_cache.prepared = True return model_root, output_root, dataset, identity, files identity = { "sha256": hashlib.sha256(raw_dataset).hexdigest(), "bytes": len(raw_dataset), @@ -2346,13 +2463,18 @@ def execute_job(request, session): session.private_progress("owner_gate", "complete") session.private_progress("verify_files", "begin") initial_source = {} - preparation = ({"initial_source": initial_source} - if request.get("inference_backend") == "llama_cpp_bf16_v1" else {}) - model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session, **preparation) - if request["mode"] == "private_conversation": - conversation_constraint_binding(request, dataset, profile_name) - session.private_progress("verify_files", "complete") - session.check() + with contextlib.ExitStack() as source_lifetime: + source_cache = (source_lifetime.enter_context(NativeSourceCache(request)) + if request.get("inference_backend") == "llama_cpp_bf16_v1" else None) + preparation = ({"initial_source": initial_source, "source_cache": source_cache} + if source_cache is not None else {}) + model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session, **preparation) + if request["mode"] == "private_conversation": + conversation_constraint_binding(request, dataset, profile_name) + session.private_progress("verify_files", "complete") + session.check() + if source_cache is not None: + source_cache.advise(session) cohort = None if request["mode"] == "aggregate_adapter": root, metadata = plain_path(request["adapter_root"], True) From 2439892e480410285a68a4cb3187edba29b50b66 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:23:01 +0200 Subject: [PATCH 29/39] feat: supervise bounded owner-native sessions with explicit lifetime cleanup --- .github/workflows/quality.yml | 5 +- crates/volparossa/src/compute/native_cpu.rs | 54 +- .../src/compute/native_cpu/tests.rs | 1 + .../native_cpu/tests/native_session.rs | 53 ++ .../src/compute/private_conversation/WIRE.md | 48 +- .../volparossa/src/compute/private_serve.rs | 375 +++++++++++- .../src/compute/private_serve/tests.rs | 1 + .../private_serve/tests/native_session.rs | 553 ++++++++++++++++++ .../private_serve/tests/output_constraint.rs | 2 +- .../src/compute/private_serve/wire.rs | 9 + crates/volparossa/src/compute/private_task.rs | 25 +- crates/volparossa/src/compute/sandbox.rs | 97 ++- crates/volparossa/src/compute/supervise.rs | 5 + .../src/compute/supervise/lifetime.rs | 30 + .../src/compute/supervise/native_session.rs | 314 ++++++++++ .../compute/supervise/native_session/tests.rs | 97 +++ .../supervise/native_session/worker.rs | 455 ++++++++++++++ docs/IMPLEMENTATION_STATUS.md | 18 + workers/volparossa-ml/build_llama_cpu.py | 7 +- workers/volparossa-ml/convert_llama_cpu.py | 4 +- workers/volparossa-ml/llama_cpu.py | 107 +++- .../volparossa-ml/native-cpu/CMakeLists.txt | 3 + .../volparossa-ml/native-cpu/abi-smoke.cpp | 2 + workers/volparossa-ml/native-cpu/adapter.cpp | 65 +- workers/volparossa-ml/native-cpu/adapter.h | 10 + .../native-cpu/output-constraint-tests.cpp | 30 + .../volparossa-ml/native-cpu/session-state.h | 58 ++ .../native-cpu/session-tests.cpp | 147 +++++ workers/volparossa-ml/native_session.py | 151 +++++ workers/volparossa-ml/test_native_session.py | 254 ++++++++ workers/volparossa-ml/worker.py | 33 +- 31 files changed, 2964 insertions(+), 49 deletions(-) create mode 100644 crates/volparossa/src/compute/native_cpu/tests/native_session.rs create mode 100644 crates/volparossa/src/compute/private_serve/tests/native_session.rs create mode 100644 crates/volparossa/src/compute/supervise/native_session.rs create mode 100644 crates/volparossa/src/compute/supervise/native_session/tests.rs create mode 100644 crates/volparossa/src/compute/supervise/native_session/worker.rs create mode 100644 workers/volparossa-ml/native-cpu/session-state.h create mode 100644 workers/volparossa-ml/native-cpu/session-tests.cpp create mode 100644 workers/volparossa-ml/native_session.py create mode 100644 workers/volparossa-ml/test_native_session.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 57d3bfcb7..11bd84e66 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -97,11 +97,14 @@ jobs: - name: Check native CPU contracts without loading models working-directory: workers/volparossa-ml - run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint test_native_source_lifecycle + run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint test_native_source_lifecycle test_native_session - name: Check native CPU cache hints and unchanged admission without loading models run: | cargo test --locked -p volparossa compute::native_cpu + cargo test --locked -p volparossa compute::supervise::native_session + cargo test --locked -p volparossa compute::private_serve::tests::native_session + cargo test --locked -p volparossa compute::sandbox::tests::native_session cargo test --locked -p volparossa compute::resources::tests cargo test --locked -p volparossa compute::spare_capacity::memory::tests python3 -B tests/integration/test-agent-private-conversation.py diff --git a/crates/volparossa/src/compute/native_cpu.rs b/crates/volparossa/src/compute/native_cpu.rs index 4aae22eda..a5c820cfd 100644 --- a/crates/volparossa/src/compute/native_cpu.rs +++ b/crates/volparossa/src/compute/native_cpu.rs @@ -73,6 +73,8 @@ struct Manifest { build_manifest_sha256: String, #[serde(default, deserialize_with = "present_output_constraints")] output_constraints: Option>, + #[serde(default, deserialize_with = "present_native_sessions")] + native_sessions: Option>, } fn present_output_constraints<'de, D: serde::Deserializer<'de>>( @@ -81,13 +83,24 @@ fn present_output_constraints<'de, D: serde::Deserializer<'de>>( Vec::::deserialize(deserializer).map(Some) } +fn present_native_sessions<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result>, D::Error> { + Vec::::deserialize(deserializer).map(Some) +} + impl Manifest { fn validate(&self) -> Result<()> { ensure!( ((self.version == 1 && self.output_constraints.is_none()) - || (self.version == 2 + || ((self.version == 2 || self.version == 3) && self.output_constraints.as_deref() == Some(&[OutputConstraint::QwenTaggedToolJsonV1]))) + && (if self.version == 3 { + self.native_sessions.as_deref() == Some(&["connection_bound_v1".to_owned()]) + } else { + self.native_sessions.is_none() + }) && self.kind == KIND && self.abi_version == 1 && self.source_commit == SOURCE @@ -171,6 +184,11 @@ impl Options { .is_ok_and(|request| request.supported_output_constraint.is_some()) } + pub(super) fn supports_native_session(&self, profile: ModelProfile) -> bool { + self.request(profile) + .is_ok_and(|request| request.supported_native_session) + } + pub(super) fn request_for_dataset( &self, profile: ModelProfile, @@ -232,6 +250,7 @@ impl Options { .and_then(|values| values.first().copied()), output_constraint: None, output_constraint_tools: None, + supported_native_session: manifest.version == 3, }) } } @@ -252,6 +271,8 @@ pub(super) struct Request { expected_report: Option, #[serde(skip)] supported_output_constraint: Option, + #[serde(skip)] + supported_native_session: bool, } impl Request { @@ -325,5 +346,36 @@ impl Request { } } +/// Preserve the existing post-supervisor advisories, once after the whole +/// connection-owned process has joined, never after a still-resident turn. +pub(super) struct SessionHints { + gguf: Binding, + source: source_weight_cache::Binding, + profile: ModelProfile, + diagnostic: bool, +} + +impl SessionHints { + pub(super) fn bind(request: &Request, options: &super::Options) -> Self { + Self { + gguf: request.cache_hint_observed(&options.native_backend, options.model_profile), + source: source_weight_cache::Binding::bind( + request, + &options.native_backend, + options.model_profile, + &options.model_root, + ), + profile: options.model_profile, + diagnostic: true, + } + } + + pub(super) fn finish(self, result: &Result) { + self.gguf + .after_supervisor(result, self.profile, self.diagnostic); + self.source.after_supervisor(result, self.diagnostic); + } +} + #[cfg(test)] mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs index 56a91c640..a739da81c 100644 --- a/crates/volparossa/src/compute/native_cpu/tests.rs +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -5,6 +5,7 @@ use clap::{CommandFactory as _, error::ErrorKind}; use super::*; +mod native_session; mod output_constraint; #[test] diff --git a/crates/volparossa/src/compute/native_cpu/tests/native_session.rs b/crates/volparossa/src/compute/native_cpu/tests/native_session.rs new file mode 100644 index 000000000..d74c11c12 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests/native_session.rs @@ -0,0 +1,53 @@ +//! Manifest authorization only; no executable backend is loaded by these tests. + +use super::*; + +#[test] +fn native_session_capability_requires_owner_hash_and_exact_new_manifest() { + let (root, mut options) = fixture(); + let profile = ModelProfile::Qwen4bInstruct2507; + assert!(!options.supports_native_session(profile)); + let mut value = manifest(); + value["version"] = json!(3); + value["output_constraints"] = json!(["qwen_tagged_tool_json_v1"]); + value["native_sessions"] = json!(["connection_bound_v1"]); + let bytes = serde_json::to_vec(&value).unwrap(); + fs::write(root.path().join("backend.json"), &bytes).unwrap(); + assert!(!options.supports_native_session(profile)); + options.native_backend_sha256 = Some(hex::encode(Sha256::digest(&bytes))); + assert!(options.supports_native_session(profile)); + assert!(options.supports_output_constraint(profile)); + assert!(!options.supports_native_session(ModelProfile::Qwen600)); + let wire = serde_json::to_value(options.request(profile).unwrap()).unwrap(); + assert!(wire.get("native_session").is_none()); + assert!(wire.get("native_sessions").is_none()); +} + +#[test] +fn historical_manifests_cannot_claim_sessions_and_new_capability_is_closed() { + let mut modern = manifest(); + modern["version"] = json!(3); + modern["output_constraints"] = json!(["qwen_tagged_tool_json_v1"]); + modern["native_sessions"] = json!(["connection_bound_v1"]); + for invalid in [ + Value::Null, + json!([]), + json!(["other"]), + json!(["connection_bound_v1", "connection_bound_v1"]), + ] { + let mut bad = modern.clone(); + bad["native_sessions"] = invalid; + let decoded = serde_json::from_value::(bad); + assert!(decoded.is_err() || decoded.unwrap().validate().is_err()); + } + for version in [1, 2] { + let mut bad = modern.clone(); + bad["version"] = json!(version); + assert!( + serde_json::from_value::(bad) + .unwrap() + .validate() + .is_err() + ); + } +} diff --git a/crates/volparossa/src/compute/private_conversation/WIRE.md b/crates/volparossa/src/compute/private_conversation/WIRE.md index c7b301144..e56ee285c 100644 --- a/crates/volparossa/src/compute/private_conversation/WIRE.md +++ b/crates/volparossa/src/compute/private_conversation/WIRE.md @@ -146,6 +146,48 @@ These events observe report validation, not model attempts: validating the same bound report again may repeat its code without another execution. Request/result counters track actual attempts. +## Connection-bound native sessions + +The explicit Qwen4B native backend can retain its model and decoded prompt +prefix within one authenticated owner connection. Negotiate +`native_session_version:1` together with `generation_policy_version:1` in +`conversation_capabilities`. Only that handshake adds `native_session_version:1` +and `native_session` to the capabilities. The latter is either null (unsupported) +or exactly `{version:1,max_task_seconds:2400,max_idle_seconds:300,max_requests:128}`. +Support requires the owner-bound backend v3/build v4 manifest with +`native_sessions:["connection_bound_v1"]`. This is not a bearer session identifier +or permission to share private state with another connection. + +Send `{type:"open_native_session"}` after negotiation. The core reserves the +shared execution slot and returns `native_session_opened` with the open ID; +model preparation starts only after the first admitted `submit_conversation`. +Submissions retain their ordinary input, tool, generation-policy and output- +constraint validation. A successful inner result has `version:2` and replaces +only the legacy cleanup member with +`{version:2,request_files_removed:true,session_closed:false,session_private_state_retained:true}`. +The envelope stays version 1. This result proves neither worker exit nor final +private-state removal. Legacy clients cannot interpret it as a v1 result. + +Send `{type:"close_native_session"}` to end the owner task. Only after joined +worker/descendant cleanup and private staging removal does the core return +`native_session_closed` with the close ID and +`cleanup:{version:1,child_reaped:true,private_state_removed:true}`, followed by EOF. +Automatic idle or lifetime closure uses the original open ID. Terminal native +errors carry `session_cleanup` with the same confirmed record or null when +cleanup is uncertain. They correlate to the pending request, requested close or +original open, respectively. Cancellation uses the existing task ID; its immediate +acknowledgement is not a cleanup receipt. Uncertain cleanup quarantines the slot. + +The core keeps the 600-second request ceiling, including preparation, generation +and the internal end-of-turn handshake. It caps the owner lifetime at 2,400 seconds, +idle time at 300 seconds and submissions at 128. Each request retains its CPU +allowance; the resident process has a separate cumulative task ceiling. Memory, +pressure and owner-activity checks continue while resident, including idle time. +Exact matching decoded tokens may be reused, with the final prompt token +recomputed; no prompt truncation, model substitution or shared KV cache is added. +Final cleanup is process/staging cleanup, not a claim of forensic RAM erasure. +Real acceleration and a complete coding task still require actual-model evidence. + ## Explicit limits Capabilities contain the same model/limits facts as result `limits`, plus @@ -246,7 +288,9 @@ source provenance, not the weights read by native inference. The worker carries the actual complete initial shard/aggregate measurement into `model.weights`; it does not reconstruct that measurement from expected constants or claim a second source-shard verification at the end. The owner-bound converted GGUF is -fully hashed both before native loading and after the native handle is closed. +fully hashed before and after each native generation. A one-shot execution closes +its handle before the final hash; a connection-bound session retains that handle +until session cleanup and still performs the complete per-request hashes. Converter tensor-value equality, original model identity, native complete tensor validation and library/build/manifest authorization remain required. @@ -259,7 +303,7 @@ changes the internal native verification contract, not the public conversation schema. It does not attest continuous filesystem immutability against the local owner, successful inference, or private execution on other nodes. -Candidate resources are CPU BF16/SDPA, at most two threads, 600 seconds per task, +Candidate resources are CPU BF16/SDPA, at most two threads, 600 seconds per request, 10GiB observed RSS, 24GiB address space and 10.5GiB known spare memory before launch. These are admission/stop limits, not measured adequacy or a no-impact guarantee. The planned disposable comparison uses a 12GiB guest with an 11GiB diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index c9d81b74b..a54bc425d 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -254,13 +254,14 @@ enum Incoming { Invalid, } -#[derive(Default)] +#[derive(Clone, Default)] struct Handshakes { qa: bool, conversation: bool, generation_policy: Option, execution_error: Option, output_constraint: Option, + native_session: Option, } fn conversation_capabilities( @@ -301,6 +302,18 @@ fn conversation_capabilities( json!([]) }; } + if handshakes.native_session == Some(1) { + caps["native_session_version"] = 1.into(); + caps["native_session"] = if config + .native_backend + .supports_native_session(config.model_profile) + { + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300, + "max_requests":128}) + } else { + Value::Null + }; + } caps } @@ -326,11 +339,13 @@ fn respond( generation_policy_version, execution_error_version, output_constraint_version, + native_session_version, } => { handshakes.conversation = true; handshakes.generation_policy = generation_policy_version; handshakes.execution_error = execution_error_version; handshakes.output_constraint = output_constraint_version; + handshakes.native_session = native_session_version; let mut response = wire::response(&request.id, "conversation_capabilities"); response["capabilities"] = conversation_capabilities(config, gate, handshakes); response @@ -408,6 +423,9 @@ fn respond( wire::error(Some(&request.id), "no_such_task") } } + wire::Operation::OpenNativeSession {} | wire::Operation::CloseNativeSession {} => { + wire::error(Some(&request.id), "invalid_request") + } } } @@ -444,11 +462,322 @@ fn execution_budget_exceeded(error: &anyhow::Error) -> bool { .is_some_and(|failure| failure.code() == "JOB_DEADLINE_EXCEEDED") } +/// This lane is owned by one authenticated Unix connection, never by a UID or +/// a prompt-cache key. Its slot is released only after the entire session joins. +struct NativeSession { + open_id: String, + handshakes: Handshakes, + commands: mpsc::Sender, + events: mpsc::Receiver, + activity: watch::Sender, + execution: Option>>, + opened: bool, + pending: Option, + close_id: Option, + requests: usize, + cancelled: bool, + invalid_event: bool, +} + +impl NativeSession { + fn start( + open_id: String, + config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, + ) -> Self { + Self::spawn(open_id, slot, handshakes, move |commands, events, activity| { + super::supervise::native_session::run(config, commands, events, activity) + }) + } + + fn spawn( + open_id: String, + mut slot: ExecutionSlot, + handshakes: Handshakes, + run: F, + ) -> Self + where + F: FnOnce( + mpsc::Receiver, + mpsc::Sender, + watch::Receiver, + ) -> Fut, + Fut: std::future::Future> + Send + 'static, + { + let (commands, receive) = mpsc::channel(1); + let (send, events) = mpsc::channel(1); + let (activity, signal) = watch::channel(true); + let execution = run(receive, send, signal); + let execution = tokio::spawn(async move { + let result = execution.await; + if native_cleanup(&result).is_none() { + // Quarantine before dropping the permit, not after scheduling the + // connection's terminal response: another connection may be ready. + slot.gate.close(); + } + finish_execution(&mut slot, result) + }); + Self { + open_id, + handshakes, + commands, + events, + activity, + execution: Some(execution), + opened: false, + pending: None, + close_id: None, + requests: 0, + cancelled: false, + invalid_event: false, + } + } + + fn cancel(&mut self) { + self.cancelled = true; + let _ = self.activity.send(false); + } + + fn request(&mut self, request: wire::Request) -> Option { + use super::supervise::native_session::Command; + match request.operation { + wire::Operation::SubmitConversation { conversation } => { + if !self.opened + || self.pending.is_some() + || self.close_id.is_some() + || self.cancelled + { + return Some(wire::error(Some(&request.id), "busy")); + } + if self.requests >= 128 + || !conversation.requires_generation_policy_handshake() + || (conversation.output_constraint().is_some() + && self.handshakes.output_constraint != Some(1)) + { + return Some(wire::error(Some(&request.id), "invalid_request")); + } + let input = conversation + .bytes_profile(ModelProfile::Qwen4bInstruct2507) + .expect("validated native conversation serializes"); + self.pending = Some(request.id.clone()); + if self + .commands + .try_send(Command::Submit { + id: request.id.clone(), + input, + }) + .is_err() + { + self.invalid_event = true; + self.cancel(); + return None; + } + self.requests += 1; + Some(wire::response(&request.id, "admitted")) + } + wire::Operation::CloseNativeSession {} => { + if self.close_id.is_some() { + return Some(wire::error(Some(&request.id), "busy")); + } + self.close_id = Some(request.id); + if self.pending.is_some() { + self.cancel(); + } else if self.commands.try_send(Command::Close).is_err() { + self.invalid_event = true; + self.cancel(); + } + None // A close receipt is emitted only after the session joins. + } + wire::Operation::Cancel { task_id } => { + if self.pending.as_deref() != Some(task_id.as_str()) { + return Some(wire::error(Some(&request.id), "no_such_task")); + } + self.cancel(); + let mut reply = wire::response(&request.id, "cancel_requested"); + reply["task_id"] = task_id.into(); + Some(reply) + } + _ => Some(wire::error(Some(&request.id), "busy")), + } + } + + fn event(&mut self, event: super::supervise::native_session::Event) -> Option { + use super::supervise::native_session::Event; + match event { + Event::Opened if !self.opened && !self.cancelled && self.close_id.is_none() => { + self.opened = true; + Some(wire::response(&self.open_id, "native_session_opened")) + } + Event::Result { id, value } if self.opened && self.pending.as_ref() == Some(&id) => { + if self.cancelled || self.close_id.is_some() { + return None; + } + // The runtime validates model output. This separate check prevents + // a per-turn result from masquerading as a child-reaped terminal. + if value["version"] != 2 + || value["cleanup"] + != json!({"version":2,"request_files_removed":true, + "session_closed":false,"session_private_state_retained":true}) + { + self.invalid_event = true; + self.cancel(); + return None; + } + self.pending = None; + let mut reply = wire::response(&id, "result"); + reply["result"] = value; + Some(reply) + } + _ => { + self.invalid_event = true; + self.cancel(); + None + } + } + } + + fn terminal( + &self, + result: std::result::Result, tokio::task::JoinError>, + gate: &Semaphore, + ) -> Vec { + let (cleanup, failed, deadline) = match result { + Ok(Ok(value)) => (Some(value), false, false), + Ok(Err(error)) => ( + super::supervise::native_session::cleanup(&error), + true, + super::supervise::native_session::execution_deadline(&error), + ), + Err(_) => (None, true, false), + }; + let cleanup = cleanup.filter(|value| { + *value == json!({"version":1,"child_reaped":true,"private_state_removed":true}) + }); + if cleanup.is_none() { + gate.close(); + } + let id = self + .pending + .as_ref() + .or(self.close_id.as_ref()) + .unwrap_or(&self.open_id); + if gate.is_closed() + || failed + || self.cancelled + || self.invalid_event + || self.pending.is_some() + { + let code = if gate.is_closed() { + "cleanup_unconfirmed" + } else if self.cancelled && !self.invalid_event { + "cancelled" + } else if deadline && self.handshakes.execution_error == Some(1) { + "execution_budget_exceeded" + } else { + "execution_failed" + }; + let mut reply = wire::error(Some(id), code); + reply["session_cleanup"] = cleanup.clone().unwrap_or(Value::Null); + let mut replies = vec![reply]; + if self.pending.is_some() + && self.close_id.is_some() + && cleanup.is_some() + && !gate.is_closed() + { + replies.push(self.closed(cleanup.expect("checked cleanup"))); + } + return replies; + } + vec![self.closed(cleanup.expect("confirmed cleanup"))] + } + + fn closed(&self, cleanup: Value) -> Value { + let mut reply = wire::response( + self.close_id.as_ref().unwrap_or(&self.open_id), + "native_session_closed", + ); + reply["cleanup"] = cleanup; + reply + } +} + +fn native_cleanup(result: &Result) -> Option { + let value = match result { + Ok(value) => Some(value.clone()), + Err(error) => super::supervise::native_session::cleanup(error), + }; + value.filter(|value| { + *value == json!({"version":1,"child_reaped":true,"private_state_removed":true}) + }) +} + +impl Drop for NativeSession { + fn drop(&mut self) { + let _ = self.activity.send(false); + } +} + +type NativeSessionStart = + fn(String, Arc, ExecutionSlot, Handshakes) -> NativeSession; + +fn connection_request( + request: wire::Request, + config: &Arc, + gate: &Arc, + active: &mut Option, + handshakes: &mut Handshakes, + native: &mut Option, + start_native: NativeSessionStart, +) -> Option { + if let Some(session) = native { + return session.request(request); + } + if !matches!(request.operation, wire::Operation::OpenNativeSession {}) { + return Some(respond(request, config, gate, active, handshakes)); + } + if !handshakes.conversation + || handshakes.native_session != Some(1) + || handshakes.generation_policy != Some(1) + { + Some(wire::error(Some(&request.id), "handshake_required")) + } else if !config + .native_backend + .supports_native_session(config.model_profile) + { + Some(wire::error(Some(&request.id), "invalid_request")) + } else if gate.is_closed() { + Some(wire::error(Some(&request.id), "cleanup_unconfirmed")) + } else if active.is_some() { + Some(wire::error(Some(&request.id), "busy")) + } else if let Some(slot) = ExecutionSlot::admit(gate) { + *native = Some(start_native( + request.id, + config.clone(), + slot, + handshakes.clone(), + )); + None + } else { + Some(wire::error(Some(&request.id), "busy")) + } +} + async fn connection( + stream: UnixStream, + config: Arc, + gate: Arc, + shutdown: watch::Receiver, +) { + connection_with_native(stream, config, gate, shutdown, NativeSession::start).await; +} + +async fn connection_with_native( stream: UnixStream, config: Arc, gate: Arc, mut shutdown: watch::Receiver, + start_native: NativeSessionStart, ) { let (mut reader, mut writer) = stream.into_split(); let (send, mut receive) = mpsc::channel(1); @@ -473,7 +802,16 @@ async fn connection( let mut handshakes = Handshakes::default(); let mut seen = BTreeSet::new(); let mut active: Option = None; + let mut native: Option = None; + let mut native_events_open = true; loop { + // Borrow the disjoint channel/handle fields separately while select! + // polls them; no two futures may borrow the whole session mutably. + let (events, execution) = if let Some(session) = native.as_mut() { + (Some(&mut session.events), session.execution.as_mut()) + } else { + (None, None) + }; tokio::select! { biased; _ = shutdown.changed() => break, @@ -488,8 +826,32 @@ async fn connection( let _ = wire::write(&mut writer, &wire::error(Some(&request.id), "invalid_request")).await; break; } - let response = respond(request, &config, &gate, &mut active, &mut handshakes); - if wire::write(&mut writer, &response).await.is_err() { break; } + let response = connection_request(request, &config, &gate, &mut active, + &mut handshakes, &mut native, start_native); + if let Some(response) = response { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + } + event = async { + events.expect("guarded native events").recv().await + }, if events.is_some() && native_events_open => { + if let Some(event) = event { + if let Some(response) = native.as_mut().expect("native session").event(event) { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + } else { + native_events_open = false; + } + } + result = async { + execution.expect("guarded native execution").await + }, if execution.is_some() => { + let mut session = native.take().expect("completed native session"); + session.execution.take(); + for response in session.terminal(result, &gate) { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + break; // A closed/failed owner session cannot be reused or reopened. } result = async { active.as_mut().expect("guarded active task").execution.as_mut().expect("active handle").await @@ -512,6 +874,13 @@ async fn connection( task.cancel(); let _ = task.reap().await; } + if let Some(mut session) = native { + session.cancel(); + if let Some(execution) = session.execution.take() { + // No detached worker after EOF, shutdown, invalid frame or write failure. + let _ = execution.await; + } + } } pub(super) async fn run(options: Options) -> Result<()> { diff --git a/crates/volparossa/src/compute/private_serve/tests.rs b/crates/volparossa/src/compute/private_serve/tests.rs index f87a64977..c869a9728 100644 --- a/crates/volparossa/src/compute/private_serve/tests.rs +++ b/crates/volparossa/src/compute/private_serve/tests.rs @@ -4,6 +4,7 @@ use super::*; use tokio::io::AsyncWriteExt as _; +mod native_session; mod output_constraint; mod task_diagnostic; diff --git a/crates/volparossa/src/compute/private_serve/tests/native_session.rs b/crates/volparossa/src/compute/private_serve/tests/native_session.rs new file mode 100644 index 000000000..e8b0ef45c --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/native_session.rs @@ -0,0 +1,553 @@ +//! Inert connection/ownership futures only. No model, worker or sandbox is run. +use super::*; + +use std::time::Duration; + +use sha2::{Digest as _, Sha256}; + +use super::super::super::supervise::native_session::{Command, Event}; + +fn native_config(root: &std::path::Path) -> private_task::ExecutionConfig { + let mut config = super::output_constraint::native_config(root, true); + let manifest = config + .native_backend + .native_backend_root + .as_ref() + .unwrap() + .join("backend.json"); + let mut value: Value = serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap(); + value["version"] = 3.into(); + value["native_sessions"] = json!(["connection_bound_v1"]); + let raw = serde_json::to_vec(&value).unwrap(); + fs::write(manifest, &raw).unwrap(); + config.native_backend.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + config +} + +fn cleanup() -> Value { + json!({"version":1,"child_reaped":true,"private_state_removed":true}) +} + +fn answer() -> Value { + json!({"version":2,"answer":"inert wire fixture","cleanup":{ + "version":2,"request_files_removed":true,"session_closed":false, + "session_private_state_retained":true}}) +} + +fn conversation(instructions: &str) -> Value { + json!({"type":"submit_conversation","conversation":{ + "version":1,"visibility":"private_local","instructions":instructions, + "history":[{"type":"message","role":"user","text":"Hello"}], + "tools":[],"generation_policy":"greedy_v1"}}) +} + +fn fake_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + fake_session_mode(id, slot, handshakes, false) +} + +fn unconfirmed_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + fake_session_mode(id, slot, handshakes, true) +} + +fn idle_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + |_commands, events, _activity| async move { + events.send(Event::Opened).await.unwrap(); + Ok(cleanup()) + }, + ) +} + +fn unknown_failure_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + |_commands, events, _activity| async move { + events.send(Event::Opened).await.unwrap(); + Err(anyhow::anyhow!("PRIVATE-CANARY-unknown-cleanup")) + }, + ) +} + +fn fake_session_mode( + id: String, + slot: ExecutionSlot, + handshakes: Handshakes, + unconfirmed: bool, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + move |mut commands, events, mut activity| async move { + events.send(Event::Opened).await.unwrap(); + loop { + tokio::select! { + _ = activity.changed() => break, + command = commands.recv() => { + match command { + Some(Command::Submit { id, input }) => { + let value: Value = serde_json::from_slice(&input).unwrap(); + if value["instructions"] != "hold" { + let mut result = answer(); + if value["instructions"] == "wrong cleanup" { + result["answer"] = "PRIVATE-CANARY-never-export".into(); + result["cleanup"] = cleanup(); + } + events.send(Event::Result { id, value: result }).await.unwrap(); + } + } + Some(Command::Close) | None => break, + } + } + } + } + if unconfirmed { + Err(private_task::CleanupUnconfirmed.into()) + } else { + Ok(cleanup()) + } + }, + ) +} + +async fn receive(client: &mut UnixStream) -> Value { + tokio::time::timeout( + Duration::from_secs(3), + wire::read(client, wire::MAX_RESPONSE_BYTES), + ) + .await + .unwrap() + .unwrap() + .unwrap() +} + +async fn send(client: &mut UnixStream, id: &str, operation: Value) { + wire::write(client, &request(id, operation)).await.unwrap(); +} + +async fn negotiate(client: &mut UnixStream) { + send( + client, + FIRST, + json!({"type":"conversation_capabilities", + "generation_policy_version":1,"execution_error_version":1, + "output_constraint_version":1,"native_session_version":1}), + ) + .await; + let reply = receive(client).await; + assert_eq!(reply["capabilities"]["native_session_version"], 1); + assert_eq!( + reply["capabilities"]["native_session"], + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128}) + ); +} + +#[test] +fn native_session_version_and_operations_reject_unknown_null_duplicate_and_free_fields() { + for version in [ + json!(1), + json!(0), + json!(2), + Value::Null, + json!(true), + json!("1"), + ] { + let value = request( + FIRST, + json!({"type":"conversation_capabilities","native_session_version":version}), + ); + let accepted = serde_json::from_value::(value).is_ok_and(|request| { + request + .validate_profile(ModelProfile::Qwen4bInstruct2507) + .is_ok() + }); + assert_eq!(accepted, version == json!(1)); + } + let duplicate = format!( + r#"{{"version":1,"id":"{FIRST}","operation":{{"type":"conversation_capabilities","native_session_version":1,"native_session_version":1}}}}"# + ); + assert!(serde_json::from_str::(&duplicate).is_err()); + for operation in ["open_native_session", "close_native_session"] { + assert!( + serde_json::from_value::(request(FIRST, json!({"type":operation}))) + .is_ok() + ); + for key in ["session_id", "owner", "path", "max_seconds", "model"] { + let mut value = json!({"type":operation}); + value[key] = "PRIVATE-CANARY".into(); + assert!(serde_json::from_value::(request(FIRST, value)).is_err()); + } + } +} + +#[test] +fn session_capability_requires_opt_in_and_exact_owner_manifest_and_legacy_caps_stay_exact() { + let root = tempfile::tempdir().unwrap(); + let mut config = native_config(root.path()); + let gate = Semaphore::new(1); + let mut handshake = Handshakes::default(); + let original = conversation_capabilities(&config, &gate, &handshake); + assert!(original.get("native_session").is_none()); + handshake.native_session = Some(1); + let mut extended = conversation_capabilities(&config, &gate, &handshake); + assert_eq!( + extended + .as_object_mut() + .unwrap() + .remove("native_session_version"), + Some(json!(1)) + ); + assert_eq!( + extended.as_object_mut().unwrap().remove("native_session"), + Some(json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128})) + ); + assert_eq!(extended, original); + config.native_backend.native_backend_sha256 = Some("0".repeat(64)); + assert_eq!( + conversation_capabilities(&config, &gate, &handshake)["native_session"], + Value::Null + ); +} + +#[tokio::test] +async fn same_connection_two_turns_retain_slot_until_explicit_session_close_and_eof() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut client).await, + wire::response(SECOND, "native_session_opened") + ); + assert_eq!(gate.available_permits(), 0); + for id in [THIRD, "04040404040404040404040404040404"] { + send(&mut client, id, conversation("Reply")).await; + assert_eq!(receive(&mut client).await, wire::response(id, "admitted")); + let reply = receive(&mut client).await; + assert_eq!(reply["result"], answer()); + assert!(reply["result"]["cleanup"].get("child_reaped").is_none()); + assert_eq!(gate.available_permits(), 0); + } + let close = "05050505050505050505050505050505"; + send(&mut client, close, json!({"type":"close_native_session"})).await; + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":close,"event":"native_session_closed","cleanup":cleanup()}) + ); + assert!( + wire::read::(&mut client, wire::MAX_RESPONSE_BYTES) + .await + .unwrap() + .is_none() + ); + serving.await.unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn close_during_turn_cancels_before_closed_and_unconfirmed_cleanup_quarantines() { + for unconfirmed in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let start: NativeSessionStart = if unconfirmed { + unconfirmed_session + } else { + fake_session + }; + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + start, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!(receive(&mut client).await["event"], "native_session_opened"); + send(&mut client, THIRD, conversation("hold")).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + let close = "04040404040404040404040404040404"; + send(&mut client, close, json!({"type":"close_native_session"})).await; + let reply = receive(&mut client).await; + assert_eq!(reply["id"], THIRD); + assert_eq!( + reply["code"], + if unconfirmed { + "cleanup_unconfirmed" + } else { + "cancelled" + } + ); + assert_eq!( + reply["session_cleanup"], + if unconfirmed { Value::Null } else { cleanup() } + ); + if !unconfirmed { + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":close,"event":"native_session_closed","cleanup":cleanup()}) + ); + } + serving.await.unwrap(); + assert_eq!(gate.is_closed(), unconfirmed); + } +} + +#[tokio::test] +async fn eof_shutdown_and_invalid_turn_cleanup_wait_for_session_and_never_export_raw_output() { + for mode in ["eof", "shutdown", "invalid"] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut client).await; + let instruction = if mode == "invalid" { + "wrong cleanup" + } else { + "hold" + }; + send(&mut client, THIRD, conversation(instruction)).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + if mode == "invalid" { + let reply = receive(&mut client).await; + assert_eq!(reply["code"], "execution_failed"); + assert_eq!(reply["session_cleanup"], cleanup()); + assert!(!reply.to_string().contains("PRIVATE-CANARY")); + } else if mode == "shutdown" { + stop.send(true).unwrap(); + } else { + drop(client); + } + tokio::time::timeout(Duration::from_secs(3), serving) + .await + .unwrap() + .unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); + } +} + +#[tokio::test] +async fn session_slot_is_connection_exclusive_and_open_requires_its_own_handshake() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (first_server, mut first) = UnixStream::pair().unwrap(); + let (second_server, mut second) = UnixStream::pair().unwrap(); + let (stop, shutdown) = watch::channel(false); + let owner = tokio::spawn(connection_with_native( + first_server, + config.clone(), + gate.clone(), + shutdown.clone(), + fake_session, + )); + let other = tokio::spawn(connection_with_native( + second_server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut first).await; + send(&mut first, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut first).await; + send(&mut second, THIRD, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut second).await, + wire::error(Some(THIRD), "handshake_required") + ); + negotiate(&mut second).await; + send(&mut second, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!(receive(&mut second).await, wire::error(Some(SECOND), "busy")); + assert_eq!(gate.available_permits(), 0); + stop.send(true).unwrap(); + owner.await.unwrap(); + other.await.unwrap(); + assert_eq!(gate.available_permits(), 1); +} + +#[tokio::test] +async fn automatic_idle_close_uses_original_open_id_and_requires_joined_cleanup() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + idle_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut client).await, + wire::response(SECOND, "native_session_opened") + ); + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":SECOND,"event":"native_session_closed","cleanup":cleanup()}) + ); + serving.await.unwrap(); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn turn_count_and_negotiated_constraint_are_fixed_for_the_owner_session() { + let gate = Arc::new(Semaphore::new(1)); + let slot = ExecutionSlot::admit(&gate).unwrap(); + let mut session = NativeSession::spawn( + FIRST.into(), + slot, + Handshakes::default(), + |_commands, _events, mut activity| async move { + activity.changed().await.unwrap(); + Ok(cleanup()) + }, + ); + session.opened = true; + session.requests = 128; + let query = serde_json::from_value(request(SECOND, conversation("Reply"))).unwrap(); + assert_eq!( + session.request(query), + Some(wire::error(Some(SECOND), "invalid_request")) + ); + assert!(session.pending.is_none()); + session.requests = 0; + let constrained = serde_json::from_value(request( + THIRD, + json!({"type":"submit_conversation","conversation":{ + "version":1,"visibility":"private_local","instructions":"Reply", + "history":[{"type":"message","role":"user","text":"Hello"}], + "tools":[{"type":"custom","name":"read","description":"Read"}], + "generation_policy":"greedy_v1","output_constraint":"qwen_tagged_tool_json_v1"}}), + )) + .unwrap(); + assert_eq!( + session.request(constrained), + Some(wire::error(Some(THIRD), "invalid_request")) + ); + session.cancel(); + session.execution.take().unwrap().await.unwrap().unwrap(); + assert_eq!(gate.available_permits(), 1); +} + +#[tokio::test] +async fn ordinary_cancel_closes_the_session_only_after_confirmed_join() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut client).await; + send(&mut client, THIRD, conversation("hold")).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + let cancel = "04040404040404040404040404040404"; + send( + &mut client, + cancel, + json!({"type":"cancel","task_id":THIRD}), + ) + .await; + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":cancel,"event":"cancel_requested","task_id":THIRD}) + ); + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":THIRD,"event":"error","code":"cancelled","session_cleanup":cleanup()}) + ); + serving.await.unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn opaque_engine_failure_without_cleanup_facts_quarantines_before_slot_release() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + unknown_failure_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!(receive(&mut client).await["event"], "native_session_opened"); + let reply = receive(&mut client).await; + assert_eq!( + reply, + json!({"version":1,"id":SECOND,"event":"error", + "code":"cleanup_unconfirmed","session_cleanup":null}) + ); + assert!(!reply.to_string().contains("PRIVATE-CANARY")); + assert!(gate.is_closed()); + assert!(ExecutionSlot::admit(&gate).is_none()); + serving.await.unwrap(); +} diff --git a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs index 68dce6d79..6400df491 100644 --- a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs +++ b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs @@ -5,7 +5,7 @@ use sha2::{Digest as _, Sha256}; const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; -fn native_config(root: &std::path::Path, extended: bool) -> private_task::ExecutionConfig { +pub(super) fn native_config(root: &std::path::Path, extended: bool) -> private_task::ExecutionConfig { let mut config = config(root); config.model_profile = ModelProfile::Qwen4bInstruct2507; let backend = root.join("backend"); diff --git a/crates/volparossa/src/compute/private_serve/wire.rs b/crates/volparossa/src/compute/private_serve/wire.rs index 31b1149d0..6c9a665f0 100644 --- a/crates/volparossa/src/compute/private_serve/wire.rs +++ b/crates/volparossa/src/compute/private_serve/wire.rs @@ -33,7 +33,11 @@ pub(super) enum Operation { execution_error_version: Option, #[serde(default, deserialize_with = "present_version")] output_constraint_version: Option, + #[serde(default, deserialize_with = "present_version")] + native_session_version: Option, }, + OpenNativeSession {}, + CloseNativeSession {}, SubmitConversation { conversation: super::super::private_conversation::Input, }, @@ -78,6 +82,7 @@ impl Request { generation_policy_version, execution_error_version, output_constraint_version, + native_session_version, } = &self.operation { ensure!( @@ -92,6 +97,10 @@ impl Request { output_constraint_version.is_none_or(|version| version == 1), "private_ipc_output_constraint_version" ); + ensure!( + native_session_version.is_none_or(|version| version == 1), + "private_ipc_native_session_version" + ); } if let Operation::Cancel { task_id } = &self.operation { ensure!(valid_id(task_id), "private_ipc_invalid_request"); diff --git a/crates/volparossa/src/compute/private_task.rs b/crates/volparossa/src/compute/private_task.rs index c9a7dde15..5f7edbc98 100644 --- a/crates/volparossa/src/compute/private_task.rs +++ b/crates/volparossa/src/compute/private_task.rs @@ -261,6 +261,29 @@ pub(super) fn validate_mode_report( raw: &[u8], profile: ModelProfile, mode: Mode, +) -> Result<()> { + validate_mode_report_lifetime(report, raw, profile, mode, true) +} + +pub(super) fn validate_native_session_report( + report: &Value, + raw: &[u8], + profile: ModelProfile, +) -> Result<()> { + ensure!( + profile == ModelProfile::Qwen4bInstruct2507 + && report["supervisor"]["native_session_version"] == 1, + "compute_native_session_result_scope" + ); + validate_mode_report_lifetime(report, raw, profile, Mode::PrivateConversation, false) +} + +fn validate_mode_report_lifetime( + report: &Value, + raw: &[u8], + profile: ModelProfile, + mode: Mode, + child_reaped: bool, ) -> Result<()> { validate_profile_input(mode, raw, profile)?; let spec = profile.spec(); @@ -271,7 +294,7 @@ pub(super) fn validate_mode_report( && report["private_data_supported"] == true && report["distributed_execution_claimed"] == false && report["private_training_claimed"] == false - && report["supervisor"]["child_reaped"] == true + && report["supervisor"]["child_reaped"] == child_reaped && report["supervisor"]["network_access"] == false && report["model_weights_loaded"] == true && report.get("baseline_evaluation").is_none() diff --git a/crates/volparossa/src/compute/sandbox.rs b/crates/volparossa/src/compute/sandbox.rs index db4d45870..c3e75d3f1 100644 --- a/crates/volparossa/src/compute/sandbox.rs +++ b/crates/volparossa/src/compute/sandbox.rs @@ -1,6 +1,6 @@ //! Fixed Debian sandbox, never a remotely supplied executable or shell command. -use std::process::Stdio; +use std::{path::Path, process::Stdio}; use tokio::process::Command; @@ -51,6 +51,9 @@ fn worker_source() -> String { let native_cpu = serde_json::json!(include_str!( "../../../../workers/volparossa-ml/llama_cpu.py" )); + let native_session = serde_json::json!(include_str!( + "../../../../workers/volparossa-ml/native_session.py" + )); format!( "import sys as _vp_sys, types as _vp_types\n\ _vp_decoder = _vp_types.ModuleType('volparossa_task_graph_decoder')\n\ @@ -67,14 +70,25 @@ fn worker_source() -> String { _vp_sys.modules['volparossa_qwen_conversation'] = _vp_qwen\n\ _vp_native_cpu = _vp_types.ModuleType('volparossa_llama_cpu')\n\ _vp_sys.modules['volparossa_llama_cpu'] = _vp_native_cpu\n\ - exec({native_cpu}, _vp_native_cpu.__dict__)\n{}", + exec({native_cpu}, _vp_native_cpu.__dict__)\n\ + _vp_native_session = _vp_types.ModuleType('volparossa_native_session')\n\ + _vp_sys.modules['volparossa_native_session'] = _vp_native_session\n\ + exec({native_session}, _vp_native_session.__dict__)\n{}", include_str!("../../../../workers/volparossa-ml/worker.py") ) } +pub(super) fn command(options: &Options) -> Command { + command_inner(options, None) +} + +pub(super) fn native_session_command(options: &Options, root: &Path) -> Command { + command_inner(options, Some(root)) +} + // Keep the complete fixed sandbox argument vector reviewable in one place. #[allow(clippy::too_many_lines)] -pub(super) fn command(options: &Options) -> Command { +fn command_inner(options: &Options, session_root: Option<&Path>) -> Command { let mut command = Command::new("/usr/bin/bwrap"); if let Some(root) = &options.native_backend.native_backend_root { command.arg("--ro-bind").arg(root).arg("/native-backend"); @@ -121,16 +135,26 @@ pub(super) fn command(options: &Options) -> Command { .arg("/runtime") .arg("--ro-bind") .arg(&options.model_root) - .arg("/model") - .arg("--ro-bind") - .arg(&options.dataset) - .arg("/dataset.json") - .arg("--bind") - .arg(&options.output) - .arg("/output") + .arg("/model"); + if let Some(root) = session_root { + command.arg("--bind").arg(root).arg("/session"); + } else { + command + .arg("--ro-bind") + .arg(&options.dataset) + .arg("/dataset.json") + .arg("--bind") + .arg(&options.output) + .arg("/output"); + } + command .args([ "--chdir", - "/output", + if session_root.is_some() { + "/session" + } else { + "/output" + }, "--setenv", "LANG", "C.UTF-8", @@ -174,7 +198,8 @@ pub(super) fn command(options: &Options) -> Command { // to use that same window rather than killing a two-thread job early. &format!( "--cpu={}", - u64::from(options.max_seconds) * u64::from(options.threads) + session_root.map_or(u64::from(options.max_seconds), |_| 2400) + * u64::from(options.threads) ), "/usr/bin/nice", "-n", @@ -288,6 +313,54 @@ mod tests { } } + #[test] + fn native_session_keeps_fixed_sandbox_with_distinct_task_cpu_and_mount_lifetime() { + let options = super::Options { + mode: crate::compute::Mode::PrivateConversation, + runtime_root: "/owner/runtime".into(), + model_root: "/owner/model".into(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: crate::compute::native_cpu::Options { + native_backend_root: Some("/owner/native".into()), + native_backend_sha256: Some("a".repeat(64)), + }, + adapter_root: None, + dataset: "/owner/session/turn-1/input.json".into(), + output: "/owner/session/turn-1/output".into(), + steps: 1, + threads: 2, + max_seconds: 600, + spare_capacity: true, + execute: true, + }; + let command = + super::native_session_command(&options, std::path::Path::new("/owner/session")); + let arguments: Vec<_> = command.as_std().get_args().collect(); + assert!( + arguments + .windows(3) + .any(|args| args == ["--bind", "/owner/session", "/session"]) + ); + for (source, destination) in [ + ("/owner/model", "/model"), + ("/owner/runtime", "/runtime"), + ("/owner/native", "/native-backend"), + ] { + assert!( + arguments + .windows(3) + .any(|args| args == ["--ro-bind", source, destination]) + ); + } + for flag in ["--unshare-net", "--die-with-parent", "--cpu=4800"] { + assert!(arguments.iter().any(|arg| *arg == flag)); + } + assert!(!arguments.iter().any(|arg| *arg == "/dataset.json")); + let legacy = super::command(&options); + assert!(legacy.as_std().get_args().any(|arg| arg == "--cpu=1200")); + assert!(worker_source().contains("ModuleType('volparossa_native_session')")); + } + #[test] fn fixed_worker_bundles_decoder_without_a_filesystem_import_or_oversized_argument() { let source = worker_source(); diff --git a/crates/volparossa/src/compute/supervise.rs b/crates/volparossa/src/compute/supervise.rs index 7a74235a5..88a67568c 100644 --- a/crates/volparossa/src/compute/supervise.rs +++ b/crates/volparossa/src/compute/supervise.rs @@ -2,6 +2,7 @@ pub(super) mod diagnostic; mod lifetime; +pub(super) mod native_session; use std::{ collections::BTreeSet, fmt, fs::File, io::Read, os::unix::process::ExitStatusExt, path::Path, @@ -441,6 +442,10 @@ fn check_result(value: &Value, request: &WorkerRequest, status: ExitStatus) -> R if value.get("status").and_then(Value::as_str) == Some("error") { return Err(worker_failure(value, &request.id, status)); } + check_successful_result(value, request) +} + +fn check_successful_result(value: &Value, request: &WorkerRequest) -> Result<()> { ensure!( value.get("status").and_then(Value::as_str) == Some("ok"), "compute_backend_failed" diff --git a/crates/volparossa/src/compute/supervise/lifetime.rs b/crates/volparossa/src/compute/supervise/lifetime.rs index ad23540e8..60c9358c7 100644 --- a/crates/volparossa/src/compute/supervise/lifetime.rs +++ b/crates/volparossa/src/compute/supervise/lifetime.rs @@ -107,6 +107,35 @@ impl OwnedLifetimes { .await; } } + + /// Aggregate process CPU for this exact still-observable tree. Each record + /// includes its own waited-child counters, but a previously observed member + /// disappearing is conservatively an error, never invented CPU credit. + pub(super) fn cpu_ticks(&self) -> Result { + ensure!( + !self.observation_failed, + "compute_private_process_observation" + ); + let mut total = 0_u64; + for expected in &self.members { + let root = Path::new("/proc").join(expected.pid.to_string()); + let raw = super::system_text(&root.join("stat"))?; + ensure!( + parse_record(expected.pid, &raw)?.identity == *expected, + "compute_native_session_cpu_identity" + ); + let (_, tail) = raw + .rsplit_once(") ") + .context("compute_native_session_cpu_stat")?; + let fields: Vec<_> = tail.split_whitespace().collect(); + for index in [11, 12, 13, 14] { + total = total + .checked_add(fields[index].parse::()?) + .context("compute_native_session_cpu_bound")?; + } + } + Ok(total) + } } fn same_lifetime(expected: Identity) -> Result { @@ -203,6 +232,7 @@ mod tests { let tracker = OwnedLifetimes::capture(pid).unwrap(); child.kill().await.unwrap(); assert!(timed_out.is_err()); + assert!(tracker.cpu_ticks().is_err()); tracker .wait_closed(Instant::now() + Duration::from_secs(1)) .await diff --git a/crates/volparossa/src/compute/supervise/native_session.rs b/crates/volparossa/src/compute/supervise/native_session.rs new file mode 100644 index 000000000..24635e86a --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session.rs @@ -0,0 +1,314 @@ +//! A single same-owner IPC connection owns one resident native worker. +//! Per-turn files and whole-process cleanup have deliberately different receipts. + +#[cfg(test)] +mod tests; +mod worker; + +use std::{ + fmt, fs, + io::Write as _, + os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::Path, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context as _, Result, ensure}; +use serde_json::{Value, json}; +use tokio::{ + sync::{mpsc, watch}, + time::Instant, +}; + +use crate::compute::{ + Mode, Options, WorkerRequest, native_cpu, private_conversation, private_task, resources, + runtime_lease, +}; + +pub(in crate::compute) const MAX_TASK_SECONDS: u64 = 2400; +pub(in crate::compute) const MAX_IDLE_SECONDS: u64 = 300; +pub(in crate::compute) const MAX_REQUESTS: u16 = 128; + +pub(in crate::compute) enum Command { + Submit { id: String, input: Vec }, + Close, +} + +pub(in crate::compute) enum Event { + Opened, + Result { id: String, value: Value }, +} + +#[derive(Debug)] +struct Failure { + cause: anyhow::Error, +} + +impl fmt::Display for Failure { + fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result { + output.write_str("compute_native_session_execution_failed") + } +} + +impl std::error::Error for Failure {} + +fn cleanup_record() -> Value { + json!({"version":1,"child_reaped":true,"private_state_removed":true}) +} + +/// Only this private type is constructed after both independent cleanup steps. +pub(in crate::compute) fn cleanup(error: &anyhow::Error) -> Option { + error.downcast_ref::().map(|_| cleanup_record()) +} + +pub(in crate::compute) fn execution_deadline(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some_and(|failure| { + failure + .cause + .downcast_ref::() + .is_some() + }) +} + +struct PrivateRoot(Option); + +impl PrivateRoot { + fn new(parent: &Path) -> Result { + crate::compute::private_directory(parent)?; + Ok(Self(Some( + tempfile::Builder::new() + .prefix("private-native-session-") + .permissions(fs::Permissions::from_mode(0o700)) + .tempdir_in(parent)?, + ))) + } + + fn path(&self) -> &Path { + self.0 + .as_ref() + .expect("owned session root exists until explicit cleanup") + .path() + } + + fn close(mut self) -> Result<()> { + self.0 + .take() + .context("compute_native_session_root")? + .close() + .map_err(|_| anyhow::anyhow!("compute_private_storage_cleanup_failed")) + } +} + +impl Drop for PrivateRoot { + fn drop(&mut self) { + // Panic/cancellation cannot unlink files still mounted by an unjoined child. + if let Some(root) = self.0.take() { + let _ = root.keep(); + } + } +} + +fn stage( + config: &private_task::ExecutionConfig, + root: &Path, + turn: u16, + input: &[u8], +) -> Result { + config + .native_backend + .validate_input(Mode::PrivateConversation, input, config.model_profile)?; + let directory = root.join(format!("turn-{turn}")); + fs::DirBuilder::new().mode(0o700).create(&directory)?; + let dataset = directory.join("input.json"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&dataset)?; + file.write_all(input)?; + file.sync_all()?; + let output = directory.join("output"); + fs::DirBuilder::new().mode(0o700).create(&output)?; + Ok(Options { + mode: Mode::PrivateConversation, + runtime_root: config.runtime_root.clone(), + model_root: config.model_root.clone(), + model_profile: config.model_profile, + native_backend: config.native_backend.clone(), + adapter_root: None, + dataset, + output, + steps: 1, + threads: config.threads, + max_seconds: config.max_seconds, + spare_capacity: true, + execute: true, + }) +} + +fn request(options: &Options, id: String) -> Result { + Ok(WorkerRequest { + native_backend: options.native_backend.request_for_dataset( + options.model_profile, + Mode::PrivateConversation, + &options.dataset, + )?, + version: 1, + id, + mode: Mode::PrivateConversation, + model_profile: options.model_profile, + model_root: "/model", + dataset_path: "/dataset.json", + output_root: "/output", + adapter_root: None, + steps: 1, + threads: options.threads, + max_seconds: options.max_seconds, + owner_control: true, + private_generation_diagnostics: tracing::enabled!(target: "volparossa::compute::private_diagnostic", tracing::Level::DEBUG), + }) +} + +struct Resident { + worker: Option, + hints: Option, + turn: u16, +} + +impl Resident { + #[allow( + clippy::too_many_arguments, + reason = "Task and per-turn identities remain explicit" + )] + async fn submit( + &mut self, + config: &private_task::ExecutionConfig, + root: &Path, + id: String, + input: &[u8], + task_deadline: Instant, + activity: &mut watch::Receiver, + ) -> Result { + ensure!(self.turn < MAX_REQUESTS, "compute_native_session_request_bound"); + self.turn += 1; + let deadline = + (Instant::now() + Duration::from_secs(u64::from(config.max_seconds))).min(task_deadline); + let options = stage(config, root, self.turn, input)?; + let request = request(&options, id)?; + if self.worker.is_none() { + resources::admission(config.model_profile, true)?; + ensure!(*activity.borrow(), "compute_owner_busy"); + self.hints = Some(native_cpu::SessionHints::bind( + &request.native_backend, + &options, + )); + self.worker = Some(worker::Live::spawn(&options, root)?); + } + let report = self + .worker + .as_mut() + .context("compute_native_session_worker")? + .turn(&request, &options, self.turn, deadline, activity) + .await?; + private_task::validate_native_session_report(&report, input, config.model_profile)?; + // The worker has acknowledged its no-more-IO turn boundary, not exited. + let turn_root = options + .dataset + .parent() + .context("compute_native_session_turn_root")?; + fs::remove_dir_all(turn_root) + .map_err(|_| anyhow::anyhow!("compute_private_storage_cleanup_failed"))?; + let mut value = private_conversation::summary(&report, config.model_profile); + value["version"] = json!(2); + value["cleanup"] = json!({"version":2,"request_files_removed":true, + "session_closed":false,"session_private_state_retained":true}); + Ok(value) + } +} + +async fn serve( + config: &private_task::ExecutionConfig, + root: &Path, + resident: &mut Resident, + commands: &mut mpsc::Receiver, + events: &mpsc::Sender, + activity: &mut watch::Receiver, +) -> Result<()> { + let task_deadline = Instant::now() + Duration::from_secs(MAX_TASK_SECONDS); + let mut idle_deadline = Instant::now() + Duration::from_secs(MAX_IDLE_SECONDS); + let mut ticks = tokio::time::interval(Duration::from_millis(250)); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + events + .send(Event::Opened) + .await + .context("compute_native_session_receiver")?; + loop { + tokio::select! { + biased; + () = tokio::time::sleep_until(task_deadline.min(idle_deadline)) => return Ok(()), + changed = activity.changed() => { + ensure!(changed.is_ok() && *activity.borrow(), "compute_owner_busy"); + }, + command = commands.recv() => match command { + Some(Command::Submit { id, input }) => { + let value = resident.submit(config, root, id.clone(), &input, task_deadline, activity).await?; + events.send(Event::Result { id, value }).await.context("compute_native_session_receiver")?; + idle_deadline = Instant::now() + Duration::from_secs(MAX_IDLE_SECONDS); + }, + Some(Command::Close) | None => return Ok(()), + }, + _ = ticks.tick() => if let Some(worker) = resident.worker.as_mut() { + worker.idle(root, config.model_profile)?; + }, + } + } +} + +pub(in crate::compute) async fn run( + config: Arc, + mut commands: mpsc::Receiver, + events: mpsc::Sender, + mut activity: watch::Receiver, +) -> Result { + ensure!( + !nix::unistd::geteuid().is_root(), + "compute_unprivileged_user_required" + ); + config.validate()?; + ensure!( + config + .native_backend + .supports_native_session(config.model_profile), + "compute_native_session_unsupported" + ); + let _lease = runtime_lease(&config.runtime_root)?; + let root = PrivateRoot::new(&config.work_parent)?; + let mut resident = Resident { + worker: None, + hints: None, + turn: 0, + }; + let result = serve( + &config, + root.path(), + &mut resident, + &mut commands, + &events, + &mut activity, + ) + .await; + if let Some(worker) = resident.worker.take() { + // This join is mandatory even when the client disconnects or a validator fails. + worker.close().await?; + } + root.close()?; + let result = result + .map(|()| cleanup_record()) + .map_err(|cause| anyhow::Error::new(Failure { cause })); + if let Some(hints) = resident.hints.take() { + hints.finish(&result); + } + result +} diff --git a/crates/volparossa/src/compute/supervise/native_session/tests.rs b/crates/volparossa/src/compute/supervise/native_session/tests.rs new file mode 100644 index 000000000..8b09ce807 --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session/tests.rs @@ -0,0 +1,97 @@ +//! Inert lifecycle/authority checks; no namespace, model or native backend. + +use std::os::unix::fs::PermissionsExt as _; + +use super::*; + +#[test] +fn cleanup_is_explicitly_constructed_only_after_join_and_removal() { + let ordinary = anyhow::anyhow!("not a cleanup proof"); + assert_eq!(cleanup(&ordinary), None); + assert_eq!(cleanup(&private_task::CleanupUnconfirmed.into()), None); + let confirmed = anyhow::Error::new(Failure { + cause: super::super::ExecutionDeadline.into(), + }); + assert_eq!(cleanup(&confirmed), Some(cleanup_record())); + assert!(execution_deadline(&confirmed)); + assert!(!execution_deadline(&ordinary)); + let different = anyhow::Error::new(Failure { + cause: anyhow::anyhow!("compute_owner_busy"), + }); + assert!(!execution_deadline(&different)); +} + +#[test] +fn private_root_drop_preserves_uncertain_state_but_explicit_close_removes_it() { + let parent = tempfile::tempdir().unwrap(); + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let root = PrivateRoot::new(parent.path()).unwrap(); + let retained = root.path().to_owned(); + fs::write(retained.join("inert-canary"), b"private").unwrap(); + assert_eq!( + fs::metadata(&retained).unwrap().permissions().mode() & 0o777, + 0o700 + ); + drop(root); + assert_eq!(fs::read(retained.join("inert-canary")).unwrap(), b"private"); + let root = PrivateRoot::new(parent.path()).unwrap(); + let removed = root.path().to_owned(); + root.close().unwrap(); + assert!(!removed.exists()); + assert!(retained.exists()); +} + +#[test] +fn staged_turn_names_and_permissions_are_core_owned_and_do_not_reuse_files() { + let parent = tempfile::tempdir().unwrap(); + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let root = PrivateRoot::new(parent.path()).unwrap(); + let config = private_task::ExecutionConfig { + runtime_root: "/runtime".into(), + model_root: "/model".into(), + work_parent: parent.path().to_owned(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: native_cpu::Options::default(), + threads: 2, + max_seconds: 600, + }; + let input = serde_json::to_vec(&json!({ + "version":1,"visibility":"private_local","generation_policy":"greedy_v1", + "instructions":"Use all offered tools when appropriate.","tools":[], + "history":[{"type":"message","role":"user","text":"Check this project."}] + })) + .unwrap(); + let one = stage(&config, root.path(), 1, &input).unwrap(); + assert_eq!(one.dataset, root.path().join("turn-1/input.json")); + assert_eq!(one.output, root.path().join("turn-1/output")); + assert_eq!(fs::read(&one.dataset).unwrap(), input); + assert_eq!( + fs::metadata(&one.dataset).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&one.output).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert!(stage(&config, root.path(), 1, &input).is_err()); + let two = stage(&config, root.path(), 2, &input).unwrap(); + assert_ne!(one.dataset, two.dataset); + root.close().unwrap(); +} + +#[test] +fn fixed_task_and_turn_bounds_are_distinct_from_legacy_cleanup() { + assert_eq!( + (MAX_TASK_SECONDS, MAX_IDLE_SECONDS, MAX_REQUESTS), + (2400, 300, 128) + ); + let raw = json!({"conversation":{"type":"assistant","text":"ok"}}); + let legacy = + private_conversation::summary(&raw, crate::compute::ModelProfile::Qwen4bInstruct2507); + assert_eq!(legacy["version"], 1); + assert_eq!( + legacy["cleanup"], + json!({"complete":true,"retained_input":false,"retained_report":false}) + ); + assert_ne!(legacy["cleanup"], cleanup_record()); +} diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs new file mode 100644 index 000000000..e4b950360 --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -0,0 +1,455 @@ +//! Persistent pipe supervision. A turn result is not a process-exit receipt. + +use std::{io::Read as _, path::Path, time::Duration}; + +use anyhow::{Context as _, Result, ensure}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}, + process::{Child, ChildStdin, ChildStdout}, + sync::watch, + task::JoinHandle, + time::Instant, +}; + +use super::super::{self as supervisor, lifetime::OwnedLifetimes}; +use crate::compute::{ + MAX_LINE_BYTES, MAX_STREAM_BYTES, ModelProfile, Options, WorkerRequest, check_message, + owner_control::{Action, Controls}, + private_conversation::decode_worker_message, + resources, sandbox, + spare_capacity::{Budget, Decision}, +}; + +struct Frames { + stream: BufReader, + pending: Vec, + bytes: usize, +} + +impl Frames { + async fn next(&mut self) -> Result { + // Partial bytes belong to this reader, so select cancellation cannot + // silently lose a prefix or splice two differently correlated records. + loop { + let available = self.stream.fill_buf().await?; + ensure!(!available.is_empty(), "compute_native_session_stdout_closed"); + let newline = available.iter().position(|byte| *byte == b'\n'); + let count = newline.unwrap_or(available.len()); + ensure!( + self.pending.len() + count <= MAX_LINE_BYTES, + "compute_worker_line_size" + ); + self.pending.extend_from_slice(&available[..count]); + self.stream.consume(count + usize::from(newline.is_some())); + if newline.is_some() { + self.bytes = self + .bytes + .checked_add(self.pending.len() + 1) + .context("compute_stdout_size")?; + ensure!(self.bytes <= MAX_STREAM_BYTES, "compute_stdout_size"); + let raw = std::mem::take(&mut self.pending); + return decode_worker_message(&raw) + .map_err(|_| anyhow::anyhow!("compute_worker_invalid_json")); + } + } + } +} + +pub(super) struct Live { + child: Child, + stdin: Option, + stdout: Frames, + stderr: Option>>, + lifetimes: Option>, + pid: u32, + budget: Budget, + peak_rss: u64, + clock_ticks: u64, +} + +fn clock_ticks() -> Result { + // Linux's own bounded auxiliary vector, not a subprocess or guessed USER_HZ. + ensure!( + std::mem::size_of::() == 8, + "compute_native_session_cpu_clock" + ); + let mut bytes = Vec::new(); + std::fs::File::open("/proc/self/auxv")? + .take(8193) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= 8192 && bytes.len() % 16 == 0, + "compute_native_session_cpu_clock" + ); + let mut found = None; + for pair in bytes.chunks_exact(16) { + let key = u64::from_ne_bytes(pair[..8].try_into()?); + let value = u64::from_ne_bytes(pair[8..].try_into()?); + if key == libc::AT_CLKTCK { + ensure!( + found.is_none() && (1..=1_000_000).contains(&value), + "compute_native_session_cpu_clock" + ); + found = Some(value); + } + } + found.context("compute_native_session_cpu_clock") +} + +impl Live { + pub(super) fn spawn(options: &Options, root: &Path) -> Result { + let ticks = clock_ticks()?; + let child = sandbox::native_session_command(options, root) + .spawn() + .context("compute_sandbox_spawn")?; + Ok(Self::own(child, ticks)) + } + + fn own(mut child: Child, ticks: u64) -> Self { + // The fixed builder configures all three pipes. A panic still keeps the + // session tree and quarantines its slot; it cannot fabricate cleanup. + let pid = child.id().expect("newly spawned owned worker has an id"); + let stdin = child.stdin.take().expect("fixed piped stdin"); + let stdout = child.stdout.take().expect("fixed piped stdout"); + let stderr = child.stderr.take().expect("fixed piped stderr"); + Self { + child, + stdin: Some(stdin), + stdout: Frames { + stream: BufReader::new(stdout), + pending: Vec::new(), + bytes: 0, + }, + stderr: Some(tokio::spawn(supervisor::drain_stderr(stderr))), + lifetimes: Some(OwnedLifetimes::capture(pid)), + pid, + budget: Budget::new(), + peak_rss: 0, + clock_ticks: ticks, + } + } + + fn tracker(&mut self) -> Result<&mut OwnedLifetimes> { + self.lifetimes + .as_mut() + .context("compute_private_process_observation")? + .as_mut() + .map_err(|_| anyhow::anyhow!("compute_private_process_observation")) + } + + pub(super) fn idle(&mut self, root: &Path, profile: ModelProfile) -> Result<()> { + ensure!( + self.child.try_wait()?.is_none(), + "compute_native_session_child_exited" + ); + self.tracker()?.refresh()?; + self.peak_rss = self.peak_rss.max(supervisor::observe( + self.pid, + root, + false, + resources::limits(profile).observed_rss, + )?); + ensure!( + self.budget.sample() != Decision::Cancel, + "{}", + self.budget.cancellation_code() + ); + ensure!( + !self.stderr.as_ref().is_some_and(JoinHandle::is_finished), + "compute_native_session_stderr_closed" + ); + Ok(()) + } + + pub(super) async fn turn( + &mut self, + request: &WorkerRequest, + options: &Options, + turn: u16, + deadline: Instant, + activity: &mut watch::Receiver, + ) -> Result { + ensure!( + *activity.borrow() && Instant::now() < deadline, + "compute_owner_busy" + ); + self.tracker()?.refresh()?; + let before = self.tracker()?.cpu_ticks()?; + let controls = Controls::default(); + let progress = + supervisor::diagnostic::Progress::with_generation(request.private_generation_diagnostics); + let mut value = serde_json::to_value(request)?; + value["dataset_path"] = json!(format!("/session/turn-{turn}/input.json")); + value["output_root"] = json!(format!("/session/turn-{turn}/output")); + value["native_session"] = json!({"version":1,"turn":turn}); + let mut bytes = serde_json::to_vec(&value)?; + ensure!(bytes.len() < 65536, "compute_request_limit"); + bytes.push(b'\n'); + bytes.extend( + controls + .issue(supervisor::pressure_action(&mut self.budget)?, &request.id)? + .context("compute_initial_control")?, + ); + self.stdout.bytes = 0; + let result = self + .turn_inner( + request, options, deadline, activity, before, &controls, &progress, &bytes, + ) + .await; + if result.is_err() { + supervisor::diagnostic::terminal_state( + &progress, + &self.budget, + Some(&controls), + self.peak_rss, + ); + } + result + } + + async fn write(&mut self, bytes: &[u8], deadline: Instant) -> Result<()> { + tokio::time::timeout_at( + deadline.min(Instant::now() + Duration::from_secs(1)), + self.stdin + .as_mut() + .context("compute_control_stdin")? + .write_all(bytes), + ) + .await + .context("compute_control_write_deadline")??; + Ok(()) + } + + fn tick(&mut self, options: &Options, before: u64) -> Result<()> { + let root = options + .dataset + .parent() + .and_then(Path::parent) + .context("compute_native_session_root")?; + self.idle(root, options.model_profile)?; + let consumed = self + .tracker()? + .cpu_ticks()? + .checked_sub(before) + .context("compute_native_session_cpu_backwards")?; + let bound = u64::from(options.max_seconds) * u64::from(options.threads) * self.clock_ticks; + ensure!(consumed < bound, "compute_native_session_cpu_budget"); + Ok(()) + } + + #[allow( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "One original turn deadline surrounds controls, result and quiescence before file removal" + )] + async fn turn_inner( + &mut self, + request: &WorkerRequest, + options: &Options, + deadline: Instant, + activity: &mut watch::Receiver, + before: u64, + controls: &Controls, + progress: &supervisor::diagnostic::Progress, + input: &[u8], + ) -> Result { + self.write(input, deadline).await?; + let mut ticks = tokio::time::interval(Duration::from_millis(250)); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut result = None; + loop { + tokio::select! { + biased; + () = tokio::time::sleep_until(deadline) => return Err(supervisor::ExecutionDeadline.into()), + changed = activity.changed() => { + ensure!(changed.is_ok() && *activity.borrow(), "compute_owner_busy"); + }, + record = self.stdout.next() => { + let record = record?; + if record == json!({"version":1,"id":request.id,"kind":"native_turn_idle"}) { + ensure!(result.is_some(), "compute_native_session_unrequested_idle"); + break; + } + let bytes = serde_json::to_vec(&record)?; + let value = check_message(&bytes, &request.id)?; + if value["kind"] == "result" { + ensure!(result.is_none(), "compute_output_after_result"); + controls.terminal()?; + // There is no invented successful exit status here. + supervisor::check_successful_result(&value, request)?; + controls.check_report(&value)?; + supervisor::check_artifacts(&value, options.mode, &options.output)?; + let disk = crate::compute::read_file(&options.output.join("report.json"), MAX_LINE_BYTES as u64)?; + let persisted = decode_worker_message(&disk).map_err(|_| anyhow::anyhow!("compute_native_session_report"))?; + ensure!(persisted == value, "compute_native_session_report"); + result = Some(value); + let mut finish = serde_json::to_vec(&json!({"version":1,"id":request.id,"kind":"native_turn_finish"}))?; + finish.push(b'\n'); + self.write(&finish, deadline).await?; + } else if matches!(value["phase"].as_str(), Some("paused" | "resumed")) { + controls.acknowledge(&value)?; + } else { + ensure!(result.is_none() && value.get("control_sequence").is_none(), "compute_output_after_result"); + progress.observe(&value)?; + } + }, + _ = ticks.tick() => { + self.tick(options, before)?; + if result.is_none() { + // tick() already sampled the unchanged capacity guard. + let action = match self.budget.current() { + Decision::Run => Action::Resume, + Decision::Pause => Action::Pause, + Decision::Cancel => anyhow::bail!("{}", self.budget.cancellation_code()), + }; + if let Some(record) = controls.issue(action, &request.id)? { + self.write(&record, deadline).await?; + } + } + }, + } + } + self.tick(options, before)?; + let mut report = result.context("compute_result_missing")?; + let mut observed = + supervisor::supervisor_report(options, self.peak_rss, Some(controls), &self.budget); + observed["child_reaped"] = json!(false); + observed["native_session_version"] = json!(1); + report["supervisor"] = observed; + Ok(report) + } + + pub(super) async fn close(mut self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(3); + if let Some(Ok(tracker)) = self.lifetimes.as_mut() { + let _ = tracker.refresh(); + } + drop(self.stdin.take()); + // The normal EOF path lets native destructors run; cancellation still + // has the exact original three-second cleanup ceiling and owned child. + if tokio::time::timeout(Duration::from_millis(250), self.child.wait()) + .await + .is_err() + { + let _ = self.child.start_kill(); + } + let joined = tokio::time::timeout_at(deadline, self.child.wait()).await; + if !matches!(joined, Ok(Ok(_))) { + return Err(crate::compute::private_task::CleanupUnconfirmed.into()); + } + let lifetime = self + .lifetimes + .take() + .context("compute_private_process_observation")?; + let closed = match lifetime { + Ok(tracker) => tracker.wait_closed(deadline).await.is_ok(), + Err(_) => false, + }; + let mut stderr = self.stderr.take().context("compute_native_session_stderr")?; + let drained = match tokio::time::timeout_at(deadline, &mut stderr).await { + Ok(Ok(Ok(_))) => true, + _ => { + stderr.abort(); + let _ = stderr.await; + false + } + }; + ensure!( + closed && drained, + crate::compute::private_task::CleanupUnconfirmed + ); + Ok(()) + } +} + +impl Drop for Live { + fn drop(&mut self) { + if let Some(stderr) = self.stderr.take() { + stderr.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::process::Stdio; + + use super::*; + + fn child(script: &str) -> Child { + tokio::process::Command::new("/usr/bin/python3") + .args(["-I", "-c", script]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap() + } + + #[tokio::test] + async fn partial_frame_survives_select_cancellation_without_splicing() { + let mut process = child( + "import sys,time;sys.stdout.write('{\"value\":');sys.stdout.flush();time.sleep(.1);print('1}')", + ); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + let first = tokio::time::timeout(Duration::from_millis(40), frames.next()).await; + assert!(first.is_err()); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), frames.next()) + .await + .unwrap() + .unwrap(), + json!({"value":1}) + ); + assert!(process.wait().await.unwrap().success()); + } + + #[tokio::test] + async fn exact_owned_inert_child_is_joined_before_cleanup_returns() { + let process = child("import sys;sys.stdin.buffer.read()"); + let pid = process.id().unwrap(); + let live = Live::own(process, clock_ticks().unwrap()); + let original = live + .lifetimes + .as_ref() + .unwrap() + .as_ref() + .unwrap() + .cpu_ticks() + .unwrap(); + assert!(original < 1_000_000); + live.close().await.unwrap(); + assert!(!Path::new("/proc").join(pid.to_string()).exists()); + } + + #[tokio::test] + async fn oversized_or_uncorrelated_data_never_becomes_a_turn_boundary() { + let mut process = child("print('x'*17000)"); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + assert!(frames.next().await.is_err()); + let _ = process.kill().await; + let _ = process.wait().await; + } + + #[tokio::test] + async fn duplicate_keys_are_rejected_before_any_value_round_trip() { + let mut process = child("print('{\"version\":0,\"version\":1}')"); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + assert!(frames.next().await.is_err()); + assert!(process.wait().await.unwrap().success()); + } +} diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 556cb3080..850b12581 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -18,6 +18,24 @@ Last updated: 2026-10-15 ### Explicit native CPU inference candidate +#### Connection-bound model reuse candidate + +Actual Code trial `38742699743` on core `3327115e` / Code `8b21775e` +completes two native reads, then reaches the third request's compute deadline. +No edit or test passes. The next slice retains one model and exact processed +prompt prefixes within a single owner connection, with the normal full toolset. +The new [versioned session contract](../crates/volparossa/src/compute/private_conversation/WIRE.md#connection-bound-native-sessions) +separates per-request file removal from final worker/private-state cleanup. +No state is shared across owners or projects; failed admitted requests are not +replayed. The 600-second request ceiling and pressure/memory guards remain; +the resident worker has a separate 2,400-second task lifetime and cumulative CPU +ceiling, with 300-second idle closure and at most 128 requests. + +Python and synthetic Unix/HTTP contracts have targeted coverage. New Rust and +native ABI tests still require hosted execution, followed by the same normal +read/edit/test task with the paired Code candidate. This is in development, not +demonstrated acceleration, complete coding execution or confidential peer work. + #### Source verification lifecycle candidate [Actual trial `38726624377`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38726624377) diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py index 7cf67db6f..7f18bb12b 100644 --- a/workers/volparossa-ml/build_llama_cpu.py +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -27,9 +27,9 @@ LIBRARY = "libvolparossa_llama_cpu.so" ALLOWED_NEEDED = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "ld-linux-x86-64.so.2"} CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", - "vp-native-output-constraint", "vp-upstream-tensor-tests") + "vp-native-output-constraint", "vp-native-session", "vp-upstream-tensor-tests") WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch", - "output-constraint.h", "output-constraint.cpp") + "output-constraint.h", "output-constraint.cpp", "session-state.h") def require(condition, code): @@ -169,6 +169,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): patched_loader(source) plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, "output_constraints": [native.OUTPUT_CONSTRAINT], + "native_sessions": [native.NATIVE_SESSION], "sanitizers": sanitize, "provisionable": not sanitize, "source_overlay": native.loader_provenance(), "jobs": 2, "models_loaded": False, "downloads": False, "install": False} @@ -252,7 +253,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(runtime_notice.is_file(), "NATIVE_TOOLCHAIN_NOTICE") shutil.copyfile(runtime_notice, licenses / "GCC-runtime-copyright") (licenses / "GCC-runtime-copyright").chmod(0o444) - report = dict(plan, version=3, abi_version=1, library={"path": LIBRARY, **digest(library)}, + report = dict(plan, version=4, abi_version=1, library={"path": LIBRARY, **digest(library)}, upstream_original_unchanged=True, loader_compile_source_verified=True, dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, checks=list(CHECK_TARGETS) if checks else [], diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 537ca4d9e..96b54fcf6 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -368,8 +368,10 @@ def check(): "library": {"path": native.LIBRARY, **builder.digest(root / native.LIBRARY)}, "gguf": {"path": "model.gguf", **builder.digest(output)}, "verification": verified, "build_manifest_sha256": hashlib.sha256(build_raw).hexdigest()} - if build["version"] == 3: + if build["version"] in (3, 4): manifest.update(version=2, output_constraints=list(build["output_constraints"])) + if build["version"] == 4: + manifest.update(version=3, native_sessions=list(build["native_sessions"])) native.validate_manifest(manifest, builder.require) retained = sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) builder.require(retained + 65536 <= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index 1802335f8..5d8067202 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -28,13 +28,18 @@ HEX = re.compile(r"[0-9a-f]{64}\Z") ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) OUTPUT_CONSTRAINT = "qwen_tagged_tool_json_v1" +NATIVE_SESSION = "connection_bound_v1" def validate_constraints(value, legacy_version, require, version_error="NATIVE_OUTPUT_CONSTRAINT_VERSION"): """Versioned capability, not a request to enable it or executable grammar.""" version = value.get("version") if type(value) is dict else None - require(type(version) is int and version in (legacy_version, legacy_version + 1), + require(type(version) is int and version in (legacy_version, legacy_version + 1, legacy_version + 2), version_error) + if version == legacy_version + 2: + require(value.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_BINDING") + else: + require("native_sessions" not in value, "NATIVE_SESSION_BINDING") if version == legacy_version: require("output_constraints" not in value, "NATIVE_OUTPUT_CONSTRAINT_BINDING") return [] @@ -74,7 +79,8 @@ def validate_manifest(value, require): fields = {"version", "kind", "abi_version", "source_commit", "model_profile", "source_weights_sha256", "source_weights_bytes", "library", "gguf", "verification", "build_manifest_sha256"} - require(value.keys() == fields | ({"output_constraints"} if constraints else set()), "NATIVE_MANIFEST_FIELDS") + require(value.keys() == fields | ({"output_constraints"} if constraints else set()) + | ({"native_sessions"} if value["version"] == 3 else set()), "NATIVE_MANIFEST_FIELDS") require(type(value["abi_version"]) is int and value["abi_version"] == 1 and value["kind"] == KIND and value["source_commit"] == SOURCE and value["model_profile"] == PROFILE and value["source_weights_sha256"] == WEIGHTS_SHA @@ -118,8 +124,11 @@ def verified_bundle(request, worker, tokenizer, session): provenance = worker.parse_json(build) validate_build_provenance(provenance, require) require(provenance["version"] == value["version"] + 1 - and provenance.get("output_constraints") == value.get("output_constraints"), + and provenance.get("output_constraints") == value.get("output_constraints") + and provenance.get("native_sessions") == value.get("native_sessions"), "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if "native_session" in request: + require(value.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_UNSUPPORTED") if "output_constraint" in request: require(request["output_constraint"] == OUTPUT_CONSTRAINT and value.get("output_constraints") == [OUTPUT_CONSTRAINT], @@ -197,7 +206,7 @@ def joined(self): class NativeModel: def __init__(self, root, capacity, threads, probe, require, *, output_constraints=None, - output_constraint=None, output_constraint_tools=None): + output_constraint=None, output_constraint_tools=None, native_session=False): self.handle = ctypes.c_void_p() self.require, self.probe = require, probe # The caller checked owner-authorized digest before this executable load. @@ -238,6 +247,16 @@ def __init__(self, root, capacity, threads, probe, require, *, output_constraint and type(output_constraint_tools) is list and 1 <= len(output_constraint_tools) <= 32 and output_constraint_tools == [f"vp_{index}" for index in range(len(output_constraint_tools))], "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if native_session: + for name, result, arguments in ( + ("vp_llama_native_session_abi_v1", ctypes.c_uint32, []), + ("vp_llama_begin_turn_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ABORT, ctypes.c_void_p])): + require(hasattr(self.lib, name), "NATIVE_SESSION_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_native_session_abi_v1() + require(type(extension) is int and extension == 1, "NATIVE_SESSION_ABI_MISMATCH") try: status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, probe.callback, None, ctypes.byref(self.handle)) @@ -273,20 +292,40 @@ def sample(self): self.require(status == 0 and 0 <= token.value < 151936, "NATIVE_SAMPLE_FAILED") return token.value + def begin_turn(self, retained_tokens, probe, constraint, aliases): + self.require(type(retained_tokens) is int and 0 <= retained_tokens < 12288, + "NATIVE_SESSION_PREFIX_BOUND") + self.require((constraint is None and aliases is None) or + (constraint == OUTPUT_CONSTRAINT and type(aliases) is list + and 1 <= len(aliases) <= 32 + and aliases == [f"vp_{index}" for index in range(len(aliases))]), + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + # The old callback remains alive until the synchronous ABI handover has + # returned. No native work or callback may outlive this one execution thread. + previous = self.probe + status = self.lib.vp_llama_begin_turn_v1(self.handle, retained_tokens, + 0 if constraint is None else 1, 0 if aliases is None else len(aliases), probe.callback, None) + self.probe = probe + probe.joined() + self.require(status == 0, "NATIVE_SESSION_PREFIX_RESET_FAILED") + del previous + def close(self): if self.handle.value is not None: self.lib.vp_llama_close_v1(self.handle) self.handle = ctypes.c_void_p() -def generate(model, prompt, tokenizer, profile, session, worker, observation=None): +def generate(model, prompt, tokenizer, profile, session, worker, observation=None, *, retained_tokens=0, + decoded_tokens=None): worker.require(type(prompt) is list and 1 <= len(prompt) <= profile["prompt_tokens"] and all(type(token) is int and 0 <= token < 151936 for token in prompt), "NATIVE_PROMPT_BOUND") if observation is not None: observation.forward_begin(None, None) # This is the complete original HF-rendered prompt in bounded prefill batches, # not truncation, sliding context, a second prompt or a hidden warmup. - for start in range(0, len(prompt), 128): + worker.require(type(retained_tokens) is int and 0 <= retained_tokens < len(prompt), "NATIVE_SESSION_PREFIX_BOUND") + for start in range(retained_tokens, len(prompt), 128): session.check() model.decode(prompt[start:start + 128]) if observation is not None: @@ -310,12 +349,16 @@ def generate(model, prompt, tokenizer, profile, session, worker, observation=Non text = text[:-1] if observation is not None: observation.complete() + if decoded_tokens is not None: + # The final sampled token was not decoded (EOS or generation bound). + # Prefix reuse can only name positions actually present in this handle. + decoded_tokens[:] = prompt + generated[:-1] return {"sample_index": 0, "text": text, "generated_tokens": len(generated), "text_truncated": text != raw, "generation": metadata} def execute(request, session, tokenizer, torch, versions, model_root, output_root, - dataset, data_identity, model_files, worker, *, initial_source_weights): + dataset, data_identity, model_files, worker, *, initial_source_weights, retained=None): worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", "NATIVE_GREEDY_POLICY_REQUIRED") constraint = worker.conversation_constraint_binding(request, dataset, PROFILE) @@ -343,18 +386,25 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo session.check() probe = OwnerProbe(session, worker) session.private_progress("model_load", "begin") - model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require, - **constraint_options) + worker.require((retained is not None) == ("native_session" in request), "NATIVE_SESSION_BINDING") + prefix, decoded = 0, [] + if retained is None: + model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require, + **constraint_options) + else: + model, prefix = retained.begin(root, manifest, identity, request, prompt, probe, worker) try: session.private_progress("model_load", "complete") session.progress("baseline") session.private_progress("generation", "begin") observation = (worker.PrivateGenerationObservation(session, torch, len(prompt)) if request.get("private_generation_diagnostics") is True else None) - output = generate(model, prompt, tokenizer, profile, session, worker, observation) + output = generate(model, prompt, tokenizer, profile, session, worker, observation, + retained_tokens=prefix, decoded_tokens=decoded if retained is not None else None) session.private_progress("generation", "complete") finally: - model.close() + if retained is None: + model.close() session.private_progress("verify_after", "begin") worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"], session=session)["sha256"] == manifest["gguf"]["sha256"], "NATIVE_GGUF_CHANGED") @@ -375,4 +425,39 @@ def execute(request, session, tokenizer, torch, versions, model_root, output_roo session.private_progress("result", "begin") result = worker.finish_result(result, output_root, session) session.private_progress("result", "complete") + if retained is not None: + retained.tokens = decoded return result + + +class RetainedModel: + """One interpreter/connection's private RAM; never global or serializable.""" + def __init__(self): + self.model, self.binding, self.tokens = None, None, [] + + def begin(self, root, manifest, identity, request, prompt, probe, worker): + worker.require(manifest.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_UNSUPPORTED") + binding = (str(root), json.dumps(identity, sort_keys=True), request["threads"]) + worker.require(self.binding is None or self.binding == binding, "NATIVE_SESSION_MODEL_CHANGED") + constraint, aliases = request.get("output_constraint"), request.get("output_constraint_tools") + prefix = 0 + if self.model is None: + self.model = NativeModel(root, 12288 + 1024, request["threads"], probe, worker.require, + output_constraints=manifest["output_constraints"], native_session=True) + self.binding = binding + else: + for old, new in zip(self.tokens, prompt[:-1]): + if old != new: + break + prefix += 1 + self.model.begin_turn(prefix, probe, constraint, aliases) + # A failed turn can never authorize reuse, even if its partial decode ran. + self.tokens = [] + return self.model, prefix + + def close(self): + try: + if self.model is not None: + self.model.close() + finally: + self.model, self.binding, self.tokens = None, None, [] diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt index 1590315a0..251be5463 100644 --- a/workers/volparossa-ml/native-cpu/CMakeLists.txt +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -70,6 +70,9 @@ if(VP_NATIVE_TESTS) add_executable(vp-native-abi-smoke abi-smoke.cpp) target_compile_options(vp-native-abi-smoke PRIVATE -UNDEBUG) target_link_libraries(vp-native-abi-smoke PRIVATE volparossa_llama_cpu) + # Real turn-state transitions with backend callbacks, no model or KV claims. + add_executable(vp-native-session session-tests.cpp) + target_compile_options(vp-native-session PRIVATE -UNDEBUG -Wall -Wextra -Werror) # The exact pinned grammar engine with synthetic token pieces; no model or inference. add_executable(vp-native-output-constraint output-constraint-tests.cpp output-constraint.cpp) target_include_directories(vp-native-output-constraint PRIVATE "${VP_LLAMA_SOURCE}/src") diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp index 070ff6c29..36a8a71a0 100644 --- a/workers/volparossa-ml/native-cpu/abi-smoke.cpp +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -13,6 +13,8 @@ static int32_t abort_immediately(void * opaque) { int main() { assert(vp_llama_abi_v1() == 1); assert(vp_llama_output_constraint_abi_v1() == 1); + assert(vp_llama_native_session_abi_v1() == 1); + assert(vp_llama_begin_turn_v1(nullptr, 0, 0, 0, nullptr, nullptr) == VP_INVALID); assert(vp_llama_set_output_constraint_v1(nullptr, 1, 1) == VP_INVALID); assert(vp_llama_open_stage_v1() == VP_OPEN_NONE); assert(std::strcmp(vp_llama_source_v1(), "7fe450e19305b828c199d602c23a8337aaa1f03b") == 0); diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp index 56fdffe1f..88987da9a 100644 --- a/workers/volparossa-ml/native-cpu/adapter.cpp +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -2,6 +2,7 @@ #include "adapter.h" #include "llama.h" #include "output-constraint.h" +#include "session-state.h" #include #include #include @@ -9,6 +10,8 @@ #include #include #include +#include +#include #include namespace { @@ -16,18 +19,14 @@ constexpr uint32_t max_context = 12288 + 1024; constexpr uint32_t max_batch = 128; constexpr int32_t vocabulary = 151936; constexpr const char * source = "7fe450e19305b828c199d602c23a8337aaa1f03b"; -struct state { +struct state : vp::session_state { llama_model * model = nullptr; llama_context * context = nullptr; llama_sampler * sampler = nullptr; vp_abort_v1 abort = nullptr; void * opaque = nullptr; uint32_t capacity = 0; - uint32_t used = 0; - bool ready = false; - bool failed = false; std::unique_ptr constraint; - uint32_t generated = 0; ~state() { if (context) { llama_synchronize(context); } constraint.reset(); @@ -95,9 +94,50 @@ extern "C" uint32_t vp_llama_abi_v1(void) { return 1; } extern "C" const char * vp_llama_source_v1(void) { return source; } extern "C" uint32_t vp_llama_open_stage_v1(void) { return open_stage; } extern "C" uint32_t vp_llama_output_constraint_abi_v1(void) { return 1; } +extern "C" uint32_t vp_llama_native_session_abi_v1(void) { return 1; } + +extern "C" int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained, + uint32_t mode, uint32_t count, + vp_abort_v1 abort, void * opaque) { + auto * value = static_cast(handle); + if (!value) { return VP_INVALID; } + if (!abort || value->failed || !((mode == 0 && count == 0) || + (mode == 1 && count >= 1 && count <= 32))) { + value->ready = false; + value->failed = true; + return VP_INVALID; + } + // The context's original callback still points to this handle; only the + // handle-local owner callback changes, before any new backend operation. + value->abort = abort; + value->opaque = opaque; + return vp::begin_session_turn(*value, retained, value->capacity, + [&] { return aborted(value); }, + [&] { llama_synchronize(value->context); }, + [&](uint32_t prefix) { + const auto memory = llama_get_memory(value->context); + return memory && llama_memory_seq_rm(memory, 0, static_cast(prefix), -1); + }, + [&] { + std::unique_ptr next; + if (mode == 1) { + const auto * vocab = llama_model_get_vocab(value->model); + std::string open, close; + if (llama_vocab_is_control(vocab, 151657) || llama_vocab_is_control(vocab, 151658) || + !token_piece(vocab, 151657, open) || open != "" || + !token_piece(vocab, 151658, close) || close != "") { + throw std::invalid_argument("constraint vocabulary"); + } + next = std::make_unique(vocab, count); + } + llama_sampler_reset(value->sampler); + value->constraint = std::move(next); + }); +} extern "C" int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t count) { auto * value = static_cast(handle); + if (value && value->session_active) { return vp::invalid_session(value); } if (!value || value->failed || value->used || value->constraint || mode != 1 || count < 1 || count > 32) { return VP_INVALID; } @@ -189,9 +229,14 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, extern "C" int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uint32_t count) { auto * value = static_cast(handle); if (!value || !tokens || value->failed || !count || count > max_batch || - value->used > value->capacity || count > value->capacity - value->used) { return VP_INVALID; } + value->used > value->capacity || count > value->capacity - value->used) { + return vp::invalid_session(value); + } + if (value->session_active && value->generated == 0 && value->used + count > 12288) { + return vp::invalid_session(value); + } for (uint32_t i = 0; i < count; ++i) { - if (tokens[i] < 0 || tokens[i] >= vocabulary) { return VP_INVALID; } + if (tokens[i] < 0 || tokens[i] >= vocabulary) { return vp::invalid_session(value); } } try { value->ready = false; @@ -210,13 +255,17 @@ extern "C" int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uin extern "C" int32_t vp_llama_sample_v1(void * handle, int32_t * token) { auto * value = static_cast(handle); - if (!value || !token || value->failed || !value->ready) { return VP_INVALID; } + if (!value || !token || value->failed || !value->ready) { return vp::invalid_session(value); } try { if (aborted(value)) { value->failed = true; return VP_ABORTED; } + if (value->session_active && !vp::session_sample_allowed(*value)) { + value->failed = true; return VP_INVALID; + } if (value->constraint) { return constrained_sample(value, token); } const auto generated = llama_sampler_sample(value->sampler, value->context, -1); if (generated < 0 || generated >= vocabulary) { value->failed = true; return VP_BACKEND; } *token = generated; + if (value->session_active) { ++value->generated; } value->ready = false; return VP_OK; } catch (...) { value->failed = true; return VP_BACKEND; } diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h index 93455d205..10a9c0cf9 100644 --- a/workers/volparossa-ml/native-cpu/adapter.h +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -26,6 +26,16 @@ uint32_t vp_llama_open_stage_v1(void); // Absence preserves the original greedy_v1 path. uint32_t vp_llama_output_constraint_abi_v1(void); int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t tool_count); +// Optional same-owner, same-thread in-memory session. No state crosses handles +// or owners. The caller retains only the exact token LCP of already decoded +// tokens, at most new_prompt_length-1, and must decode a nonempty suffix before +// sampling. Mode/count is either 0/0 (greedy) or 1/1..32 (fresh fixed grammar). +// A failed begin/decode/sample requires close, never a cold fallback. The caller +// keeps each callback/opaque alive until replacement or close has returned. +uint32_t vp_llama_native_session_abi_v1(void); +int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained_tokens, + uint32_t constraint_mode, uint32_t tool_count, + vp_abort_v1 abort, void * opaque); int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, uint32_t threads, vp_abort_v1 abort, void * opaque, void ** handle); diff --git a/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp index 81557da5a..ce96a710c 100644 --- a/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp +++ b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp @@ -192,6 +192,35 @@ void finite_selection_and_cancel() { [&] { return inspected != 0; }, chosen) == vp::selection::cancelled); assert(inspected == 1 && chosen == -1); // Cancellation also during candidate rejection. } + +void independent_turn_grammars() { + auto value = std::make_unique(nullptr, 1); + feed(*value, call); + value->accept("", true); + assert(!value->permits(call, false)); + // The native begin-turn path constructs a fresh grammar, not a clone of + // the previous tool call's ended state or its old offered alias set. + value = std::make_unique(nullptr, 2); + const std::string second = "{\"name\":\"vp_1\",\"arguments\":{}}"; + feed(*value, second); + value->accept("", true); + value = std::make_unique(nullptr, 1); + assert(!value->permits(second, false)); + feed(*value, "A fresh ordinary answer remains permitted."); + assert(value->permits("", true)); + value->accept("", true); + value = std::make_unique(nullptr, 1); + feed(*value, "(nullptr, 1); + feed(*value, call); // No unfinished marker from the old turn. + assert(value->permits("", true)); + value = std::make_unique(nullptr, 1); + feed(*value, "plain \xc3"); + assert(!value->permits("", true)); + value = std::make_unique(nullptr, 1); + feed(*value, call); // No incomplete UTF-8 state from the old turn. + assert(value->permits("", true)); +} } int main() { @@ -202,4 +231,5 @@ int main() { depth_and_wire_bounds(); json_values_and_incomplete_suffix(); finite_selection_and_cancel(); + independent_turn_grammars(); } diff --git a/workers/volparossa-ml/native-cpu/session-state.h b/workers/volparossa-ml/native-cpu/session-state.h new file mode 100644 index 000000000..bad67021b --- /dev/null +++ b/workers/volparossa-ml/native-cpu/session-state.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include "adapter.h" + +namespace vp { +// Process-local state of one handle, never a transferable cache or owner ID. +// Legacy calls leave session_active false and retain their existing behavior. +struct session_state { + uint32_t used = 0; + uint32_t generated = 0; + bool ready = false; + bool failed = false; + bool session_active = false; +}; + +inline int32_t invalid_session(session_state * value) { + if (value && value->session_active) { + value->ready = false; + value->failed = true; + } + return VP_INVALID; +} + +inline bool session_sample_allowed(const session_state & value) { + return value.generated < 1024 && (value.generated != 0 || value.used <= 12288); +} + +// The production callbacks operate on the same already-synchronized context. +// Tests can exercise every failure boundary without a model or synthetic KV +// contents being admitted as an inference result. Any partial reset poisons the +// handle: callers must close it, never silently clear/reload and continue. +template +int32_t begin_session_turn(session_state & value, uint32_t retained, uint32_t capacity, + Cancelled cancelled, Synchronize synchronize, Truncate truncate, Reset reset) { + value.ready = false; // Old logits can never be sampled after begin_turn. + if (value.failed || value.used > capacity || retained > value.used || + retained >= capacity || retained >= 12288) { + value.failed = true; + return VP_INVALID; + } + try { + if (cancelled()) { value.failed = true; return VP_ABORTED; } + synchronize(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + if (!truncate(retained)) { value.failed = true; return VP_BACKEND; } + if (cancelled()) { value.failed = true; return VP_ABORTED; } + reset(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + value.used = retained; + value.generated = 0; + value.session_active = true; + return VP_OK; + } catch (...) { + value.failed = true; + return VP_BACKEND; + } +} +} diff --git a/workers/volparossa-ml/native-cpu/session-tests.cpp b/workers/volparossa-ml/native-cpu/session-tests.cpp new file mode 100644 index 000000000..feaf36ba5 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/session-tests.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Exercise the production turn-state transition with bounded callback doubles. +// No model, real KV cache, inference or cross-owner reuse is claimed. +#include "session-state.h" +#include +#include +#include + +namespace { +struct backend { + unsigned checks = 0, synchronized = 0, truncated = 0, resets = 0; + unsigned cancel_at = 99, throw_at = 99; + uint32_t observed_prefix = 99; + bool truncate_ok = true; + + int32_t begin(vp::session_state & value, uint32_t prefix, uint32_t capacity = 13312) { + return vp::begin_session_turn(value, prefix, capacity, + [&] { assert(!value.ready); return checks++ == cancel_at; }, + [&] { + ++synchronized; + if (throw_at == 0) { throw std::runtime_error("synthetic sync"); } + }, + [&](uint32_t retained) { + ++truncated; + observed_prefix = retained; + if (throw_at == 1) { throw std::runtime_error("synthetic truncate"); } + return truncate_ok; + }, + [&] { + ++resets; + if (throw_at == 2) { throw std::runtime_error("synthetic reset"); } + }); + } +}; + +vp::session_state previous() { + vp::session_state value; + value.used = 16; + value.generated = 4; + value.ready = true; + value.session_active = true; + return value; +} + +void fresh_and_retained_turns() { + vp::session_state first; + backend initial; + assert(initial.begin(first, 0) == VP_OK); + assert(first.session_active && !first.failed && !first.ready); + assert(first.used == 0 && first.generated == 0 && initial.observed_prefix == 0); + assert(initial.checks == 4 && initial.synchronized == 1 && initial.truncated == 1 && initial.resets == 1); + for (uint32_t prefix : {0u, 1u, 15u, 16u}) { + auto value = previous(); + backend next; + assert(next.begin(value, prefix) == VP_OK); + assert(value.used == prefix && value.generated == 0 && !value.ready && !value.failed); + assert(next.observed_prefix == prefix && next.resets == 1); + } +} + +void invalid_retention_poisoned_without_backend_work() { + for (unsigned variant = 0; variant < 5; ++variant) { + auto value = previous(); + uint32_t prefix = 17, capacity = 13312; + if (variant == 1) { prefix = 16; capacity = 16; } + if (variant == 2) { prefix = 12288; value.used = 12288; } + if (variant == 3) { prefix = 0; capacity = 15; } + if (variant == 4) { prefix = 0; value.failed = true; } + backend next; + assert(next.begin(value, prefix, capacity) == VP_INVALID); + assert(value.failed && !value.ready); + assert(next.checks == 0 && next.synchronized == 0 && next.truncated == 0 && next.resets == 0); + assert(next.begin(value, 0) == VP_INVALID); // No cold restart after refusal. + } +} + +void truncation_failure_never_resets_or_reuses() { + auto value = previous(); + backend next; + next.truncate_ok = false; + assert(next.begin(value, 8) == VP_BACKEND); + assert(value.failed && !value.ready && value.used == 16 && value.generated == 4); + assert(next.truncated == 1 && next.resets == 0); + next.truncate_ok = true; + assert(next.begin(value, 0) == VP_INVALID); + assert(next.truncated == 1 && next.resets == 0); +} + +void cancellation_at_every_backend_boundary() { + for (unsigned cancel = 0; cancel < 4; ++cancel) { + auto value = previous(); + backend next; + next.cancel_at = cancel; + assert(next.begin(value, 8) == VP_ABORTED); + assert(value.failed && !value.ready && value.used == 16 && value.generated == 4); + assert(next.synchronized == (cancel >= 1 ? 1u : 0u)); + assert(next.truncated == (cancel >= 2 ? 1u : 0u)); + assert(next.resets == (cancel >= 3 ? 1u : 0u)); + } +} + +void exceptions_never_cross_the_turn_boundary() { + for (unsigned stage = 0; stage < 3; ++stage) { + auto value = previous(); + backend next; + next.throw_at = stage; + assert(next.begin(value, 8) == VP_BACKEND); + assert(value.failed && !value.ready); + } + auto value = previous(); + assert(vp::begin_session_turn(value, 8, 13312, + []() -> bool { throw std::runtime_error("synthetic callback"); }, + [] { assert(false); }, [](uint32_t) { assert(false); return true; }, + [] { assert(false); }) == VP_BACKEND); + assert(value.failed && !value.ready); +} + +void prompt_generation_and_legacy_boundaries() { + vp::session_state value; + value.used = 12288; + assert(vp::session_sample_allowed(value)); + value.used = 12289; + assert(!vp::session_sample_allowed(value)); + value.generated = 1; + assert(vp::session_sample_allowed(value)); + value.generated = 1023; + assert(vp::session_sample_allowed(value)); + value.generated = 1024; + assert(!vp::session_sample_allowed(value)); + value.ready = true; + assert(vp::invalid_session(&value) == VP_INVALID); + assert(!value.failed && value.ready); // Unrequested legacy behavior unchanged. + value.session_active = true; + assert(vp::invalid_session(&value) == VP_INVALID); + assert(value.failed && !value.ready); + assert(vp::invalid_session(nullptr) == VP_INVALID); +} +} + +int main() { + fresh_and_retained_turns(); + invalid_retention_poisoned_without_backend_work(); + truncation_failure_never_resets_or_reuses(); + cancellation_at_every_backend_boundary(); + exceptions_never_cross_the_turn_boundary(); + prompt_generation_and_legacy_boundaries(); +} diff --git a/workers/volparossa-ml/native_session.py b/workers/volparossa-ml/native_session.py new file mode 100644 index 000000000..ca05b1476 --- /dev/null +++ b/workers/volparossa-ml/native_session.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Connection-owned RAM lifetime, selected only by the fixed core supervisor. + +No listener, session token, file-cache export or cross-owner lookup exists here. +Each turn still performs the full original source/GGUF/input validation. The +parent independently owns admission, pressure, deadlines and joined cleanup. +""" +import contextlib +import math +import os +import resource +import select +import sys +import time + +MAX_TASK_SECONDS = 2400 +MAX_IDLE_SECONDS = 300 +MAX_REQUESTS = 128 + + +class Runtime: + def __init__(self, worker): + self.worker = worker + native = sys.modules.get("volparossa_llama_cpu") + worker.require(native is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") + self.model = native.RetainedModel() + self.loaded, self.loaded_tokenizer, self.threads = None, None, None + + def backend(self, threads, session): + self.worker.require(self.threads is None or self.threads == threads, "NATIVE_SESSION_MODEL_CHANGED") + if self.loaded is None: + self.loaded = self.worker.load_backend(threads, session) + self.threads = threads + session.check() + return self.loaded + + def tokenizer(self, transformers, root): + if self.loaded_tokenizer is None: + self.loaded_tokenizer = transformers.AutoTokenizer.from_pretrained( + str(root), local_files_only=True, trust_remote_code=False, use_fast=True) + return self.loaded_tokenizer + + def close(self): + try: + self.model.close() + finally: + self.loaded_tokenizer, self.loaded = None, None + + +def turn_cpu_limit(request, require): + """New lifetime only: retain the old per-turn allowance and fixed task hard cap. + + SIGXCPU keeps its default terminating disposition. Parent /proc observation + checks the same turn delta; neither path guesses unknown CPU consumption. + """ + usage = resource.getrusage(resource.RUSAGE_SELF) + consumed = usage.ru_utime + usage.ru_stime + require(math.isfinite(consumed) and consumed >= 0, "NATIVE_SESSION_CPU_OBSERVATION") + hard = MAX_TASK_SECONDS * request["threads"] + _soft, actual_hard = resource.getrlimit(resource.RLIMIT_CPU) + require(actual_hard == hard and consumed < hard, "NATIVE_SESSION_CPU_BOUND") + allowance = request["max_seconds"] * request["threads"] + resource.setrlimit(resource.RLIMIT_CPU, (min(hard, math.ceil(consumed) + allowance), hard)) + + +def read_frame(frames, deadline, worker, *, eof=False): + while True: + worker.require(not worker.is_stopping(), "JOB_CANCELLED") + worker.require(time.monotonic() < deadline, "NATIVE_SESSION_DEADLINE") + raw = frames.take(worker.MAX_REQUEST, "INVALID_REQUEST_FRAME") + if raw is not None: + return worker.parse_json(raw) + if not select.select([frames.descriptor], [], [], min(0.05, max(0, deadline - time.monotonic())))[0]: + continue + try: + block = os.read(frames.descriptor, min(4096, worker.MAX_REQUEST - len(frames.pending))) + except BlockingIOError: + continue + if not block: + worker.require(eof and not frames.pending, "OWNER_CONTROL_CLOSED") + return None + frames.pending.extend(block) + + +def settle_turn(session, deadline, worker): + # Controls written just before result observation belong to the old request, + # never to the next turn. No further model work happens in this handshake. + while True: + value = read_frame(session.frames, deadline, worker) + if value == {"version": 1, "id": session.request["id"], "kind": "native_turn_finish"}: + worker.require(type(value["version"]) is int, "INVALID_NATIVE_SESSION_FINISH") + break + raw = worker.json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii") + b"\n" + worker.require(len(raw) <= worker.MAX_CONTROL_LINE, "INVALID_CONTROL_FRAME") + session.accept_control(raw) + worker.emit({"version": 1, "id": session.request["id"], "kind": "native_turn_idle"}) + + +def run(first, frames, worker): + runtime, session, request = Runtime(worker), None, first + started, turn, seen = time.monotonic(), 0, set() + binding = tuple(first[key] for key in ("model_profile", "inference_backend", "native_backend_sha256", "threads", "max_seconds")) + try: + while request is not None: + turn += 1 + worker.require(turn <= MAX_REQUESTS and request["native_session"]["turn"] == turn + and request["id"] not in seen, "INVALID_NATIVE_SESSION_SEQUENCE") + seen.add(request["id"]) + worker.require(binding == tuple(request[key] for key in + ("model_profile", "inference_backend", "native_backend_sha256", "threads", "max_seconds")), + "NATIVE_SESSION_MODEL_CHANGED") + worker.require(time.monotonic() < started + MAX_TASK_SECONDS, "NATIVE_SESSION_DEADLINE") + turn_cpu_limit(request, worker.require) + session = worker.Session(request, frames) + with open(os.devnull, "w", encoding="ascii") as quiet: + with contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet): + result = worker.execute_job(request, session, native_session=runtime) + worker.emit(result) + settle_turn(session, min(started + MAX_TASK_SECONDS, session.started + request["max_seconds"]), worker) + # Drop the complete request/result; retained private tokens/KV have a + # separate explicit lifetime. This does not claim secure RAM erasure. + session, result = None, None + # The parent owns the exact 300-second idle boundary and closes this + # pipe. A second independently started idle clock must not race its + # joined close into an erroneous failed turn. The task backstop is + # still finite here and the parent's earlier deadline always wins. + value = read_frame(frames, started + MAX_TASK_SECONDS, worker, eof=True) + request = None if value is None else worker.validate_request(value) + worker.require(request is None or "native_session" in request, "INVALID_NATIVE_SESSION") + return 0 + except worker.JobError as error: + code = str(error) + except (FileNotFoundError, NotADirectoryError): + code = "JOB_INPUT_NOT_FOUND" + except PermissionError: + code = "JOB_PATH_PERMISSION_DENIED" + except MemoryError: + code = "JOB_MEMORY_EXHAUSTED" + except ImportError: + code = "BACKEND_IMPORT_FAILED" + except Exception: + code = "BACKEND_EXECUTION_FAILED" + finally: + runtime.close() + failure = {"version": 1, "id": request["id"] if request is not None else first["id"], + "kind": "result", "status": "error", "code": code, + "elapsed_ms": session.elapsed() if session is not None else 0} + if session is not None: + failure["owner_control"] = session.owner_stats() + worker.emit(failure) + return 1 diff --git a/workers/volparossa-ml/test_native_session.py b/workers/volparossa-ml/test_native_session.py new file mode 100644 index 000000000..641ff110e --- /dev/null +++ b/workers/volparossa-ml/test_native_session.py @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert session protocol and exact-prefix tests; no model or native library.""" +import copy +import json +import os +import queue +import threading +import time +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +import native_session as warm +from test_llama_cpu import job, manifest +from test_native_output_constraint import library +from test_worker_protocol import WORKER + + +def selected(turn=1): + return WORKER.validate_request(dict(job(), model_root="/model", dataset_path=f"/session/turn-{turn}/input.json", + output_root=f"/session/turn-{turn}/output", native_session={"version": 1, "turn": turn}, + id=f"{turn:032x}")) + + +def frame(value): + return json.dumps(value, separators=(",", ":")).encode() + b"\n" + + +class ContractTests(unittest.TestCase): + def test_selection_is_closed_native_only_and_fixed_paths(self): + self.assertEqual(selected()["native_session"], {"version": 1, "turn": 1}) + for value in (None, True, {}, {"version": True, "turn": 1}, {"version": 1, "turn": True}, + {"version": 1, "turn": 0}, {"version": 1, "turn": 129}, + {"version": 1, "turn": 1, "token": "private"}): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), native_session=value)) + for key, value in (("model_root", "/elsewhere"), ("dataset_path", "/session/turn-2/input.json"), + ("output_root", "/session/turn-1/../output"), ("owner_control", False), + ("inference_backend", "other")): + with self.subTest(key=key), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), **{key: value})) + + def test_legacy_absence_and_manifests_keep_their_own_shapes(self): + self.assertNotIn("native_session", WORKER.validate_request(job())) + for version in (1, 2, 3): + value = manifest() + value["version"] = version + if version >= 2: + value["output_constraints"] = [native.OUTPUT_CONSTRAINT] + if version == 3: + value["native_sessions"] = [native.NATIVE_SESSION] + self.assertEqual(native.validate_manifest(value, WORKER.require), value) + bad = copy.deepcopy(value) + if version == 3: + del bad["native_sessions"] + else: + bad["native_sessions"] = [native.NATIVE_SESSION] + with self.assertRaises(WORKER.JobError): + native.validate_manifest(bad, WORKER.require) + for value in (None, [], ["other"], [native.NATIVE_SESSION, native.NATIVE_SESSION]): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + native.validate_manifest(dict(manifest(), version=3, output_constraints=[native.OUTPUT_CONSTRAINT], + native_sessions=value), WORKER.require) + + def test_cpu_soft_delta_never_raises_fixed_hard_task_cap(self): + with mock.patch.object(warm.resource, "getrusage", return_value=SimpleNamespace(ru_utime=17.2, ru_stime=2.1)), \ + mock.patch.object(warm.resource, "getrlimit", return_value=(1200, 4800)), \ + mock.patch.object(warm.resource, "setrlimit") as set_limit: + warm.turn_cpu_limit(selected(), WORKER.require) + set_limit.assert_called_once_with(warm.resource.RLIMIT_CPU, (1220, 4800)) + for consumed, hard in ((float("nan"), 4800), (-1, 4800), (4800, 4800), (0, 9999)): + with self.subTest(consumed=consumed, hard=hard), \ + mock.patch.object(warm.resource, "getrusage", return_value=SimpleNamespace(ru_utime=consumed, ru_stime=0)), \ + mock.patch.object(warm.resource, "getrlimit", return_value=(1200, hard)), \ + mock.patch.object(warm.resource, "setrlimit") as set_limit, self.assertRaises(WORKER.JobError): + warm.turn_cpu_limit(selected(), WORKER.require) + set_limit.assert_not_called() + + def test_backend_configuration_and_tokenizer_are_once_per_owned_runtime(self): + backend = (mock.Mock(), mock.Mock(), mock.Mock(), {}) + tokenizer = object() + backend[1].AutoTokenizer.from_pretrained.return_value = tokenizer + with mock.patch.dict(warm.sys.modules, {"volparossa_llama_cpu": native}), \ + mock.patch.object(WORKER, "load_backend", return_value=backend) as load: + runtime = warm.Runtime(WORKER) + for _ in range(2): + self.assertIs(runtime.backend(2, mock.Mock()), backend) + self.assertIs(runtime.tokenizer(backend[1], Path("/model")), tokenizer) + load.assert_called_once() + backend[1].AutoTokenizer.from_pretrained.assert_called_once() + with self.assertRaises(WORKER.JobError): + runtime.backend(1, mock.Mock()) + runtime.close() + self.assertIsNone(runtime.loaded_tokenizer) + + +class PrefixTests(unittest.TestCase): + def setUp(self): + self.manifest = dict(manifest(), version=3, output_constraints=[native.OUTPUT_CONSTRAINT], + native_sessions=[native.NATIVE_SESSION]) + self.constructor = mock.patch.object(native, "NativeModel") + self.model_type = self.constructor.start() + self.addCleanup(self.constructor.stop) + + def begin(self, state, prompt, **changes): + request = dict(selected(), **changes) + return state.begin(Path("/native-backend"), self.manifest, {"library": "fixed"}, request, + prompt, mock.Mock(), WORKER) + + def test_only_exact_decoded_prefix_survives_and_last_prompt_token_is_recomputed(self): + state = native.RetainedModel() + self.assertEqual(self.begin(state, [1, 2, 3])[1], 0) + state.tokens = [1, 2, 3, 4] + self.assertEqual(self.begin(state, [1, 2, 3])[1], 2) + self.assertEqual(state.tokens, []) + state.tokens = [1, 2, 3] + self.assertEqual(self.begin(state, [1, 9, 3])[1], 1) + state.tokens = [1, 2, 3] + self.assertEqual(self.begin(state, [9, 2, 3])[1], 0) + self.assertEqual(self.model_type.call_count, 1) + self.assertEqual(self.model_type.call_args.args[1], 12288 + 1024) + + def test_no_cross_instance_identity_or_failed_handle_replacement(self): + first, second = native.RetainedModel(), native.RetainedModel() + self.begin(first, [1, 2]) + first.tokens = [1, 2] + self.assertEqual(self.begin(second, [1, 2])[1], 0) + self.assertEqual(self.model_type.call_count, 2) + with self.assertRaises(WORKER.JobError): + self.begin(first, [1, 2], threads=1) + first.model.begin_turn.side_effect = WORKER.JobError("NATIVE_SESSION_PREFIX_RESET_FAILED") + with self.assertRaises(WORKER.JobError): + self.begin(first, [1, 2]) + self.assertEqual(self.model_type.call_count, 2) + first.close() + self.assertEqual(first.tokens, []) + self.assertIsNone(first.binding) + + def test_generate_reuses_no_uncomputed_sample_and_keeps_full_prompt_bound(self): + model = mock.Mock() + model.sample.side_effect = [4, 151645] + tokenizer = SimpleNamespace(eos_token_id=151645, decode=lambda *a, **k: "ok") + tokens = [] + result = native.generate(model, [1, 2, 3], tokenizer, WORKER.model_profile(native.PROFILE), + mock.Mock(), WORKER, retained_tokens=2, decoded_tokens=tokens) + self.assertEqual(tokens, [1, 2, 3, 4]) + self.assertEqual(model.decode.call_args_list, [mock.call([3]), mock.call([4])]) + self.assertEqual(result["generated_tokens"], 2) + for retain in (-1, 3, True): + with self.subTest(retain=retain), self.assertRaises(WORKER.JobError): + native.generate(model, [1, 2, 3], tokenizer, WORKER.model_profile(native.PROFILE), + mock.Mock(), WORKER, retained_tokens=retain) + + +class AbiTests(unittest.TestCase): + def open(self, value, **kwargs): + with mock.patch.object(native.ctypes, "CDLL", return_value=value): + return native.NativeModel(Path("/inert"), 12288 + 1024, 2, mock.Mock(), WORKER.require, **kwargs) + + def test_new_manifest_never_loads_old_or_mismatched_session_abi(self): + for version in (None, 0, 2, True): + value = library() + if version is not None: + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=version) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + with self.subTest(version=version), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, native_session=True) + value.vp_llama_open_v1.assert_not_called() + + def test_selected_turn_resets_exact_prefix_and_does_not_call_legacy_setter(self): + value = library() + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + model = self.open(value, native_session=True, output_constraints=[native.OUTPUT_CONSTRAINT]) + probe = mock.Mock() + model.begin_turn(12, probe, native.OUTPUT_CONSTRAINT, ["vp_0", "vp_1"]) + self.assertEqual(value.vp_llama_begin_turn_v1.call_args.args[1:4], (12, 1, 2)) + self.assertIs(value.vp_llama_begin_turn_v1.call_args.args[4], probe.callback) + self.assertIs(model.probe, probe) + value.vp_llama_set_output_constraint_v1.assert_not_called() + model.close() + value.vp_llama_close_v1.assert_called_once() + + +class PipeTests(unittest.TestCase): + def test_two_real_pipe_turns_drain_old_controls_before_next_request(self): + read, write = os.pipe() + output, errors = queue.Queue(), [] + runtime = mock.Mock() + def execute(request, session, *, native_session): + self.assertIs(native_session, runtime) + session.check() + return {"version": 1, "id": request["id"], "kind": "result", "status": "ok"} + def consume(): + try: + errors.append(warm.run(selected(), WORKER.InputFrames(read), WORKER)) + except BaseException as error: + errors.append(error) + with mock.patch.object(warm, "Runtime", return_value=runtime), \ + mock.patch.object(warm, "turn_cpu_limit"), \ + mock.patch.object(WORKER, "execute_job", side_effect=execute), \ + mock.patch.object(WORKER, "emit", side_effect=output.put): + thread = threading.Thread(target=consume, daemon=True) + thread.start() + try: + for turn in (1, 2): + request = selected(turn) + if turn == 2: + os.write(write, frame(request)) + os.write(write, frame({"version": 1, "id": request["id"], "sequence": 1, "action": "resume"})) + self.assertEqual(output.get(timeout=2)["phase"], "resumed") + self.assertEqual(output.get(timeout=2)["kind"], "result") + # A real old control in flight at result time must not become + # an invalid next request or authorize more model operations. + os.write(write, frame({"version": 1, "id": request["id"], "sequence": 2, "action": "pause"}) + + frame({"version": 1, "id": request["id"], "kind": "native_turn_finish"})) + self.assertEqual(output.get(timeout=2)["phase"], "paused") + self.assertEqual(output.get(timeout=2), {"version": 1, "id": request["id"], "kind": "native_turn_idle"}) + os.close(write) + write = None + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, [0]) + runtime.close.assert_called_once() + finally: + if write is not None: + os.close(write) + thread.join(2) + os.close(read) + + def test_deadline_cancel_and_partial_eof_never_become_idle_success(self): + read, write = os.pipe() + try: + frames = WORKER.InputFrames(read) + with self.assertRaisesRegex(WORKER.JobError, "DEADLINE"): + warm.read_frame(frames, time.monotonic() - 1, WORKER, eof=True) + with mock.patch.object(WORKER, "is_stopping", return_value=True), \ + self.assertRaisesRegex(WORKER.JobError, "CANCELLED"): + warm.read_frame(frames, time.monotonic() + 1, WORKER, eof=True) + os.write(write, b'{"version":') + os.close(write) + write = None + with self.assertRaisesRegex(WORKER.JobError, "OWNER_CONTROL_CLOSED"): + warm.read_frame(frames, time.monotonic() + 1, WORKER, eof=True) + finally: + if write is not None: + os.close(write) + os.close(read) + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index 84e043579..71a35d2b5 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -260,7 +260,7 @@ def validate_request(value): required = {"version", "id", "mode", "model_root", "dataset_path", "output_root"} optional = {"steps", "threads", "max_seconds", "adapter_root", "owner_control", "model_profile", "private_generation_diagnostics", "inference_backend", "native_backend_root", "native_backend_sha256", - "output_constraint", "output_constraint_tools"} + "output_constraint", "output_constraint_tools", "native_session"} require(type(value) is dict and required <= value.keys() and value.keys() <= required | optional, "INVALID_REQUEST_FIELDS") require(type(value["version"]) is int and value["version"] == VERSION, "UNSUPPORTED_VERSION") @@ -274,6 +274,17 @@ def validate_request(value): and profile_name == QWEN4B_MODEL_PROFILE and value["mode"] == "private_conversation" and type(value["native_backend_sha256"]) is str and re.fullmatch(r"[0-9a-f]{64}", value["native_backend_sha256"]), "INVALID_NATIVE_BACKEND_SCOPE") + if "native_session" in value: + selected = value["native_session"] + require(type(selected) is dict and selected.keys() == {"version", "turn"} + and type(selected["version"]) is int and selected["version"] == 1 + and bounded_integer(selected["turn"], 1, 128) + and native_fields <= value.keys() and value.get("owner_control") is True, + "INVALID_NATIVE_SESSION") + turn = selected["turn"] + require(value["model_root"] == "/model" and value["native_backend_root"] == "/native-backend" + and value["dataset_path"] == f"/session/turn-{turn}/input.json" + and value["output_root"] == f"/session/turn-{turn}/output", "INVALID_NATIVE_SESSION_PATH") constraint_fields = {"output_constraint", "output_constraint_tools"} if constraint_fields & value.keys(): aliases = value.get("output_constraint_tools") @@ -2455,7 +2466,8 @@ def save_checkpoint(model, output_root, session): return adapter_root, artifacts -def execute_job(request, session): +def execute_job(request, session, *, native_session=None): + require((native_session is not None) == ("native_session" in request), "INVALID_NATIVE_SESSION") profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) session.private_progress("owner_gate", "begin") @@ -2487,15 +2499,17 @@ def execute_job(request, session): configure_offline() session.check() session.private_progress("backend_import", "begin") - torch, transformers, peft, versions = load_backend(request["threads"], session) + torch, transformers, peft, versions = (load_backend(request["threads"], session) + if native_session is None else native_session.backend(request["threads"], session)) session.private_progress("backend_import", "complete") session.check() if cohort is not None: return execute_aggregation(request, session, torch, versions, output_root, data_identity, model_files, cohort) session.private_progress("tokenizer_load", "begin") - tokenizer = transformers.AutoTokenizer.from_pretrained( + tokenizer = (transformers.AutoTokenizer.from_pretrained( str(model_root), local_files_only=True, trust_remote_code=False, use_fast=True) + if native_session is None else native_session.tokenizer(transformers, model_root)) expected_pad, expected_eos = (151643, 151645) if profile_name in NATIVE_CONVERSATION_PROFILES else (2, 2) require(tokenizer.pad_token_id == expected_pad and tokenizer.eos_token_id == expected_eos, "MODEL_TOKENIZER_MISMATCH") @@ -2504,9 +2518,10 @@ def execute_job(request, session): if request.get("inference_backend") == "llama_cpp_bf16_v1": native_backend = sys.modules.get("volparossa_llama_cpu") require(native_backend is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") + warm = {} if native_session is None else {"retained": native_session.model} return native_backend.execute(request, session, tokenizer, torch, versions, model_root, output_root, dataset, data_identity, model_files, SimpleNamespace(**globals()), - initial_source_weights=initial_source.get("weights")) + initial_source_weights=initial_source.get("weights"), **warm) if request["mode"] == "plan_document": plan = plan_document(tokenizer, dataset, session, profile_name) raw = json.dumps(plan, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")).encode("ascii") @@ -2679,6 +2694,10 @@ def stop_requested(_signum, _frame): STOP_REQUESTED = True +def is_stopping(): + return STOP_REQUESTED + + def main(): os.umask(0o077) signal.signal(signal.SIGTERM, stop_requested) @@ -2694,6 +2713,10 @@ def main(): if type(value) is dict and type(value.get("id")) is str and HEX32.fullmatch(value["id"]): request_id = value["id"] request = validate_request(value) + if "native_session" in request: + runtime = sys.modules.get("volparossa_native_session") + require(runtime is not None, "NATIVE_SESSION_MODULE_UNAVAILABLE") + return runtime.run(request, frames, SimpleNamespace(**globals())) session = Session(request, frames if controlled else None) # Third-party diagnostics may include local paths. Only our fixed, bounded protocol # uses the original stdout; no raw prompt, dataset or stack trace is logged. From d6d7c4d3be495dd670868639a417c496c5f1675c Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:32:08 +0200 Subject: [PATCH 30/39] style(compute): apply hosted native-session formatter feedback --- crates/volparossa/src/compute/private_serve.rs | 11 ++++++++--- .../compute/private_serve/tests/native_session.rs | 9 +++++++-- .../private_serve/tests/output_constraint.rs | 5 ++++- .../src/compute/supervise/native_session.rs | 9 ++++++--- .../compute/supervise/native_session/worker.rs | 15 +++++++++++---- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index a54bc425d..423139631 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -486,9 +486,14 @@ impl NativeSession { slot: ExecutionSlot, handshakes: Handshakes, ) -> Self { - Self::spawn(open_id, slot, handshakes, move |commands, events, activity| { - super::supervise::native_session::run(config, commands, events, activity) - }) + Self::spawn( + open_id, + slot, + handshakes, + move |commands, events, activity| { + super::supervise::native_session::run(config, commands, events, activity) + }, + ) } fn spawn( diff --git a/crates/volparossa/src/compute/private_serve/tests/native_session.rs b/crates/volparossa/src/compute/private_serve/tests/native_session.rs index e8b0ef45c..c1768657c 100644 --- a/crates/volparossa/src/compute/private_serve/tests/native_session.rs +++ b/crates/volparossa/src/compute/private_serve/tests/native_session.rs @@ -224,7 +224,9 @@ fn session_capability_requires_opt_in_and_exact_owner_manifest_and_legacy_caps_s ); assert_eq!( extended.as_object_mut().unwrap().remove("native_session"), - Some(json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128})) + Some( + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128}) + ) ); assert_eq!(extended, original); config.native_backend.native_backend_sha256 = Some("0".repeat(64)); @@ -408,7 +410,10 @@ async fn session_slot_is_connection_exclusive_and_open_requires_its_own_handshak ); negotiate(&mut second).await; send(&mut second, SECOND, json!({"type":"open_native_session"})).await; - assert_eq!(receive(&mut second).await, wire::error(Some(SECOND), "busy")); + assert_eq!( + receive(&mut second).await, + wire::error(Some(SECOND), "busy") + ); assert_eq!(gate.available_permits(), 0); stop.send(true).unwrap(); owner.await.unwrap(); diff --git a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs index 6400df491..7559b7f0c 100644 --- a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs +++ b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs @@ -5,7 +5,10 @@ use sha2::{Digest as _, Sha256}; const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; -pub(super) fn native_config(root: &std::path::Path, extended: bool) -> private_task::ExecutionConfig { +pub(super) fn native_config( + root: &std::path::Path, + extended: bool, +) -> private_task::ExecutionConfig { let mut config = config(root); config.model_profile = ModelProfile::Qwen4bInstruct2507; let backend = root.join("backend"); diff --git a/crates/volparossa/src/compute/supervise/native_session.rs b/crates/volparossa/src/compute/supervise/native_session.rs index 24635e86a..09aae12a8 100644 --- a/crates/volparossa/src/compute/supervise/native_session.rs +++ b/crates/volparossa/src/compute/supervise/native_session.rs @@ -191,10 +191,13 @@ impl Resident { task_deadline: Instant, activity: &mut watch::Receiver, ) -> Result { - ensure!(self.turn < MAX_REQUESTS, "compute_native_session_request_bound"); + ensure!( + self.turn < MAX_REQUESTS, + "compute_native_session_request_bound" + ); self.turn += 1; - let deadline = - (Instant::now() + Duration::from_secs(u64::from(config.max_seconds))).min(task_deadline); + let deadline = (Instant::now() + Duration::from_secs(u64::from(config.max_seconds))) + .min(task_deadline); let options = stage(config, root, self.turn, input)?; let request = request(&options, id)?; if self.worker.is_none() { diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs index e4b950360..7137c2c0b 100644 --- a/crates/volparossa/src/compute/supervise/native_session/worker.rs +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -33,7 +33,10 @@ impl Frames { // silently lose a prefix or splice two differently correlated records. loop { let available = self.stream.fill_buf().await?; - ensure!(!available.is_empty(), "compute_native_session_stdout_closed"); + ensure!( + !available.is_empty(), + "compute_native_session_stdout_closed" + ); let newline = available.iter().position(|byte| *byte == b'\n'); let count = newline.unwrap_or(available.len()); ensure!( @@ -177,8 +180,9 @@ impl Live { self.tracker()?.refresh()?; let before = self.tracker()?.cpu_ticks()?; let controls = Controls::default(); - let progress = - supervisor::diagnostic::Progress::with_generation(request.private_generation_diagnostics); + let progress = supervisor::diagnostic::Progress::with_generation( + request.private_generation_diagnostics, + ); let mut value = serde_json::to_value(request)?; value["dataset_path"] = json!(format!("/session/turn-{turn}/input.json")); value["output_root"] = json!(format!("/session/turn-{turn}/output")); @@ -346,7 +350,10 @@ impl Live { Ok(tracker) => tracker.wait_closed(deadline).await.is_ok(), Err(_) => false, }; - let mut stderr = self.stderr.take().context("compute_native_session_stderr")?; + let mut stderr = self + .stderr + .take() + .context("compute_native_session_stderr")?; let drained = match tokio::time::timeout_at(deadline, &mut stderr).await { Ok(Ok(Ok(_))) => true, _ => { From 00a4d73bff03f0b605d2391dd575989d805557e3 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:51:14 +0200 Subject: [PATCH 31/39] fix(compute): resolve native session supervisor import and qualifications --- crates/volparossa/src/compute/private_serve.rs | 2 +- .../src/compute/private_serve/tests/native_session.rs | 2 +- .../src/compute/supervise/native_session/worker.rs | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index 423139631..82bb199c4 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -508,7 +508,7 @@ impl NativeSession { mpsc::Sender, watch::Receiver, ) -> Fut, - Fut: std::future::Future> + Send + 'static, + Fut: Future> + Send + 'static, { let (commands, receive) = mpsc::channel(1); let (send, events) = mpsc::channel(1); diff --git a/crates/volparossa/src/compute/private_serve/tests/native_session.rs b/crates/volparossa/src/compute/private_serve/tests/native_session.rs index c1768657c..b0d135404 100644 --- a/crates/volparossa/src/compute/private_serve/tests/native_session.rs +++ b/crates/volparossa/src/compute/private_serve/tests/native_session.rs @@ -8,7 +8,7 @@ use sha2::{Digest as _, Sha256}; use super::super::super::supervise::native_session::{Command, Event}; fn native_config(root: &std::path::Path) -> private_task::ExecutionConfig { - let mut config = super::output_constraint::native_config(root, true); + let mut config = output_constraint::native_config(root, true); let manifest = config .native_backend .native_backend_root diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs index 7137c2c0b..f5af4d9de 100644 --- a/crates/volparossa/src/compute/supervise/native_session/worker.rs +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -12,13 +12,13 @@ use tokio::{ time::Instant, }; -use super::super::{self as supervisor, lifetime::OwnedLifetimes}; use crate::compute::{ MAX_LINE_BYTES, MAX_STREAM_BYTES, ModelProfile, Options, WorkerRequest, check_message, owner_control::{Action, Controls}, private_conversation::decode_worker_message, resources, sandbox, spare_capacity::{Budget, Decision}, + supervise::{self as supervisor, lifetime::OwnedLifetimes}, }; struct Frames { @@ -73,10 +73,7 @@ pub(super) struct Live { fn clock_ticks() -> Result { // Linux's own bounded auxiliary vector, not a subprocess or guessed USER_HZ. - ensure!( - std::mem::size_of::() == 8, - "compute_native_session_cpu_clock" - ); + ensure!(size_of::() == 8, "compute_native_session_cpu_clock"); let mut bytes = Vec::new(); std::fs::File::open("/proc/self/auxv")? .take(8193) From 0746271a375a3d20de8da62b492d6d45c92b3875 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:07:30 +0200 Subject: [PATCH 32/39] fix(compute): satisfy native session ownership lints --- .../volparossa/src/compute/private_serve.rs | 32 +++++++++++-------- .../supervise/native_session/worker.rs | 13 ++++---- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index 82bb199c4..e2df8c8b2 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -249,6 +249,20 @@ fn capabilities(config: &private_task::ExecutionConfig, gate: &Semaphore) -> Val }) } +fn capabilities_response( + id: &str, + config: &private_task::ExecutionConfig, + gate: &Semaphore, +) -> Value { + let mut response = wire::response(id, "capabilities"); + response["capabilities"] = capabilities(config, gate); + if config.native_backend.enabled() { + response["capabilities"]["qa_supported"] = false.into(); + response["capabilities"]["inference_backend"] = super::native_cpu::KIND.into(); + } + response +} + enum Incoming { Request(wire::Request), Invalid, @@ -327,13 +341,7 @@ fn respond( match request.operation { wire::Operation::Capabilities {} => { handshakes.qa = true; - let mut response = wire::response(&request.id, "capabilities"); - response["capabilities"] = capabilities(config, gate); - if config.native_backend.enabled() { - response["capabilities"]["qa_supported"] = false.into(); - response["capabilities"]["inference_backend"] = super::native_cpu::KIND.into(); - } - response + capabilities_response(&request.id, config, gate) } wire::Operation::ConversationCapabilities { generation_policy_version, @@ -685,12 +693,10 @@ impl NativeSession { let mut reply = wire::error(Some(id), code); reply["session_cleanup"] = cleanup.clone().unwrap_or(Value::Null); let mut replies = vec![reply]; - if self.pending.is_some() - && self.close_id.is_some() - && cleanup.is_some() - && !gate.is_closed() - { - replies.push(self.closed(cleanup.expect("checked cleanup"))); + if let Some(cleanup) = cleanup.filter(|_| { + self.pending.is_some() && self.close_id.is_some() && !gate.is_closed() + }) { + replies.push(self.closed(cleanup)); } return replies; } diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs index f5af4d9de..c9795d05e 100644 --- a/crates/volparossa/src/compute/supervise/native_session/worker.rs +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -351,13 +351,12 @@ impl Live { .stderr .take() .context("compute_native_session_stderr")?; - let drained = match tokio::time::timeout_at(deadline, &mut stderr).await { - Ok(Ok(Ok(_))) => true, - _ => { - stderr.abort(); - let _ = stderr.await; - false - } + let drained = if let Ok(Ok(Ok(_))) = tokio::time::timeout_at(deadline, &mut stderr).await { + true + } else { + stderr.abort(); + let _ = stderr.await; + false }; ensure!( closed && drained, From 0de65f423a6d96fa565facf6f283efae70adf2bd Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:12:01 +0200 Subject: [PATCH 33/39] style(compute): apply hosted native cleanup formatting --- crates/volparossa/src/compute/private_serve.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index e2df8c8b2..4c8e3d85a 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -693,9 +693,9 @@ impl NativeSession { let mut reply = wire::error(Some(id), code); reply["session_cleanup"] = cleanup.clone().unwrap_or(Value::Null); let mut replies = vec![reply]; - if let Some(cleanup) = cleanup.filter(|_| { - self.pending.is_some() && self.close_id.is_some() && !gate.is_closed() - }) { + if let Some(cleanup) = cleanup + .filter(|_| self.pending.is_some() && self.close_id.is_some() && !gate.is_closed()) + { replies.push(self.closed(cleanup)); } return replies; From 0317732161f2053cb476b7c13e8a2b026d9d21d0 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:34:02 +0200 Subject: [PATCH 34/39] fix(compute): preserve closed native-session failure diagnostics --- .../workflows/native-session-diagnostics.yml | 292 ++++++++++++++++++ .../src/compute/supervise/diagnostic.rs | 170 +++++++++- .../src/compute/supervise/native_session.rs | 19 +- .../compute/supervise/native_session/tests.rs | 108 ++++++- .../supervise/native_session/worker.rs | 5 + .../integration/agent-private-conversation.py | 14 + .../test-agent-private-conversation.py | 38 ++- 7 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/native-session-diagnostics.yml diff --git a/.github/workflows/native-session-diagnostics.yml b/.github/workflows/native-session-diagnostics.yml new file mode 100644 index 000000000..59cfbf849 --- /dev/null +++ b/.github/workflows/native-session-diagnostics.yml @@ -0,0 +1,292 @@ +name: Native session closed diagnostics +run-name: Exact-source native session diagnostics without a model + +on: + workflow_dispatch: + push: + branches: [feature/native-session-closed-errors] + paths: + - .github/workflows/native-session-diagnostics.yml + - crates/volparossa/src/compute/supervise/diagnostic.rs + - crates/volparossa/src/compute/supervise/native_session.rs + - crates/volparossa/src/compute/supervise/native_session/tests.rs + - crates/volparossa/src/compute/supervise/native_session/worker.rs + - tests/integration/agent-private-conversation.py + - tests/integration/test-agent-private-conversation.py + +permissions: + contents: read + +concurrency: + group: native-session-diagnostics-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + BASELINE_COMMIT: 0de65f423a6d96fa565facf6f283efae70adf2bd + CARGO_BUILD_JOBS: '2' + CARGO_TERM_COLOR: never + PYTHONDONTWRITEBYTECODE: '1' + RUSTUP_TOOLCHAIN: 1.85.0 + +jobs: + diagnostics: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out the exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Bind the source and reject changes outside this diagnostics slice + id: scope + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$RUNNER_OS" = Linux + test "$RUNNER_ARCH" = X64 + test "$(id -u)" != 0 + test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa + test "$GITHUB_REF" = refs/heads/feature/native-session-closed-errors + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + timeout --kill-after=10s 120s git fetch --no-tags --depth=1 origin "$BASELINE_COMMIT" + umask 077 + output="$RUNNER_TEMP/native-session-diagnostics" + target="$RUNNER_TEMP/native-session-diagnostics-target" + test ! -e "$output" + test ! -e "$target" + install -d -m 0700 "$output" "$target" + printf 'DIAGNOSTIC_OUTPUT=%s\nCARGO_TARGET_DIR=%s\n' "$output" "$target" >> "$GITHUB_ENV" + python3 -B - "$output/source.json" <<'PY' + import hashlib, json, os, subprocess, sys + from pathlib import Path + + def git(*args): + return subprocess.check_output(['git', *args]) + + baseline = os.environ['BASELINE_COMMIT'] + head = os.environ['GITHUB_SHA'] + paths = { + '.github/workflows/native-session-diagnostics.yml': 'A', + 'crates/volparossa/src/compute/supervise/diagnostic.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session/tests.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session/worker.rs': 'M', + 'tests/integration/agent-private-conversation.py': 'M', + 'tests/integration/test-agent-private-conversation.py': 'M', + } + changed = git('diff', '--no-renames', '--name-status', baseline, head).decode().splitlines() + assert sorted(changed) == sorted(f'{status}\t{path}' for path, status in paths.items()) + files = {} + for path in sorted(paths): + entry = git('ls-tree', head, '--', path).decode().strip() + assert entry.startswith('100644 blob ') and entry.endswith('\t' + path) + raw = git('show', f'{head}:{path}') + files[path] = {'bytes': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()} + value = dict(version=1, candidate_commit=head, + workflow_commit=os.environ['GITHUB_WORKFLOW_SHA'], + candidate_tree=git('rev-parse', f'{head}^{{tree}}').decode().strip(), + baseline_commit=baseline, + baseline_tree=git('rev-parse', f'{baseline}^{{tree}}').decode().strip(), + run_id=os.environ['GITHUB_RUN_ID'], run_attempt=os.environ['GITHUB_RUN_ATTEMPT'], + files=files, other_tracked_source_unchanged=True, + model_execution=False, native_build=False, vm_execution=False, + original_actual_failure_cause_proven=False) + Path(sys.argv[1]).write_text(json.dumps(value, sort_keys=True) + '\n') + PY + + - name: Install the pinned source-check toolchain + id: toolchain + run: | + set -euo pipefail + rustup toolchain install 1.85.0 --profile minimal --no-self-update \ + --component clippy --component rustfmt + rustc +1.85.0 --version --verbose + cargo +1.85.0 --version --verbose + + - name: Install the reviewed Protobuf compiler on this disposable runner + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$(dpkg --print-architecture)" = amd64 + sudo -n apt-get update + sudo -n apt-get install --yes --no-install-recommends \ + protobuf-compiler=3.21.12-8.2ubuntu0.3 + test "$(protoc --version)" = 'libprotoc 3.21.12' + + - name: Check the closed Python collector contracts + id: python + run: | + set -euo pipefail + set +e + timeout --kill-after=5s 90s python3 -B tests/integration/test-agent-private-conversation.py \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/python.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/python.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + python3 -B - "$DIAGNOSTIC_OUTPUT/python.log" <<'PY' + import re, sys + from pathlib import Path + text = Path(sys.argv[1]).read_text() + assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['32'] + assert re.findall(r'^OK.*$', text, re.M) == ['OK'] + PY + + - name: Fetch locked dependencies before offline checks + id: fetch + run: | + set -euo pipefail + timeout --kill-after=10s 300s cargo +1.85.0 fetch --locked + git diff --exit-code + + - name: Inventory and execute only the three native diagnostic test groups + id: rust + run: | + set -euo pipefail + for group in diagnostic native_session private_serve; do + case "$group" in + diagnostic) filter=compute::supervise::diagnostic:: ;; + native_session) filter=compute::supervise::native_session:: ;; + private_serve) filter=compute::private_serve::tests::native_session:: ;; + esac + set +e + timeout --kill-after=10s 900s cargo +1.85.0 test --locked --offline \ + -p volparossa --bin volparossa "$filter" -- --list --format terse \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/$group.inventory.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/$group.inventory.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + set +e + timeout --kill-after=10s 180s cargo +1.85.0 test --locked --offline \ + -p volparossa --bin volparossa "$filter" -- --color never \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/$group.tests.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/$group.tests.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + python3 -B - "$DIAGNOSTIC_OUTPUT" "$group" "$filter" <<'PY' + import json, re, sys + from pathlib import Path + output, group, prefix = Path(sys.argv[1]), sys.argv[2], sys.argv[3] + inventory = (output / f'{group}.inventory.log').read_text() + result = (output / f'{group}.tests.log').read_text() + names = re.findall(r'^([A-Za-z0-9_:]+): test$', inventory, re.M) + assert names and len(names) == len(set(names)) and all(name.startswith(prefix) for name in names) + assert ': benchmark' not in inventory + passed = re.findall(r'^test ([A-Za-z0-9_:]+) \.\.\. ok$', result, re.M) + assert sorted(passed) == sorted(names) + summaries = re.findall(r'^test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored;', result, re.M) + assert summaries == [(str(len(names)), '0', '0')] + required = { + 'diagnostic': ['session_codes_are_exact_and_do_not_change_legacy_error_projection', + 'received_native_codes_require_exact_correlation_and_never_invent_exit_facts', + 'native_worker_unknown_codes_and_extra_private_fields_never_escape_projection'], + 'native_session': ['closed_failure_preserves_only_projected_diagnostic_without_opening_its_source', + 'received_worker_error_cannot_construct_exit_adapter_or_cleanup_authority', + 'closed_projected_failure_is_accepted_by_both_collector_versions'], + 'private_serve': ['same_connection_two_turns_retain_slot_until_explicit_session_close_and_eof', + 'opaque_engine_failure_without_cleanup_facts_quarantines_before_slot_release'], + }[group] + assert all(any(name.endswith('::' + case) for name in names) for case in required) + (output / f'{group}.json').write_text(json.dumps(dict(version=1, filter=prefix, + tests=sorted(names), passed=len(names), failed=0, ignored=0, + model_execution=False), sort_keys=True) + '\n') + PY + done + + - name: Run strict CLI and test Clippy without executing other tests + id: clippy + run: | + set -euo pipefail + set +e + timeout --kill-after=10s 600s cargo +1.85.0 clippy --locked --offline \ + -p volparossa --bin volparossa --tests -- -D warnings \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/clippy.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/clippy.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + + - name: Check formatting even after a preceding check fails + id: format + if: always() && steps.scope.outcome == 'success' && steps.toolchain.outcome == 'success' + run: | + set -euo pipefail + set +e + timeout --kill-after=5s 90s cargo +1.85.0 fmt --all -- --check \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/format.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/format.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + + - name: Retain exact outcomes without promoting missing phases + if: always() && steps.scope.outcome == 'success' + env: + TOOLCHAIN_OUTCOME: ${{ steps.toolchain.outcome }} + PYTHON_OUTCOME: ${{ steps.python.outcome }} + FORMAT_OUTCOME: ${{ steps.format.outcome }} + FETCH_OUTCOME: ${{ steps.fetch.outcome }} + RUST_OUTCOME: ${{ steps.rust.outcome }} + CLIPPY_OUTCOME: ${{ steps.clippy.outcome }} + run: | + set -euo pipefail + python3 -B - "$DIAGNOSTIC_OUTPUT/outcomes.json" <<'PY' + import json, os, subprocess, sys + from pathlib import Path + names = ['TOOLCHAIN', 'PYTHON', 'FORMAT', 'FETCH', 'RUST', 'CLIPPY'] + phases = {name.lower(): os.environ[name + '_OUTCOME'] for name in names} + unchanged = not subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=all']) + Path(sys.argv[1]).write_text(json.dumps(dict(version=1, phases=phases, + checkout_unchanged=unchanged, model_execution=False, + original_actual_failure_cause_proven=False), sort_keys=True) + '\n') + assert unchanged and all(value == 'success' for value in phases.values()) + PY + + - name: Preserve only source, inventory and original check evidence including failures + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-session-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: 14 + if-no-files-found: error + path: | + ${{ runner.temp }}/native-session-diagnostics/source.json + ${{ runner.temp }}/native-session-diagnostics/outcomes.json + ${{ runner.temp }}/native-session-diagnostics/python.log + ${{ runner.temp }}/native-session-diagnostics/python.status + ${{ runner.temp }}/native-session-diagnostics/format.log + ${{ runner.temp }}/native-session-diagnostics/format.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.inventory.log + ${{ runner.temp }}/native-session-diagnostics/diagnostic.inventory.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.tests.log + ${{ runner.temp }}/native-session-diagnostics/diagnostic.tests.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.json + ${{ runner.temp }}/native-session-diagnostics/native_session.inventory.log + ${{ runner.temp }}/native-session-diagnostics/native_session.inventory.status + ${{ runner.temp }}/native-session-diagnostics/native_session.tests.log + ${{ runner.temp }}/native-session-diagnostics/native_session.tests.status + ${{ runner.temp }}/native-session-diagnostics/native_session.json + ${{ runner.temp }}/native-session-diagnostics/private_serve.inventory.log + ${{ runner.temp }}/native-session-diagnostics/private_serve.inventory.status + ${{ runner.temp }}/native-session-diagnostics/private_serve.tests.log + ${{ runner.temp }}/native-session-diagnostics/private_serve.tests.status + ${{ runner.temp }}/native-session-diagnostics/private_serve.json + ${{ runner.temp }}/native-session-diagnostics/clippy.log + ${{ runner.temp }}/native-session-diagnostics/clippy.status diff --git a/crates/volparossa/src/compute/supervise/diagnostic.rs b/crates/volparossa/src/compute/supervise/diagnostic.rs index 358fb11b0..ba2b1331b 100644 --- a/crates/volparossa/src/compute/supervise/diagnostic.rs +++ b/crates/volparossa/src/compute/supervise/diagnostic.rs @@ -294,7 +294,7 @@ const WORKER_CODES: &[&str] = &[ "INVALID_NATIVE_CONTROL", ]; -#[derive(Debug, Serialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] pub(super) struct Detail { code: &'static str, io_kind: &'static str, @@ -304,6 +304,12 @@ pub(super) struct Detail { } pub(super) fn describe(error: &Error) -> Detail { + if let Some(detail) = super::native_session::diagnostic(error) { + return detail; + } + if let Some(error) = error.downcast_ref::() { + return error.0; + } let io_kind = error .chain() .find_map(|cause| cause.downcast_ref::()) @@ -346,6 +352,95 @@ pub(super) fn describe(error: &Error) -> Detail { detail } +// Selected-session diagnostics only. Legacy FIXED_CODES/WORKER_CODES and their +// historical projections remain unchanged. Exact literals, never a code prefix +// or arbitrary uppercase text, are the complete additional export authority. +const SESSION_CODES: &[&str] = &[ + "compute_native_session_child_exited", + "compute_native_session_cpu_backwards", + "compute_native_session_cpu_bound", + "compute_native_session_cpu_budget", + "compute_native_session_cpu_clock", + "compute_native_session_cpu_identity", + "compute_native_session_cpu_stat", + "compute_native_session_receiver", + "compute_native_session_report", + "compute_native_session_request_bound", + "compute_native_session_root", + "compute_native_session_stderr", + "compute_native_session_stderr_closed", + "compute_native_session_stdout_closed", + "compute_native_session_turn_root", + "compute_native_session_unrequested_idle", + "compute_native_session_unsupported", + "compute_native_session_worker", + "compute_worker_invalid_json", + "compute_output_after_result", + "compute_result_missing", +]; +const SESSION_WORKER_CODES: &[&str] = &[ + "NATIVE_DECODE_FAILED", + "NATIVE_SAMPLE_FAILED", + "NATIVE_SESSION_PREFIX_RESET_FAILED", + "NATIVE_SESSION_CPU_OBSERVATION", + "NATIVE_SESSION_CPU_BOUND", + "NATIVE_SESSION_DEADLINE", + "NATIVE_SESSION_MODEL_CHANGED", + "NATIVE_SESSION_PREFIX_BOUND", + "INVALID_NATIVE_SESSION_FINISH", + "INVALID_NATIVE_SESSION_SEQUENCE", +]; + +pub(super) fn native_session_detail(error: &Error) -> Detail { + let mut detail = describe(error); + if detail.code == "unclassified" { + for cause in error.chain() { + let message = cause.to_string(); + if let Some(code) = SESSION_CODES.iter().copied().find(|code| *code == message) { + detail.code = code; + break; + } + } + } + detail +} + +// Unlike WorkerFailure, this has no exit-status, adapter-quarantine, request-ID +// or cleanup authority. It retains only the independently closed projection. +#[derive(Debug)] +struct ReceivedNativeWorkerFailure(Detail); + +impl std::fmt::Display for ReceivedNativeWorkerFailure { + fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + output.write_str("compute_native_worker_error") + } +} + +impl std::error::Error for ReceivedNativeWorkerFailure {} + +pub(super) fn native_worker_failure(value: &Value, id: &str) -> Option { + if value.get("version") != Some(&Value::from(1)) + || value.get("id").and_then(Value::as_str) != Some(id) + || value.get("kind").and_then(Value::as_str) != Some("result") + || value.get("status").and_then(Value::as_str) != Some("error") + { + return None; + } + let code = WORKER_CODES + .iter() + .chain(SESSION_WORKER_CODES) + .copied() + .find(|code| value.get("code").and_then(Value::as_str) == Some(*code)) + .unwrap_or("worker_other"); + Some(Error::new(ReceivedNativeWorkerFailure(Detail { + code, + io_kind: "none", + exit_code: None, + signal: None, + stderr_class: None, + }))) +} + pub(in crate::compute) fn failure(phase: &'static str, error: &Error) { emit(phase, &describe(error)); } @@ -375,6 +470,79 @@ fn emit(phase: &'static str, detail: &Detail) { mod tests { use super::*; + #[test] + fn session_codes_are_exact_and_do_not_change_legacy_error_projection() { + for code in SESSION_CODES { + let original = anyhow::anyhow!("PRIVATE_CANARY /private/prompt").context(*code); + assert_eq!(describe(&original).code, "unclassified"); + let detail = native_session_detail(&original); + assert_eq!(detail.code, *code); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("PRIVATE_CANARY") && !raw.contains("/private/")); + let altered = anyhow::anyhow!("{code} PRIVATE_CANARY"); + assert_eq!(native_session_detail(&altered).code, "unclassified"); + } + } + + #[test] + fn received_native_codes_require_exact_correlation_and_never_invent_exit_facts() { + let base = serde_json::json!({"version":1,"id":"PRIVATE_REQUEST_CANARY", + "kind":"result","status":"error","code":"NATIVE_DECODE_FAILED"}); + for code in WORKER_CODES.iter().chain(SESSION_WORKER_CODES) { + let mut value = base.clone(); + value["code"] = (*code).into(); + let error = native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); + assert_eq!( + describe(&error), + Detail { + code, + io_kind: "none", + exit_code: None, + signal: None, + stderr_class: None, + } + ); + assert_eq!(error.chain().count(), 1); + assert!(super::super::adapter_violation(&error).is_none()); + assert!(super::super::native_session::cleanup(&error).is_none()); + } + for (key, changed) in [ + ("version", serde_json::json!(true)), + ("version", serde_json::json!(2)), + ("id", serde_json::json!("OTHER_PRIVATE_REQUEST")), + ("kind", serde_json::json!("progress")), + ("status", serde_json::json!("ok")), + ] { + let mut value = base.clone(); + value[key] = changed; + assert!(native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").is_none()); + } + } + + #[test] + fn native_worker_unknown_codes_and_extra_private_fields_never_escape_projection() { + for code in [ + serde_json::json!("PRIVATE_CANARY"), + serde_json::json!("NATIVE_DECODE_FAILED /private/PRIVATE_CANARY"), + serde_json::json!("NATIVE_DECODE_FAILED\nPRIVATE_CANARY"), + serde_json::json!(true), + serde_json::json!({"private":"PRIVATE_CANARY"}), + Value::Null, + ] { + let value = serde_json::json!({"version":1,"id":"PRIVATE_REQUEST_CANARY", + "kind":"result","status":"error","code":code, + "message":"PRIVATE_CANARY /private/input", "exit_code":42, "signal":9, + "cleanup":{"child_reaped":true}}); + let error = native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); + let detail = describe(&error); + assert_eq!(detail.code, "worker_other"); + assert_eq!(detail.exit_code, None); + assert_eq!(detail.signal, None); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("CANARY") && !raw.contains("/private/") && !raw.contains("42")); + } + } + fn progress(stage: &str, observation: &str, elapsed_ms: u64) -> Value { let index = PRIVATE_STAGES .iter() diff --git a/crates/volparossa/src/compute/supervise/native_session.rs b/crates/volparossa/src/compute/supervise/native_session.rs index 09aae12a8..f2dff0294 100644 --- a/crates/volparossa/src/compute/supervise/native_session.rs +++ b/crates/volparossa/src/compute/supervise/native_session.rs @@ -43,6 +43,17 @@ pub(in crate::compute) enum Event { #[derive(Debug)] struct Failure { cause: anyhow::Error, + diagnostic: super::diagnostic::Detail, +} + +impl Failure { + // Called only after the original process join and private-root removal. + // Keep the cause private for the existing deadline predicate; its chain is + // deliberately not exposed through Error::source or public wire messages. + fn after_cleanup(cause: anyhow::Error) -> Self { + let diagnostic = super::diagnostic::native_session_detail(&cause); + Self { cause, diagnostic } + } } impl fmt::Display for Failure { @@ -53,6 +64,12 @@ impl fmt::Display for Failure { impl std::error::Error for Failure {} +pub(super) fn diagnostic(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(|failure| failure.diagnostic) +} + fn cleanup_record() -> Value { json!({"version":1,"child_reaped":true,"private_state_removed":true}) } @@ -309,7 +326,7 @@ pub(in crate::compute) async fn run( root.close()?; let result = result .map(|()| cleanup_record()) - .map_err(|cause| anyhow::Error::new(Failure { cause })); + .map_err(|cause| anyhow::Error::new(Failure::after_cleanup(cause))); if let Some(hints) = resident.hints.take() { hints.finish(&result); } diff --git a/crates/volparossa/src/compute/supervise/native_session/tests.rs b/crates/volparossa/src/compute/supervise/native_session/tests.rs index 8b09ce807..a3bfe0cc0 100644 --- a/crates/volparossa/src/compute/supervise/native_session/tests.rs +++ b/crates/volparossa/src/compute/supervise/native_session/tests.rs @@ -1,6 +1,6 @@ //! Inert lifecycle/authority checks; no namespace, model or native backend. -use std::os::unix::fs::PermissionsExt as _; +use std::{io::Write as _, os::unix::fs::PermissionsExt as _}; use super::*; @@ -9,18 +9,112 @@ fn cleanup_is_explicitly_constructed_only_after_join_and_removal() { let ordinary = anyhow::anyhow!("not a cleanup proof"); assert_eq!(cleanup(&ordinary), None); assert_eq!(cleanup(&private_task::CleanupUnconfirmed.into()), None); - let confirmed = anyhow::Error::new(Failure { - cause: super::super::ExecutionDeadline.into(), - }); + let confirmed = anyhow::Error::new(Failure::after_cleanup( + super::super::ExecutionDeadline.into(), + )); assert_eq!(cleanup(&confirmed), Some(cleanup_record())); assert!(execution_deadline(&confirmed)); assert!(!execution_deadline(&ordinary)); - let different = anyhow::Error::new(Failure { - cause: anyhow::anyhow!("compute_owner_busy"), - }); + let different = anyhow::Error::new(Failure::after_cleanup(anyhow::anyhow!( + "compute_owner_busy" + ))); assert!(!execution_deadline(&different)); } +#[test] +fn closed_failure_preserves_only_projected_diagnostic_without_opening_its_source() { + let cause = anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "PRIVATE_CANARY /private/source.rs prompt body", + )) + .context("compute_private_process_children"); + let before = super::super::diagnostic::describe(&cause); + let error = anyhow::Error::new(Failure::after_cleanup(cause)); + assert_eq!(super::super::diagnostic::describe(&error), before); + assert_eq!(error.chain().count(), 1); + assert!(std::error::Error::source(error.downcast_ref::().unwrap()).is_none()); + assert_eq!(error.to_string(), "compute_native_session_execution_failed"); + assert_eq!(cleanup(&error), Some(cleanup_record())); + assert!(!execution_deadline(&error)); + let encoded = serde_json::to_string(&diagnostic(&error).unwrap()).unwrap(); + assert!(!encoded.contains("PRIVATE_CANARY") && !encoded.contains("/private/")); +} + +#[test] +fn received_worker_error_cannot_construct_exit_adapter_or_cleanup_authority() { + let value = json!({"version":1,"id":"PRIVATE_REQUEST_CANARY","kind":"result", + "status":"error","code":"NATIVE_DECODE_FAILED"}); + let received = + super::super::diagnostic::native_worker_failure(&value, "PRIVATE_REQUEST_CANARY") + .unwrap(); + assert_eq!(cleanup(&received), None); + assert!(!execution_deadline(&received)); + assert!(super::super::adapter_violation(&received).is_none()); + let error = anyhow::Error::new(Failure::after_cleanup(received)); + let detail = serde_json::to_value(super::super::diagnostic::describe(&error)).unwrap(); + assert_eq!(detail["code"], "NATIVE_DECODE_FAILED"); + assert_eq!(detail["exit_code"], Value::Null); + assert_eq!(detail["signal"], Value::Null); + assert_eq!(detail["stderr_class"], Value::Null); + assert!(!detail.to_string().contains("PRIVATE_REQUEST_CANARY")); + assert_eq!(cleanup(&error), Some(cleanup_record())); + assert!(!execution_deadline(&error)); + assert!(super::super::adapter_violation(&error).is_none()); + // An independent cleanup failure is never relabelled using this diagnostic. + let unconfirmed = anyhow::Error::new(private_task::CleanupUnconfirmed); + assert_eq!(diagnostic(&unconfirmed), None); + assert_eq!(cleanup(&unconfirmed), None); +} + +#[test] +fn closed_projected_failure_is_accepted_by_both_collector_versions() { + // Serializer/collector contract only: no worker, native backend or cleanup + // is executed by these deliberately fabricated private wrapper instances. + let worker = json!({"version":1,"id":"PRIVATE_REQUEST_CANARY","kind":"result", + "status":"error","code":"NATIVE_SAMPLE_FAILED"}); + let errors = [ + anyhow::anyhow!("PRIVATE_CANARY /private/prompt").context("compute_memory_budget"), + super::super::diagnostic::native_worker_failure(&worker, "PRIVATE_REQUEST_CANARY") + .unwrap(), + anyhow::anyhow!("PRIVATE_CANARY /private/prompt"), + ]; + let mut log = tempfile::NamedTempFile::new().unwrap(); + for cause in errors { + let error = anyhow::Error::new(Failure::after_cleanup(cause)); + let record = json!({"version":1,"phase":"task", + "detail":super::super::diagnostic::describe(&error)}); + let raw = serde_json::to_string(&record).unwrap(); + assert!(!raw.contains("CANARY") && !raw.contains("/private/")); + writeln!(log, "DEBUG private_execution_diagnostic {raw}").unwrap(); + } + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let output = std::process::Command::new("python3") + .args(["-B", "-c", r" +import runpy, sys +from pathlib import Path +fixture = runpy.run_path(sys.argv[1]) +for enabled in (False, True): + value = fixture['service_diagnostic'](Path(sys.argv[2]), memory_enabled=enabled) + expected = dict(version=2 if enabled else 1, truncated=False, unrecognized_record=False, + events=[dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in + ('compute_memory_budget', 'NATIVE_SAMPLE_FAILED', 'unclassified')]) + if enabled: + expected['memory'] = [] + assert value == expected +"]) + .arg(fixture) + .arg(log.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "closed collector rejected projection" + ); + assert!(output.stdout.is_empty() && output.stderr.is_empty()); +} + #[test] fn private_root_drop_preserves_uncertain_state_but_explicit_close_removes_it() { let parent = tempfile::tempdir().unwrap(); diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs index c9795d05e..92fd3b85f 100644 --- a/crates/volparossa/src/compute/supervise/native_session/worker.rs +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -277,6 +277,11 @@ impl Live { if value["kind"] == "result" { ensure!(result.is_none(), "compute_output_after_result"); controls.terminal()?; + if let Some(error) = supervisor::diagnostic::native_worker_failure(&value, &request.id) { + // A correlated worker error is not proof of its exit, + // cleanup, adapter invalidity or a retry entitlement. + return Err(error); + } // There is no invented successful exit status here. supervisor::check_successful_result(&value, request)?; controls.check_report(&value)?; diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index 34174b694..79422c7f6 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -91,6 +91,20 @@ 'NATIVE_MODEL_INITIALIZATION_FAILED', 'NATIVE_VOCABULARY_MISMATCH', 'NATIVE_CONTEXT_INITIALIZATION_FAILED', 'NATIVE_SAMPLER_INITIALIZATION_FAILED', 'NATIVE_MODEL_LOAD_FAILED', 'NATIVE_OWNER_CONTROL_FAILED', 'INVALID_NATIVE_CONTROL', + 'compute_native_session_child_exited', 'compute_native_session_cpu_backwards', + 'compute_native_session_cpu_bound', 'compute_native_session_cpu_budget', + 'compute_native_session_cpu_clock', 'compute_native_session_cpu_identity', + 'compute_native_session_cpu_stat', 'compute_native_session_receiver', + 'compute_native_session_report', 'compute_native_session_request_bound', + 'compute_native_session_root', 'compute_native_session_stderr', + 'compute_native_session_stderr_closed', 'compute_native_session_stdout_closed', + 'compute_native_session_turn_root', 'compute_native_session_unrequested_idle', + 'compute_native_session_unsupported', 'compute_native_session_worker', + 'compute_worker_invalid_json', 'compute_output_after_result', 'compute_result_missing', + 'NATIVE_DECODE_FAILED', 'NATIVE_SAMPLE_FAILED', 'NATIVE_SESSION_PREFIX_RESET_FAILED', + 'NATIVE_SESSION_CPU_OBSERVATION', 'NATIVE_SESSION_CPU_BOUND', 'NATIVE_SESSION_DEADLINE', + 'NATIVE_SESSION_MODEL_CHANGED', 'NATIVE_SESSION_PREFIX_BOUND', + 'INVALID_NATIVE_SESSION_FINISH', 'INVALID_NATIVE_SESSION_SEQUENCE', } SERVICE_IO_KINDS = {'none', 'not_found', 'permission_denied', 'unexpected_eof', 'broken_pipe', 'interrupted', 'invalid_data', 'other'} diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index 3d9eb5a6a..eda1f1baa 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -438,7 +438,7 @@ def test_task_setup_and_report_failures_remain_closed_and_bounded(self): def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(self): source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() expected = {'unclassified', 'worker_other', 'startup_missing_result', 'result_observed', 'panic', 'join_cancelled'} - for name in ('FIXED_CODES', 'WORKER_CODES'): + for name in ('FIXED_CODES', 'WORKER_CODES', 'SESSION_CODES', 'SESSION_WORKER_CODES'): array = source.split(f'const {name}: &[&str] = &[', 1)[1].split('];', 1)[0] expected.update(re.findall(r'"([A-Za-z_]+)"', array)) self.assertEqual(FIX['SERVICE_CODES'], expected) @@ -470,7 +470,9 @@ def test_native_json_diagnostics_keep_old_code_and_only_four_closed_categories(s self.assertNotIn('column', json.dumps(result)) def test_native_load_diagnostics_preserve_only_closed_phases(self): - codes = {code for code in FIX['SERVICE_CODES'] if 'NATIVE' in code} + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + historical = source.split('const WORKER_CODES: &[&str] = &[', 1)[1].split('];', 1)[0] + codes = {code for code in re.findall(r'"([A-Za-z_]+)"', historical) if 'NATIVE' in code} self.assertEqual(len(codes), 13) events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] @@ -486,6 +488,38 @@ def test_native_load_diagnostics_preserve_only_closed_phases(self): self.assertFalse(result['truncated']) self.assertNotIn('CANARY', json.dumps(result)) + def test_native_session_codes_are_closed_bounded_and_preserve_receipt_shape(self): + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + codes = [] + for name, count in [('SESSION_CODES', 21), ('SESSION_WORKER_CODES', 10)]: + array = source.split(f'const {name}: &[&str] = &[', 1)[1].split('];', 1)[0] + selected = re.findall(r'"([A-Za-z_]+)"', array) + self.assertEqual(len(selected), count) + codes.extend(selected) + self.assertEqual(len(set(codes)), 31) + with tempfile.TemporaryDirectory(prefix='native-session-diagnostic-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + for start in range(0, len(codes), 15): + events = [dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in codes[start:start + 15]] + invalid = dict(events[0], detail=dict(events[0]['detail'], code='NATIVE_PRIVATE_CANARY')) + raw = 'PRIVATE_CANARY /private/prompt\n' + ''.join( + 'DEBUG private_execution_diagnostic ' + json.dumps(value) + '\n' + for value in [*events, invalid]) + path.write_text(raw) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result, dict(version=1, events=events, + truncated=False, unrecognized_record=True)) + self.assertNotIn('CANARY', json.dumps(result)) + self.assertNotIn('cleanup', json.dumps(result)) + line = 'DEBUG private_execution_diagnostic ' + json.dumps(events[0]) + '\n' + path.write_text(line * 17) + result = FIX['service_diagnostic'](path) + self.assertEqual(len(result['events']), 16) + self.assertTrue(result['truncated']) + self.assertFalse(result['unrecognized_record']) + def test_terminal_execution_state_distinguishes_gate_pause_from_completed_model_load(self): value = dict(version=1, last_phase='paused', substage=dict(stage='owner_gate', state='begin', elapsed_ms=0), capacity=dict(decision='pause', constraint='cpu', cpu_some_avg10=24.5, io_some_avg10=0.0, From a37f5cd0b1ec212632dcff95cac4e6da589027e9 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:42:06 +0200 Subject: [PATCH 35/39] style(compute): apply hosted native diagnostic test formatting --- .../src/compute/supervise/native_session/tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/volparossa/src/compute/supervise/native_session/tests.rs b/crates/volparossa/src/compute/supervise/native_session/tests.rs index a3bfe0cc0..55cfe1462 100644 --- a/crates/volparossa/src/compute/supervise/native_session/tests.rs +++ b/crates/volparossa/src/compute/supervise/native_session/tests.rs @@ -45,8 +45,7 @@ fn received_worker_error_cannot_construct_exit_adapter_or_cleanup_authority() { let value = json!({"version":1,"id":"PRIVATE_REQUEST_CANARY","kind":"result", "status":"error","code":"NATIVE_DECODE_FAILED"}); let received = - super::super::diagnostic::native_worker_failure(&value, "PRIVATE_REQUEST_CANARY") - .unwrap(); + super::super::diagnostic::native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); assert_eq!(cleanup(&received), None); assert!(!execution_deadline(&received)); assert!(super::super::adapter_violation(&received).is_none()); @@ -74,8 +73,7 @@ fn closed_projected_failure_is_accepted_by_both_collector_versions() { "status":"error","code":"NATIVE_SAMPLE_FAILED"}); let errors = [ anyhow::anyhow!("PRIVATE_CANARY /private/prompt").context("compute_memory_budget"), - super::super::diagnostic::native_worker_failure(&worker, "PRIVATE_REQUEST_CANARY") - .unwrap(), + super::super::diagnostic::native_worker_failure(&worker, "PRIVATE_REQUEST_CANARY").unwrap(), anyhow::anyhow!("PRIVATE_CANARY /private/prompt"), ]; let mut log = tempfile::NamedTempFile::new().unwrap(); @@ -90,7 +88,10 @@ fn closed_projected_failure_is_accepted_by_both_collector_versions() { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../tests/integration/agent-private-conversation.py"); let output = std::process::Command::new("python3") - .args(["-B", "-c", r" + .args([ + "-B", + "-c", + r" import runpy, sys from pathlib import Path fixture = runpy.run_path(sys.argv[1]) @@ -103,7 +104,8 @@ for enabled in (False, True): if enabled: expected['memory'] = [] assert value == expected -"]) +", + ]) .arg(fixture) .arg(log.path()) .output() From 95ef4541e518f9f9050dedc5d24bd4176b70db71 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:38:51 +0200 Subject: [PATCH 36/39] Bound retained native KV allocation to full turn context --- .github/workflows/native-cpu-contract.yml | 97 +++++++++++- workers/volparossa-ml/llama_cpu.py | 72 +++++++-- .../volparossa-ml/native-cpu/abi-smoke.cpp | 2 + workers/volparossa-ml/native-cpu/adapter.cpp | 63 ++++++-- workers/volparossa-ml/native-cpu/adapter.h | 9 ++ .../volparossa-ml/native-cpu/session-state.h | 30 ++++ .../native-cpu/session-tests.cpp | 101 +++++++++++++ workers/volparossa-ml/test_native_session.py | 143 +++++++++++++++++- 8 files changed, 487 insertions(+), 30 deletions(-) diff --git a/.github/workflows/native-cpu-contract.yml b/.github/workflows/native-cpu-contract.yml index 32396c253..45aec503a 100644 --- a/.github/workflows/native-cpu-contract.yml +++ b/.github/workflows/native-cpu-contract.yml @@ -5,10 +5,12 @@ on: branches: - feature/native-cpu-inference - feature/native-coding-output-constraint + - fix/native-session-context-capacity paths: - workers/volparossa-ml/native-cpu/** - workers/volparossa-ml/build_llama_cpu.py - workers/volparossa-ml/llama_cpu.py + - workers/volparossa-ml/test_native_session.py - .github/workflows/native-cpu-contract.yml workflow_dispatch: @@ -42,10 +44,72 @@ jobs: test "$GITHUB_ACTIONS" = true test "$RUNNER_ENVIRONMENT" = github-hosted test "$RUNNER_OS" = Linux + test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - test -z "$(git status --porcelain)" + test -z "$(git status --porcelain --untracked-files=all)" env -i PATH=/usr/local/bin:/usr/bin:/bin /bin/bash -euo pipefail -c \ 'for tool in cmake c++ cc git readelf nice python3; do command -v "$tool"; done' + mkdir -p build/native-contract + python3 -B - <<'PY' + import hashlib, json, os, subprocess + from pathlib import Path + + def git(*args): + return subprocess.check_output(['git', *args]) + + head = os.environ['GITHUB_SHA'] + paths = ( + 'workers/volparossa-ml/llama_cpu.py', + 'workers/volparossa-ml/native-cpu/abi-smoke.cpp', + 'workers/volparossa-ml/native-cpu/adapter.cpp', + 'workers/volparossa-ml/native-cpu/adapter.h', + 'workers/volparossa-ml/native-cpu/session-state.h', + 'workers/volparossa-ml/native-cpu/session-tests.cpp', + 'workers/volparossa-ml/test_native_session.py', + ) + files = {} + for name in paths: + raw = git('show', head + ':' + name) + assert Path(name).read_bytes() == raw + files[name] = dict(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest(), + blob=git('rev-parse', head + ':' + name).decode().strip()) + workflow = '.github/workflows/native-cpu-contract.yml' + record = dict(version=1, kind='native-cpu-contract-source', + source_commit=head, source_tree=git('rev-parse', 'HEAD^{tree}').decode().strip(), + workflow_commit=os.environ['GITHUB_WORKFLOW_SHA'], workflow_path=workflow, + workflow_sha256=hashlib.sha256(git('show', head + ':' + workflow)).hexdigest(), + mode=os.environ['NATIVE_MODE'], run_id=os.environ['GITHUB_RUN_ID'], + run_attempt=os.environ['GITHUB_RUN_ATTEMPT'], files=files, + model_execution=False, real_kv_memory_saving_proven=False) + with Path('build/native-contract/source.json').open('x') as stream: + stream.write(json.dumps(record, sort_keys=True) + '\n') + PY + - name: Check the same 91 inert Python cases in each matrix mode + run: | + set -euo pipefail + ( + cd workers/volparossa-ml + timeout --kill-after=5s 120s \ + env -i PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ + python3 -B -m unittest -v test_llama_cpu test_conversion_diagnostic \ + test_native_output_constraint test_native_source_lifecycle test_native_session + ) 2>&1 | tee build/native-contract/python-contracts.log + python3 -B - <<'PY' + import json, os, re + from pathlib import Path + text = Path('build/native-contract/python-contracts.log').read_text() + cases = re.findall(r'^(test_[A-Za-z0-9_]+) \(([^)]+)\) \.\.\. ok$', text, re.M) + names = [scope + '::' + method for method, scope in cases] + assert len(names) == len(set(names)) == 91 + assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['91'] + assert re.findall(r'^OK.*$', text, re.M) == ['OK'] + record = dict(version=1, mode=os.environ['NATIVE_MODE'], passed=91, failed=0, + skipped=0, unique_tests_across_matrix=91, repeated_in_each_matrix_mode=True, + test_names=sorted(names), model_execution=False) + with Path('build/native-contract/python-contracts.json').open('x') as stream: + stream.write(json.dumps(record, sort_keys=True) + '\n') + PY - name: Fetch the pinned upstream source only run: | set -euo pipefail @@ -64,12 +128,43 @@ jobs: python3 -B workers/volparossa-ml/build_llama_cpu.py \ --source "$PWD/build/native-contract/llama" \ --output "$PWD/build/native-contract/output" --execute --checks "${flags[@]}" + - name: Recheck original source after success or failure + if: always() + run: | + set -euo pipefail + mkdir -p build/native-contract + ( + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + git diff --exit-code HEAD + # Only the predeclared evidence/upstream/build directory is untracked output. + test -z "$(git status --porcelain --untracked-files=all -- . ':(exclude)build/native-contract')" + python3 -B - <<'PY' + import hashlib, json, os, subprocess + from pathlib import Path + record = json.loads(Path('build/native-contract/source.json').read_text()) + assert record['source_commit'] == os.environ['GITHUB_SHA'] + assert record['workflow_commit'] == os.environ['GITHUB_WORKFLOW_SHA'] + assert record['source_tree'] == subprocess.check_output( + ['git', 'rev-parse', 'HEAD^{tree}']).decode().strip() + for name, expected in record['files'].items(): + raw = Path(name).read_bytes() + assert len(raw) == expected['bytes'] + assert hashlib.sha256(raw).hexdigest() == expected['sha256'] + assert hashlib.sha256(Path(record['workflow_path']).read_bytes()).hexdigest() == record['workflow_sha256'] + print('NATIVE_SOURCE_UNCHANGED') + PY + ) 2>&1 | tee build/native-contract/source-check.log - name: Retain original source-only reports and logs, including failures if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: native-source-${{ matrix.mode }}-${{ github.sha }} path: | + build/native-contract/source.json + build/native-contract/source-check.log + build/native-contract/python-contracts.json + build/native-contract/python-contracts.log build/native-contract/output/build.json build/native-contract/output/build.log build/native-contract/output/checks.log diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index 5d8067202..69a3503cc 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -206,9 +206,13 @@ def joined(self): class NativeModel: def __init__(self, root, capacity, threads, probe, require, *, output_constraints=None, - output_constraint=None, output_constraint_tools=None, native_session=False): + output_constraint=None, output_constraint_tools=None, native_session=False, + context_capacity=False): self.handle = ctypes.c_void_p() self.require, self.probe = require, probe + self.capacity, self.context_capacity = capacity, context_capacity + require(type(context_capacity) is bool and (not context_capacity or native_session), + "NATIVE_SESSION_ABI_MISMATCH") # The caller checked owner-authorized digest before this executable load. self.lib = ctypes.CDLL(str(root / LIBRARY), mode=0) signatures = { @@ -257,6 +261,16 @@ def __init__(self, root, capacity, threads, probe, require, *, output_constraint function.restype, function.argtypes = result, arguments extension = self.lib.vp_llama_native_session_abi_v1() require(type(extension) is int and extension == 1, "NATIVE_SESSION_ABI_MISMATCH") + if context_capacity: + for name, result, arguments in ( + ("vp_llama_context_capacity_abi_v1", ctypes.c_uint32, []), + ("vp_llama_reserve_context_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ABORT, ctypes.c_void_p])): + require(hasattr(self.lib, name), "NATIVE_SESSION_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_context_capacity_abi_v1() + require(type(extension) is int and extension == 1, "NATIVE_SESSION_ABI_MISMATCH") try: status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, probe.callback, None, ctypes.byref(self.handle)) @@ -310,6 +324,19 @@ def begin_turn(self, retained_tokens, probe, constraint, aliases): self.require(status == 0, "NATIVE_SESSION_PREFIX_RESET_FAILED") del previous + def reserve_context(self, capacity, probe): + self.require(self.context_capacity and type(capacity) is int + and self.capacity < capacity <= 12288 + 1024, "NATIVE_SESSION_PREFIX_BOUND") + # Keep the old callback alive across the synchronous handover, exactly + # as begin_turn does. The ABI installs this turn's owner/deadline first. + previous = self.probe + status = self.lib.vp_llama_reserve_context_v1(self.handle, capacity, probe.callback, None) + self.probe = probe + probe.joined() + self.require(status == 0, "NATIVE_CONTEXT_INITIALIZATION_FAILED") + self.capacity = capacity + del previous + def close(self): if self.handle.value is not None: self.lib.vp_llama_close_v1(self.handle) @@ -434,25 +461,45 @@ class RetainedModel: """One interpreter/connection's private RAM; never global or serializable.""" def __init__(self): self.model, self.binding, self.tokens = None, None, [] + self.capacity, self.failed = 0, False def begin(self, root, manifest, identity, request, prompt, probe, worker): + worker.require(not self.failed, "NATIVE_SESSION_PREFIX_RESET_FAILED") worker.require(manifest.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_UNSUPPORTED") binding = (str(root), json.dumps(identity, sort_keys=True), request["threads"]) worker.require(self.binding is None or self.binding == binding, "NATIVE_SESSION_MODEL_CHANGED") constraint, aliases = request.get("output_constraint"), request.get("output_constraint_tools") + profile = worker.model_profile(PROFILE) + worker.require(type(prompt) is list and 1 <= len(prompt) <= profile["prompt_tokens"] + and all(type(token) is int and 0 <= token < 151936 for token in prompt), "NATIVE_PROMPT_BOUND") + # Match the pinned native context's 256-token padding, not its maximum + # permitted context. The complete prompt and output allowance still fit. + capacity = ((len(prompt) + profile["new_tokens"] + 255) // 256) * 256 + worker.require(capacity <= 12288 + 1024, "NATIVE_PROMPT_BOUND") prefix = 0 - if self.model is None: - self.model = NativeModel(root, 12288 + 1024, request["threads"], probe, worker.require, - output_constraints=manifest["output_constraints"], native_session=True) - self.binding = binding - else: - for old, new in zip(self.tokens, prompt[:-1]): - if old != new: - break - prefix += 1 - self.model.begin_turn(prefix, probe, constraint, aliases) - # A failed turn can never authorize reuse, even if its partial decode ran. + previous = self.tokens + # A failed or growing turn cannot retain authorization to old KV. self.tokens = [] + try: + if self.model is None: + self.model = NativeModel(root, capacity, request["threads"], probe, worker.require, + output_constraints=manifest["output_constraints"], native_session=True, context_capacity=True) + self.binding, self.capacity = binding, capacity + elif capacity > self.capacity: + # Growth keeps the one model but destroys the old context first. + # It costs prefix reuse for this turn, never any prompt tokens. + self.model.reserve_context(capacity, probe) + self.capacity = capacity + else: + for old, new in zip(previous, prompt[:-1]): + if old != new: + break + prefix += 1 + self.model.begin_turn(prefix, probe, constraint, aliases) + except BaseException: + self.failed = True + self.close() + raise return self.model, prefix def close(self): @@ -461,3 +508,4 @@ def close(self): self.model.close() finally: self.model, self.binding, self.tokens = None, None, [] + self.capacity = 0 diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp index 36a8a71a0..f78981688 100644 --- a/workers/volparossa-ml/native-cpu/abi-smoke.cpp +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -14,6 +14,8 @@ int main() { assert(vp_llama_abi_v1() == 1); assert(vp_llama_output_constraint_abi_v1() == 1); assert(vp_llama_native_session_abi_v1() == 1); + assert(vp_llama_context_capacity_abi_v1() == 1); + assert(vp_llama_reserve_context_v1(nullptr, 2048, nullptr, nullptr) == VP_INVALID); assert(vp_llama_begin_turn_v1(nullptr, 0, 0, 0, nullptr, nullptr) == VP_INVALID); assert(vp_llama_set_output_constraint_v1(nullptr, 1, 1) == VP_INVALID); assert(vp_llama_open_stage_v1() == VP_OPEN_NONE); diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp index 88987da9a..e89f4efbe 100644 --- a/workers/volparossa-ml/native-cpu/adapter.cpp +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -26,6 +26,7 @@ struct state : vp::session_state { vp_abort_v1 abort = nullptr; void * opaque = nullptr; uint32_t capacity = 0; + uint32_t threads = 0; std::unique_ptr constraint; ~state() { if (context) { llama_synchronize(context); } @@ -40,6 +41,23 @@ bool aborted(void * pointer) { return value->abort(value->opaque) != 0; } bool load_progress(float, void * pointer) { return !aborted(pointer); } +llama_context * create_context(state * value, uint32_t capacity) { + auto params = llama_context_default_params(); + params.n_ctx = capacity; + params.n_batch = max_batch; + params.n_ubatch = max_batch; + params.n_seq_max = 1; + params.n_threads = static_cast(value->threads); + params.n_threads_batch = static_cast(value->threads); + params.type_k = GGML_TYPE_BF16; + params.type_v = GGML_TYPE_BF16; + params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED; + params.offload_kqv = false; + params.op_offload = false; + params.abort_callback = aborted; + params.abort_callback_data = value; + return llama_init_from_model(value->model, params); +} bool token_piece(const llama_vocab * vocab, llama_token token, std::string & piece) { std::array bytes{}; const auto size = llama_token_to_piece(vocab, token, bytes.data(), bytes.size(), 0, true); @@ -95,6 +113,34 @@ extern "C" const char * vp_llama_source_v1(void) { return source; } extern "C" uint32_t vp_llama_open_stage_v1(void) { return open_stage; } extern "C" uint32_t vp_llama_output_constraint_abi_v1(void) { return 1; } extern "C" uint32_t vp_llama_native_session_abi_v1(void) { return 1; } +extern "C" uint32_t vp_llama_context_capacity_abi_v1(void) { return 1; } + +extern "C" int32_t vp_llama_reserve_context_v1(void * handle, uint32_t capacity, + vp_abort_v1 abort, void * opaque) { + auto * value = static_cast(handle); + if (!value) { return VP_INVALID; } + if (!abort || !value->context || !value->model || !value->sampler) { + value->ready = false; + value->failed = true; + return VP_INVALID; + } + // Replace the expired previous turn's callback before any owner check. + value->abort = abort; + value->opaque = opaque; + return vp::grow_session_context(*value, value->capacity, capacity, + [&] { return aborted(value); }, + [&] { llama_synchronize(value->context); }, + [&] { + value->constraint.reset(); + llama_sampler_reset(value->sampler); + llama_free(value->context); + value->context = nullptr; + }, + [&](uint32_t requested) { + value->context = create_context(value, requested); + return value->context != nullptr; + }); +} extern "C" int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained, uint32_t mode, uint32_t count, @@ -183,6 +229,7 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, value->abort = abort; value->opaque = opaque; value->capacity = capacity; + value->threads = threads; if (aborted(value.get())) { return VP_ABORTED; } auto model_params = llama_model_default_params(); model_params.n_gpu_layers = 0; @@ -199,22 +246,8 @@ extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, if (llama_vocab_n_tokens(vocab) != vocabulary || llama_vocab_eos(vocab) != 151645) { return VP_INVALID; } - auto context_params = llama_context_default_params(); - context_params.n_ctx = capacity; - context_params.n_batch = max_batch; - context_params.n_ubatch = max_batch; - context_params.n_seq_max = 1; - context_params.n_threads = static_cast(threads); - context_params.n_threads_batch = static_cast(threads); - context_params.type_k = GGML_TYPE_BF16; - context_params.type_v = GGML_TYPE_BF16; - context_params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED; - context_params.offload_kqv = false; - context_params.op_offload = false; - context_params.abort_callback = aborted; - context_params.abort_callback_data = value.get(); open_stage = VP_OPEN_CONTEXT; - value->context = llama_init_from_model(value->model, context_params); + value->context = create_context(value.get(), capacity); if (aborted(value.get())) { return VP_ABORTED; } if (!value->context) { return VP_BACKEND; } open_stage = VP_OPEN_SAMPLER; diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h index 10a9c0cf9..899a645d8 100644 --- a/workers/volparossa-ml/native-cpu/adapter.h +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -36,6 +36,15 @@ uint32_t vp_llama_native_session_abi_v1(void); int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained_tokens, uint32_t constraint_mode, uint32_t tool_count, vp_abort_v1 abort, void * opaque); +// Optional context-capacity extension for an already active session. Grow only, +// at most 13312 tokens, under the new turn's callback. Synchronize and destroy +// the old context before allocating its replacement around the SAME model. +// Success discards all previous KV/logits; begin_turn_v1(..., 0, ...) and the +// entire new prompt must follow. Any failure poisons the handle until close. +// Callers lacking this extension keep their original fixed-capacity behavior. +uint32_t vp_llama_context_capacity_abi_v1(void); +int32_t vp_llama_reserve_context_v1(void * handle, uint32_t context_tokens, + vp_abort_v1 abort, void * opaque); int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, uint32_t threads, vp_abort_v1 abort, void * opaque, void ** handle); diff --git a/workers/volparossa-ml/native-cpu/session-state.h b/workers/volparossa-ml/native-cpu/session-state.h index bad67021b..52d81023f 100644 --- a/workers/volparossa-ml/native-cpu/session-state.h +++ b/workers/volparossa-ml/native-cpu/session-state.h @@ -55,4 +55,34 @@ int32_t begin_session_turn(session_state & value, uint32_t retained, uint32_t ca return VP_BACKEND; } } + +// The single model is outside this transition. Destroying the old context must +// complete before creating another; a failed or cancelled transition can only +// be closed. This is capacity growth, never a retry of an admitted generation. +template +int32_t grow_session_context(session_state & value, uint32_t & capacity, uint32_t requested, + Cancelled cancelled, Synchronize synchronize, Destroy destroy, Create create) { + value.ready = false; + if (value.failed || !value.session_active || value.used > capacity || + requested <= capacity || requested > 13312) { + value.failed = true; + return VP_INVALID; + } + try { + if (cancelled()) { value.failed = true; return VP_ABORTED; } + synchronize(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + destroy(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + if (!create(requested)) { value.failed = true; return VP_BACKEND; } + if (cancelled()) { value.failed = true; return VP_ABORTED; } + capacity = requested; + value.used = 0; + value.generated = 0; + return VP_OK; + } catch (...) { + value.failed = true; + return VP_BACKEND; + } +} } diff --git a/workers/volparossa-ml/native-cpu/session-tests.cpp b/workers/volparossa-ml/native-cpu/session-tests.cpp index feaf36ba5..4520bf999 100644 --- a/workers/volparossa-ml/native-cpu/session-tests.cpp +++ b/workers/volparossa-ml/native-cpu/session-tests.cpp @@ -135,6 +135,103 @@ void prompt_generation_and_legacy_boundaries() { assert(value.failed && !value.ready); assert(vp::invalid_session(nullptr) == VP_INVALID); } + +struct growing_backend { + uint32_t capacity = 1280; + unsigned checks = 0, synchronized = 0, destroyed = 0, created = 0; + unsigned cancel_at = 99, throw_at = 99, live_contexts = 1; + bool create_ok = true; + + int32_t grow(vp::session_state & value, uint32_t requested = 1536) { + return vp::grow_session_context(value, capacity, requested, + [&] { assert(!value.ready); return checks++ == cancel_at; }, + [&] { + assert(live_contexts == 1); + ++synchronized; + if (throw_at == 0) { throw std::runtime_error("synthetic sync"); } + }, + [&] { + assert(synchronized == 1 && live_contexts == 1); + ++destroyed; + if (throw_at == 1) { throw std::runtime_error("synthetic destroy"); } + live_contexts = 0; + }, + [&](uint32_t next) { + assert(destroyed == 1 && live_contexts == 0 && next == requested); + ++created; + if (throw_at == 2) { throw std::runtime_error("synthetic create"); } + if (create_ok) { live_contexts = 1; } + return create_ok; + }); + } +}; + +void growth_destroys_before_create_and_forgets_all_old_positions() { + auto value = previous(); + growing_backend next; + assert(next.grow(value) == VP_OK); + assert(next.capacity == 1536 && next.live_contexts == 1); + assert(next.synchronized == 1 && next.destroyed == 1 && next.created == 1 && next.checks == 4); + assert(value.used == 0 && value.generated == 0 && !value.ready && !value.failed && value.session_active); + backend turn; + assert(turn.begin(value, 0, next.capacity) == VP_OK); + assert(value.used == 0 && !value.ready); // A complete new prompt must still be decoded. + auto cannot_retain = previous(); + growing_backend other; + assert(other.grow(cannot_retain) == VP_OK); + assert(turn.begin(cannot_retain, 1, other.capacity) == VP_INVALID); +} + +void growth_rejects_invalid_state_capacity_and_preserves_maximum() { + for (unsigned variant = 0; variant < 6; ++variant) { + auto value = previous(); + growing_backend next; + uint32_t requested = 1280; + if (variant == 1) { requested = 1279; } + if (variant == 2) { requested = 13313; } + if (variant == 3) { requested = 1536; value.failed = true; } + if (variant == 4) { requested = 1536; value.session_active = false; } + if (variant == 5) { requested = 1536; value.used = 1281; } + assert(next.grow(value, requested) == VP_INVALID); + assert(value.failed && !value.ready); + assert(next.capacity == 1280 && next.checks == 0 && next.destroyed == 0 && next.created == 0); + } + auto value = previous(); + growing_backend maximum; + assert(maximum.grow(value, 13312) == VP_OK && maximum.capacity == 13312); +} + +void growth_cancellation_never_commits_capacity_or_reuses_partial_state() { + for (unsigned cancel = 0; cancel < 4; ++cancel) { + auto value = previous(); + growing_backend next; + next.cancel_at = cancel; + assert(next.grow(value) == VP_ABORTED); + assert(value.failed && !value.ready && next.capacity == 1280); + assert(next.synchronized == (cancel >= 1 ? 1u : 0u)); + assert(next.destroyed == (cancel >= 2 ? 1u : 0u)); + assert(next.created == (cancel >= 3 ? 1u : 0u)); + const auto checked = next.checks; + assert(next.grow(value) == VP_INVALID && next.checks == checked); + } +} + +void growth_allocation_failure_or_exception_poison_without_second_context() { + auto value = previous(); + growing_backend failed; + failed.create_ok = false; + assert(failed.grow(value) == VP_BACKEND); + assert(value.failed && !value.ready && failed.live_contexts == 0 && failed.capacity == 1280); + assert(failed.grow(value) == VP_INVALID && failed.created == 1); + for (unsigned stage = 0; stage < 3; ++stage) { + auto subject = previous(); + growing_backend next; + next.throw_at = stage; + assert(next.grow(subject) == VP_BACKEND); + assert(subject.failed && !subject.ready && next.capacity == 1280); + assert(next.live_contexts <= 1); + } +} } int main() { @@ -144,4 +241,8 @@ int main() { cancellation_at_every_backend_boundary(); exceptions_never_cross_the_turn_boundary(); prompt_generation_and_legacy_boundaries(); + growth_destroys_before_create_and_forgets_all_old_positions(); + growth_rejects_invalid_state_capacity_and_preserves_maximum(); + growth_cancellation_never_commits_capacity_or_reuses_partial_state(); + growth_allocation_failure_or_exception_poison_without_second_context(); } diff --git a/workers/volparossa-ml/test_native_session.py b/workers/volparossa-ml/test_native_session.py index 641ff110e..3494d2942 100644 --- a/workers/volparossa-ml/test_native_session.py +++ b/workers/volparossa-ml/test_native_session.py @@ -120,7 +120,82 @@ def test_only_exact_decoded_prefix_survives_and_last_prompt_token_is_recomputed( state.tokens = [1, 2, 3] self.assertEqual(self.begin(state, [9, 2, 3])[1], 0) self.assertEqual(self.model_type.call_count, 1) - self.assertEqual(self.model_type.call_args.args[1], 12288 + 1024) + self.assertEqual(self.model_type.call_args.args[1], 1280) + self.assertTrue(self.model_type.call_args.kwargs["context_capacity"]) + + def test_initial_capacity_covers_complete_prompt_and_unchanged_output_bound(self): + for length, capacity in ((1, 1280), (256, 1280), (257, 1536), (4572, 5632), (12288, 13312)): + with self.subTest(length=length): + state = native.RetainedModel() + self.assertEqual(self.begin(state, [1] * length)[1], 0) + self.assertEqual(state.capacity, capacity) + self.assertEqual(self.model_type.call_args.args[1], capacity) + self.assertGreaterEqual(capacity, length + 1024) + state.close() + + def test_invalid_full_prompt_is_rejected_before_model_allocation(self): + for prompt in ([], [1] * 12289, [True], [151936], [-1], (1, 2)): + with self.subTest(length=len(prompt)), self.assertRaisesRegex(WORKER.JobError, "NATIVE_PROMPT_BOUND"): + self.begin(native.RetainedModel(), prompt) + self.model_type.assert_not_called() + + def test_fit_keeps_prefix_and_growth_keeps_model_but_recomputes_entire_prompt(self): + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + self.assertEqual(self.begin(state, [1] * 256)[1], 255) + model.reserve_context.assert_not_called() + state.tokens = [1] * 256 + def grow(capacity, _probe): + self.assertEqual(capacity, 1536) + self.assertEqual(state.tokens, []) + model.reserve_context.side_effect = grow + prompt = [1] * 257 + same, prefix = self.begin(state, prompt) + self.assertIs(same, model) + self.assertEqual(prefix, 0) + self.assertEqual(self.model_type.call_count, 1) + model.close.assert_not_called() + model.sample.return_value = 151645 + native.generate(model, prompt, SimpleNamespace(eos_token_id=151645, decode=lambda *a, **k: ""), + WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER, retained_tokens=prefix) + self.assertEqual([token for call in model.decode.call_args_list for token in call.args[0]], prompt) + self.assertEqual([len(call.args[0]) for call in model.decode.call_args_list], [128, 128, 1]) + state.tokens = prompt + self.assertEqual(self.begin(state, [1, 1])[1], 1) + self.assertEqual(state.capacity, 1536) # No shrink/reallocation on a shorter turn. + model.reserve_context.assert_called_once() + + def test_failed_or_cancelled_growth_clears_tokens_closes_and_cannot_retry(self): + for code in ("NATIVE_CONTEXT_INITIALIZATION_FAILED", "JOB_CANCELLED"): + with self.subTest(code=code): + self.model_type.reset_mock(return_value=True) + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + model.reserve_context.side_effect = WORKER.JobError(code) + with self.assertRaisesRegex(WORKER.JobError, code): + self.begin(state, [1] * 257) + self.assertEqual(state.tokens, []) + self.assertIsNone(state.model) + self.assertTrue(state.failed) + model.close.assert_called_once() + self.assertEqual(model.begin_turn.call_count, 1) + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_RESET_FAILED"): + self.begin(state, [1] * 257) + self.assertEqual(self.model_type.call_count, 1) + + def test_grown_context_begin_failure_also_closes_without_retry(self): + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + model.begin_turn.side_effect = WORKER.JobError("NATIVE_SESSION_PREFIX_RESET_FAILED") + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_RESET_FAILED"): + self.begin(state, [1] * 257) + model.reserve_context.assert_called_once() + model.close.assert_called_once() + self.assertEqual(state.tokens, []) + self.assertTrue(state.failed) def test_no_cross_instance_identity_or_failed_handle_replacement(self): first, second = native.RetainedModel(), native.RetainedModel() @@ -157,7 +232,71 @@ def test_generate_reuses_no_uncomputed_sample_and_keeps_full_prompt_bound(self): class AbiTests(unittest.TestCase): def open(self, value, **kwargs): with mock.patch.object(native.ctypes, "CDLL", return_value=value): - return native.NativeModel(Path("/inert"), 12288 + 1024, 2, mock.Mock(), WORKER.require, **kwargs) + return native.NativeModel(Path("/inert"), kwargs.pop("capacity", 12288 + 1024), + 2, mock.Mock(), WORKER.require, **kwargs) + + def capacity_library(self, version=1): + value = library() + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + if version is not None: + value.vp_llama_context_capacity_abi_v1 = mock.Mock(return_value=version) + value.vp_llama_reserve_context_v1 = mock.Mock(return_value=0) + return value + + def test_capacity_extension_required_only_for_explicit_internal_selection(self): + for version in (None, 0, 2, True): + value = self.capacity_library(version) + with self.subTest(version=version), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, native_session=True, context_capacity=True) + value.vp_llama_open_v1.assert_not_called() + self.open(value, native_session=True).close() # Old callers retain the original v1 ABI. + value = library() + self.open(value).close() # Cold/legacy needs neither session nor capacity symbols. + value.vp_llama_open_v1.reset_mock() + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, context_capacity=True) + value.vp_llama_open_v1.assert_not_called() + + def test_capacity_handover_uses_new_callback_and_no_model_reopen(self): + value = self.capacity_library() + model = self.open(value, native_session=True, context_capacity=True, capacity=1280) + previous, current = model.probe, mock.Mock() + def grow(handle, capacity, callback, opaque): + self.assertIs(model.probe, previous) # Old owner object stays alive until native returns. + self.assertIs(handle, model.handle) + self.assertEqual(capacity, 1536) + self.assertIs(callback, current.callback) + self.assertIsNone(opaque) + return 0 + value.vp_llama_reserve_context_v1.side_effect = grow + model.reserve_context(1536, current) + self.assertIs(model.probe, current) + self.assertEqual(model.capacity, 1536) + current.joined.assert_called_once() + value.vp_llama_open_v1.assert_called_once() + value.vp_llama_close_v1.assert_not_called() + for capacity in (True, 1536, 1280, 13313): + with self.subTest(capacity=capacity), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_BOUND"): + model.reserve_context(capacity, current) + value.vp_llama_reserve_context_v1.assert_called_once() + model.close() + value.vp_llama_close_v1.assert_called_once() + + def test_capacity_failure_or_pending_cancel_never_commits_capacity(self): + for status, cancelled in ((3, False), (2, False), (0, True)): + value = self.capacity_library() + value.vp_llama_reserve_context_v1.return_value = status + model = self.open(value, native_session=True, context_capacity=True, capacity=1280) + current = mock.Mock() + if cancelled: + current.joined.side_effect = WORKER.JobError("JOB_CANCELLED") + with self.subTest(status=status, cancelled=cancelled), self.assertRaises(WORKER.JobError): + model.reserve_context(1536, current) + self.assertIs(model.probe, current) + self.assertEqual(model.capacity, 1280) + model.close() + value.vp_llama_close_v1.assert_called_once() def test_new_manifest_never_loads_old_or_mismatched_session_abi(self): for version in (None, 0, 2, True): From 6d65bb12ed7b8720d43868c9306313133a5b96ae Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:28:22 +0200 Subject: [PATCH 37/39] fix(native): bind aligned Q1 scalar load to staged CPU source --- .github/workflows/native-cpu-contract.yml | 21 ++- THIRD_PARTY_LICENSES.md | 19 ++- docs/IMPLEMENTATION_STATUS.md | 16 ++ workers/volparossa-ml/build_llama_cpu.py | 71 +++++++- workers/volparossa-ml/convert_llama_cpu.py | 2 + workers/volparossa-ml/llama_cpu.py | 25 +++ .../volparossa-ml/native-cpu/CMakeLists.txt | 29 ++++ workers/volparossa-ml/native-cpu/README.md | 21 ++- .../native-cpu/q1-aligned-load.patch | 12 ++ .../test_native_cpu_alignment.py | 158 ++++++++++++++++++ 10 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 workers/volparossa-ml/native-cpu/q1-aligned-load.patch create mode 100644 workers/volparossa-ml/test_native_cpu_alignment.py diff --git a/.github/workflows/native-cpu-contract.yml b/.github/workflows/native-cpu-contract.yml index 45aec503a..3e65f8344 100644 --- a/.github/workflows/native-cpu-contract.yml +++ b/.github/workflows/native-cpu-contract.yml @@ -6,11 +6,14 @@ on: - feature/native-cpu-inference - feature/native-coding-output-constraint - fix/native-session-context-capacity + - fix/native-q1-aligned-load paths: - workers/volparossa-ml/native-cpu/** - workers/volparossa-ml/build_llama_cpu.py + - workers/volparossa-ml/convert_llama_cpu.py - workers/volparossa-ml/llama_cpu.py - workers/volparossa-ml/test_native_session.py + - workers/volparossa-ml/test_native_cpu_alignment.py - .github/workflows/native-cpu-contract.yml workflow_dispatch: @@ -60,13 +63,18 @@ jobs: head = os.environ['GITHUB_SHA'] paths = ( + 'workers/volparossa-ml/build_llama_cpu.py', + 'workers/volparossa-ml/convert_llama_cpu.py', 'workers/volparossa-ml/llama_cpu.py', + 'workers/volparossa-ml/native-cpu/CMakeLists.txt', + 'workers/volparossa-ml/native-cpu/q1-aligned-load.patch', 'workers/volparossa-ml/native-cpu/abi-smoke.cpp', 'workers/volparossa-ml/native-cpu/adapter.cpp', 'workers/volparossa-ml/native-cpu/adapter.h', 'workers/volparossa-ml/native-cpu/session-state.h', 'workers/volparossa-ml/native-cpu/session-tests.cpp', 'workers/volparossa-ml/test_native_session.py', + 'workers/volparossa-ml/test_native_cpu_alignment.py', ) files = {} for name in paths: @@ -85,7 +93,7 @@ jobs: with Path('build/native-contract/source.json').open('x') as stream: stream.write(json.dumps(record, sort_keys=True) + '\n') PY - - name: Check the same 91 inert Python cases in each matrix mode + - name: Check the same 98 inert Python cases in each matrix mode run: | set -euo pipefail ( @@ -93,7 +101,8 @@ jobs: timeout --kill-after=5s 120s \ env -i PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ python3 -B -m unittest -v test_llama_cpu test_conversion_diagnostic \ - test_native_output_constraint test_native_source_lifecycle test_native_session + test_native_output_constraint test_native_source_lifecycle test_native_session \ + test_native_cpu_alignment ) 2>&1 | tee build/native-contract/python-contracts.log python3 -B - <<'PY' import json, os, re @@ -101,11 +110,11 @@ jobs: text = Path('build/native-contract/python-contracts.log').read_text() cases = re.findall(r'^(test_[A-Za-z0-9_]+) \(([^)]+)\) \.\.\. ok$', text, re.M) names = [scope + '::' + method for method, scope in cases] - assert len(names) == len(set(names)) == 91 - assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['91'] + assert len(names) == len(set(names)) == 98 + assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['98'] assert re.findall(r'^OK.*$', text, re.M) == ['OK'] - record = dict(version=1, mode=os.environ['NATIVE_MODE'], passed=91, failed=0, - skipped=0, unique_tests_across_matrix=91, repeated_in_each_matrix_mode=True, + record = dict(version=1, mode=os.environ['NATIVE_MODE'], passed=98, failed=0, + skipped=0, unique_tests_across_matrix=98, repeated_in_each_matrix_mode=True, test_names=sorted(names), model_execution=False) with Path('build/native-contract/python-contracts.json').open('x') as stream: stream.write(json.dumps(record, sort_keys=True) + '\n') diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index f7fe00b59..696981a4c 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -20,8 +20,8 @@ The opt-in CPU backend uses [ggml-org/llama.cpp](https://github.com/ggml-org/lla at exact commit `7fe450e19305b828c199d602c23a8337aaa1f03b` (resolved release tag `v0.5.0`; MIT). The tag is unsigned. No prebuilt upstream executable, server, GPU backend or implicit artifact download is used. The original checkout remains -unchanged; the build applies the explicitly recorded loader-only patch below to -a fresh staged copy. The GPL-3.0-only adapter exposes a fixed owned C ABI and +unchanged; the build applies the explicitly recorded loader and CPU patches below +to fresh staged copies. The GPL-3.0-only adapter exposes a fixed owned C ABI and source-build flags. The builder preserves tracked upstream licenses/notices, including ggml and the Mozilla tinyBLAS notice, unchanged in the generated artifact. @@ -45,6 +45,21 @@ The builder verifies that the compilation database selects exactly the staged loader. It rejects changed source/patch bytes and retains the clean upstream checkout and original notices. This is a local patch, not an upstream release. +`workers/volparossa-ml/native-cpu/q1-aligned-load.patch` replaces one AVX2 +pointer-cast load in `ggml_vec_dot_q1_0_q8_0` with a 16-byte `memcpy` into an +aligned four-element `uint32_t` array. Arithmetic, quantization formats and the +upstream all-type test remain unchanged. The original MIT license is preserved. +Build-v4 provenance records this separate `cpu_source_overlay` and checks both +the selected translation unit and its compiler command against the staged file: + +- Original `ggml/src/ggml-cpu/arch/x86/quants.c`: `3c489fdc77e3ab484a5f188bff9c60c0e9483c951ed65c1a562a04c249d5628e`. +- Patch: `563a178667eeac17e7072c343d1d21a3f641d78c284acbeb2633db26e1c36369`. +- Effective staged CPU source: `b9a63f032ad048a688357194fa535d852959ffee0942c93d4b8fb9544febf6d2`. + +Historical builds omit this provenance pair and do not establish the correction. +The source-only normal and sanitizer builds must verify the new source before +its acceptance; no existing failed sanitizer result is replaced or suppressed. + The separate offline conversion candidate starts only from the existing pinned Apache-2.0 `Qwen/Qwen3-4B-Instruct-2507` revision `cdbee75f17c01a7cc42f958dc650907174af0554`. It compares every original BF16 tensor diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 38124dc9f..adc0d3454 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -42,6 +42,22 @@ Detailed earlier Android and storage checkpoints remain in the [pre-merge evidence ledger](https://github.com/VOLPAROSSA/volparossa/blob/2f3fa1179373b4e71ed42715bebff9bdc9c4a3ff/docs/IMPLEMENTATION_STATUS.md#android-to-core-integration-boundary). The latest completed outcomes are summarized below. +### Native alignment candidate + +The separate `fix/native-q1-aligned-load` candidate addresses the known upstream +Q1_0 sanitizer finding without changing the pinned source revision, BF16 model, +toolset or resource limits. It replaces one unaligned pointer-cast load with a +16-byte copy into aligned scalar storage. Fresh staging, exact source hashes and +the actual compiler-input/target checks bind this local patch; the original +upstream checkout and all-type test remain unchanged. + +All 98 inert Python cases pass without skips: seven new source/provenance cases +and the previous 91 cases. This does not establish native sanitizer acceptance +or model performance. Normal and ASan/UBSan hosted builds remain required; the +original failed checks remain failures. The eight-file wrapper inventory must +also be joined explicitly in Code before using this candidate in a model trial. +The running Code `ffc7c3ef` / core `95ef4541` trial is unchanged. + ### Latest completed integration evidence — 15 October - **The native diagnostic correction passes targeted source checks, not a model trial.** diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py index 7f18bb12b..ac1391528 100644 --- a/workers/volparossa-ml/build_llama_cpu.py +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -12,6 +12,7 @@ import os from pathlib import Path import re +import shlex import shutil import signal import stat @@ -29,7 +30,7 @@ CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", "vp-native-output-constraint", "vp-native-session", "vp-upstream-tensor-tests") WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch", - "output-constraint.h", "output-constraint.cpp", "session-state.h") + "output-constraint.h", "output-constraint.cpp", "session-state.h", "q1-aligned-load.patch") def require(condition, code): @@ -112,6 +113,66 @@ def verify_loader_compilation(output, loader): and digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_COMPILED_LOADER") +def patched_cpu(source): + """Copy Q1 bytes into aligned scalar storage; preserve all arithmetic.""" + original = source / native.CPU_ORIGINAL_PATH + require(digest(original)["sha256"] == native.CPU_ORIGINAL_SHA, "NATIVE_CPU_ORIGINAL") + patch_path = HERE / "native-cpu/q1-aligned-load.patch" + require(digest(patch_path)["sha256"] == native.CPU_PATCH_SHA, "NATIVE_CPU_PATCH") + before = original.read_text(encoding="utf-8") + old = " const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;\n" + new = " uint32_t qs32[4];\n memcpy(qs32, x[ib].qs, sizeof(qs32));\n" + require(before.count(old) == 1, "NATIVE_CPU_LOAD_SHAPE") + after = before.replace(old, new) + patch = "".join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), + fromfile="a/" + native.CPU_ORIGINAL_PATH, tofile="b/" + native.CPU_ORIGINAL_PATH)) + require(patch.encode() == patch_path.read_bytes(), "NATIVE_CPU_PATCH_SHAPE") + result = after.encode("utf-8") + require(hashlib.sha256(result).hexdigest() == native.CPU_EFFECTIVE_SHA, "NATIVE_CPU_EFFECTIVE") + return result + + +def stage_cpu(source, output): + data = patched_cpu(source) + overlay = output / "cpu-overlay" + overlay.mkdir(mode=0o700) + target = overlay / "quants.c" + with target.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + target.chmod(0o444) + return target + + +def verify_cpu_compilation(output, source): + path = output / "build/compile_commands.json" + require(digest(path)["bytes"] <= 16 * 1024 * 1024, "NATIVE_COMPILE_DATABASE_BOUND") + entries = json.loads(path.read_bytes()) + require(type(entries) is list and 1 <= len(entries) <= 4096, "NATIVE_COMPILE_DATABASE_SHAPE") + staged = output / "cpu-overlay/quants.c" + files = [Path(row["file"]).resolve() if Path(row["file"]).is_absolute() + else (Path(row["directory"]) / row["file"]).resolve() for row in entries] + require(files.count(staged.resolve()) == 1 and (source / native.CPU_ORIGINAL_PATH).resolve() not in files + and not staged.is_symlink() and digest(staged)["sha256"] == native.CPU_EFFECTIVE_SHA, + "NATIVE_COMPILED_CPU") + entry = entries[files.index(staged.resolve())] + require(type(entry.get("command")) is str, "NATIVE_COMPILED_CPU_TARGET") + command = shlex.split(entry["command"]) + require(command.count("-o") == 1 and command.index("-o") + 1 < len(command), + "NATIVE_COMPILED_CPU_TARGET") + target = Path(command[command.index("-o") + 1]).parts + require(any(target[i:i + 2] == ("CMakeFiles", "ggml-cpu.dir") for i in range(len(target) - 1)), + "NATIVE_COMPILED_CPU_TARGET") + require(command.count("-c") == 1 and command.index("-c") + 1 < len(command), + "NATIVE_COMPILED_CPU_COMMAND") + compiled = Path(command[command.index("-c") + 1]) + require(Path(entry["directory"]).is_absolute(), "NATIVE_COMPILED_CPU_COMMAND") + if not compiled.is_absolute(): + compiled = Path(entry["directory"]) / compiled + require(compiled.resolve() == staged.resolve(), "NATIVE_COMPILED_CPU_COMMAND") + + def stage_loader_checks(loader): """Source-derived toy check: actual launch/join statements, never a model.""" require(digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_LOADER_EFFECTIVE") @@ -167,17 +228,20 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(shutil.which(tool), "NATIVE_BUILD_TOOL_MISSING") require(not sanitize or checks, "NATIVE_SANITIZER_REQUIRES_CHECKS") patched_loader(source) + patched_cpu(source) plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, "output_constraints": [native.OUTPUT_CONSTRAINT], "native_sessions": [native.NATIVE_SESSION], "sanitizers": sanitize, "provisionable": not sanitize, "source_overlay": native.loader_provenance(), + "cpu_source_overlay": native.cpu_provenance(), "jobs": 2, "models_loaded": False, "downloads": False, "install": False} if not execute: return plan wrapper_sources = {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES} output.mkdir(mode=0o700) loader = stage_loader(source, output) + cpu = stage_cpu(source, output) if checks: stage_loader_checks(loader) started = time.monotonic() @@ -193,6 +257,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): ["cmake", "-S", str(HERE / "native-cpu"), "-B", str(output / "build"), "-DVP_LLAMA_SOURCE=" + str(source), "-DCMAKE_BUILD_TYPE=Release", "-DVP_LLAMA_MODEL_LOADER=" + str(loader), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DVP_LLAMA_CPU_QUANTS=" + str(cpu), "-DVP_NATIVE_TESTS=" + ("ON" if checks else "OFF"), "-DVP_NATIVE_SANITIZE=" + ("ON" if sanitize else "OFF")], ["cmake", "--build", str(output / "build"), "--target", "volparossa_llama_cpu", @@ -207,6 +272,7 @@ def build(source, output, execute=False, checks=False, sanitize=False): require(wrapper_sources == {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES}, "NATIVE_WRAPPER_CHANGED_DURING_BUILD") verify_loader_compilation(output, loader) + verify_cpu_compilation(output, source) candidates = list((output / "build").rglob(LIBRARY)) require(len(candidates) == 1, "NATIVE_LIBRARY_OUTPUT") library = output / LIBRARY @@ -255,9 +321,10 @@ def build(source, output, execute=False, checks=False, sanitize=False): (licenses / "GCC-runtime-copyright").chmod(0o444) report = dict(plan, version=4, abi_version=1, library={"path": LIBRARY, **digest(library)}, upstream_original_unchanged=True, loader_compile_source_verified=True, + cpu_compile_source_verified=True, dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, checks=list(CHECK_TARGETS) if checks else [], - known_upstream_sanitizer_failure={ + historical_unpatched_sanitizer_failure={ "scope": "all_type_synthetic_tensor_test", "kind": "ubsan_misaligned_load", "source": "ggml/src/ggml-cpu/arch/x86/quants.c:590", "function": "ggml_vec_dot_q1_0_q8_0", "original_log_bytes": 412, diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py index 96b54fcf6..669aefcb1 100644 --- a/workers/volparossa-ml/convert_llama_cpu.py +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -316,6 +316,8 @@ def check(): build = json.loads(build_raw) native.validate_build_provenance(build, builder.require) builder.verify_loader_compilation(build_root, build_root / "loader-overlay/llama-model-loader.cpp") + if "cpu_source_overlay" in build: + builder.verify_cpu_compilation(build_root, args.source) builder.require(build["source_commit"] == builder.SOURCE and build["kind"] == native.KIND and build["abi_version"] == 1 and build["quantization"] is False and build["provisionable"] is True diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py index 69a3503cc..c2c063ce3 100644 --- a/workers/volparossa-ml/llama_cpu.py +++ b/workers/volparossa-ml/llama_cpu.py @@ -21,6 +21,10 @@ LOADER_ORIGINAL_SHA = "efdb5f273bd1ab77301f2cadea1a5a804bafb28040aca033e45605a97a450242" LOADER_EFFECTIVE_SHA = "fcd26ea4ae21ea04954b275287c41a35e383015b736bbbe2e68f2ab5f6d095f9" LOADER_PATCH_SHA = "388fff7d5d9ca812abfeb27fddd1629144b7de6284e4bf685fbc8c147891e8c1" +CPU_ORIGINAL_PATH = "ggml/src/ggml-cpu/arch/x86/quants.c" +CPU_ORIGINAL_SHA = "3c489fdc77e3ab484a5f188bff9c60c0e9483c951ed65c1a562a04c249d5628e" +CPU_EFFECTIVE_SHA = "b9a63f032ad048a688357194fa535d852959ffee0942c93d4b8fb9544febf6d2" +CPU_PATCH_SHA = "563a178667eeac17e7072c343d1d21a3f641d78c284acbeb2633db26e1c36369" PROFILE = "qwen3-4b-instruct-2507-v1" LIBRARY = "libvolparossa_llama_cpu.so" WEIGHTS_SHA = "79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf" @@ -72,6 +76,27 @@ def validate_build_provenance(value, require): and overlay["complete_tensor_validation"] is True and overlay["owner_poll_between_tensors"] is True and value.get("loader_compile_source_verified") is True and value.get("upstream_original_unchanged") is True, "NATIVE_BUILD_PATCH_BINDING") + validate_cpu_provenance(value, require) + + +def cpu_provenance(): + return dict(kind="q1_avx2_aligned_load_v1", original_path=CPU_ORIGINAL_PATH, + original_sha256=CPU_ORIGINAL_SHA, effective_sha256=CPU_EFFECTIVE_SHA, + patch_path="native-cpu/q1-aligned-load.patch", patch_sha256=CPU_PATCH_SHA, + compiled_source="cpu-overlay/quants.c") + + +def validate_cpu_provenance(value, require): + # An additive source correction, not a new model/generation capability. + # Historical builds omit both fields and retain their unresolved Q1 finding. + present = {key for key in ("cpu_source_overlay", "cpu_compile_source_verified") if key in value} + if not present: + return + require(present == {"cpu_source_overlay", "cpu_compile_source_verified"} + and type(value.get("version")) is int and value["version"] == 4 + and type(value.get("cpu_source_overlay")) is dict + and value["cpu_source_overlay"] == cpu_provenance() + and value.get("cpu_compile_source_verified") is True, "NATIVE_CPU_PATCH_BINDING") def validate_manifest(value, require): diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt index 251be5463..4b2eb2fa9 100644 --- a/workers/volparossa-ml/native-cpu/CMakeLists.txt +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -59,6 +59,35 @@ endif() set_property(TARGET llama PROPERTY SOURCES "${effective_sources}") set_source_files_properties("${VP_LLAMA_MODEL_LOADER}" TARGET_DIRECTORY llama PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) +# The only CPU-source change is the reviewed unaligned Q1 scalar load. Keep +# generic quants.c and every other translation unit at their original source. +if(NOT IS_ABSOLUTE "${VP_LLAMA_CPU_QUANTS}" OR NOT EXISTS "${VP_LLAMA_CPU_QUANTS}") + message(FATAL_ERROR "The verified staged x86 CPU source is required") +endif() +get_target_property(cpu_source_dir ggml-cpu SOURCE_DIR) +get_target_property(cpu_sources ggml-cpu SOURCES) +set(effective_cpu_sources) +set(cpu_replacements 0) +foreach(source IN LISTS cpu_sources) + if(NOT IS_ABSOLUTE "${source}") + get_filename_component(source "${source}" ABSOLUTE BASE_DIR "${cpu_source_dir}") + endif() + if(source STREQUAL "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86/quants.c") + list(APPEND effective_cpu_sources "${VP_LLAMA_CPU_QUANTS}") + math(EXPR cpu_replacements "${cpu_replacements} + 1") + else() + list(APPEND effective_cpu_sources "${source}") + endif() +endforeach() +if(NOT cpu_replacements EQUAL 1) + message(FATAL_ERROR "Expected exactly one original x86 CPU quants source") +endif() +set_property(TARGET ggml-cpu PROPERTY SOURCES "${effective_cpu_sources}") +set_source_files_properties("${VP_LLAMA_CPU_QUANTS}" TARGET_DIRECTORY ggml-cpu + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) +# Resolve the original quoted simd-mappings.h and ../../ headers without +# copying, patching or guessing any upstream header bytes. +target_include_directories(ggml-cpu PRIVATE "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86") add_library(volparossa_llama_cpu SHARED adapter.cpp output-constraint.cpp) target_include_directories(volparossa_llama_cpu PRIVATE "${VP_LLAMA_SOURCE}/src") target_link_libraries(volparossa_llama_cpu PRIVATE llama) diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md index a58881291..4eb4a4678 100644 --- a/workers/volparossa-ml/native-cpu/README.md +++ b/workers/volparossa-ml/native-cpu/README.md @@ -60,7 +60,7 @@ default; `--execute` builds at low priority with at most two compiler jobs into a new output directory. `build.json` retains the source/tree, wrapper hashes, compiler versions, actual library hash, CPU requirements and dependency list. Original upstream notices are copied unchanged. -Build manifest v2 also binds the explicitly recorded loader-only patch, original +Build manifest v2 also binds the explicitly recorded loader patch, original and effective loader digests, and actual staged-source compilation. The checkout stays pristine; old v1 build manifests cannot masquerade as the patched backend. See [third-party provenance](../../../THIRD_PARTY_LICENSES.md#explicit-llamacpp-cpu-inference-candidate). @@ -99,8 +99,23 @@ load in upstream `ggml/src/ggml-cpu/arch/x86/quants.c:590`, `ggml_vec_dot_q1_0_q8_0`. Its retained 412-byte log has SHA-256 `37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08`. Q1_0/Q8_0 model tensors are not admitted by this BF16/F32 conversion contract, -but the finding remains unresolved and is recorded in normal build provenance. -The full sanitizer suite must not be described as passing. +and the historical failure remains in build provenance. + +The separate alignment candidate stages a second exact source patch: one AVX2 +pointer-cast load becomes a 16-byte `memcpy` into an aligned `uint32_t[4]`. +It changes no arithmetic or accepted model format. CMake substitutes only the +x86 CPU translation unit; the build and converter verify its digest, selected +compile-database entry, compiler input and `ggml-cpu` object target. The upstream +checkout, headers and all-type test remain unchanged. + +Build v4 adds the atomic `cpu_source_overlay` / `cpu_compile_source_verified` +pair for this correction. Historical manifests omitting both fields retain their +old meaning; they are not evidence that the alignment issue is corrected. +The new wrapper inventory has eight files, including the patch, so downstream +Code provenance must be explicitly joined before another paired model trial. +Seven inert alignment/provenance cases and the existing 91 Python cases pass; +the new normal and complete ASan/UBSan source builds remain required. The full +sanitizer suite must not yet be described as passing. A separate owned wrapper reused the original upstream BF16 conversion, reference and dot-product functions and tolerances, plus its F32 vector cases. diff --git a/workers/volparossa-ml/native-cpu/q1-aligned-load.patch b/workers/volparossa-ml/native-cpu/q1-aligned-load.patch new file mode 100644 index 000000000..dad83555c --- /dev/null +++ b/workers/volparossa-ml/native-cpu/q1-aligned-load.patch @@ -0,0 +1,12 @@ +--- a/ggml/src/ggml-cpu/arch/x86/quants.c ++++ b/ggml/src/ggml-cpu/arch/x86/quants.c +@@ -580,7 +580,8 @@ + + for (int ib = 0; ib < nb; ++ib) { + const float d0 = GGML_CPU_FP16_TO_FP32(x[ib].d); +- const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs; ++ uint32_t qs32[4]; ++ memcpy(qs32, x[ib].qs, sizeof(qs32)); + const block_q8_0 * GGML_RESTRICT y_ptr = &y[ib * 4]; + + __m256 acc_block; diff --git a/workers/volparossa-ml/test_native_cpu_alignment.py b/workers/volparossa-ml/test_native_cpu_alignment.py new file mode 100644 index 000000000..3abd300ad --- /dev/null +++ b/workers/volparossa-ml/test_native_cpu_alignment.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert source/provenance checks. No compiler, model, downloads or native load.""" +import copy +import difflib +import hashlib +import json +from pathlib import Path +import stat +import tempfile +import unittest +from unittest import mock + +import build_llama_cpu as builder +import llama_cpu as native + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def require(condition, code): + if not condition: + raise ValueError(code) + + +class AlignmentTests(unittest.TestCase): + def test_patch_contains_only_the_aligned_copy(self): + raw = (builder.HERE / "native-cpu/q1-aligned-load.patch").read_bytes() + self.assertEqual(sha(raw), native.CPU_PATCH_SHA) + lines = raw.decode().splitlines() + self.assertEqual([line for line in lines if line.startswith('-') and not line.startswith('---')], + ['- const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;']) + self.assertEqual([line for line in lines if line.startswith('+') and not line.startswith('+++')], + ['+ uint32_t qs32[4];', '+ memcpy(qs32, x[ib].qs, sizeof(qs32));']) + + def test_historical_absence_is_not_patch_evidence(self): + for version in (2, 3, 4): + value = dict(version=version) + native.validate_cpu_provenance(value, require) + self.assertEqual(value, dict(version=version)) + native.validate_cpu_provenance(dict(version=4, cpu_source_overlay=native.cpu_provenance(), + cpu_compile_source_verified=True), require) + + def test_provenance_is_an_atomic_exact_pair(self): + good = dict(version=4, cpu_source_overlay=native.cpu_provenance(), cpu_compile_source_verified=True) + invalid = [dict(version=4, cpu_source_overlay=native.cpu_provenance()), + dict(version=4, cpu_compile_source_verified=True)] + for version in (2, 3, 5, True, '4'): + invalid.append(dict(good, version=version)) + for verified in (False, 1, None, 'true'): + invalid.append(dict(good, cpu_compile_source_verified=verified)) + for key in native.cpu_provenance(): + bad = copy.deepcopy(good) + bad['cpu_source_overlay'][key] = 'unreviewed' + invalid.append(bad) + bad = copy.deepcopy(good) + del bad['cpu_source_overlay'][key] + invalid.append(bad) + bad = copy.deepcopy(good) + bad['cpu_source_overlay']['extra'] = True + invalid.append(bad) + for bad in invalid: + with self.subTest(bad=bad), self.assertRaisesRegex(ValueError, 'NATIVE_CPU_PATCH_BINDING'): + native.validate_cpu_provenance(bad, require) + + def test_exact_transformation_preserves_the_original_and_rejects_tampering(self): + before = b'prefix\n const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;\nsuffix\n' + after = b'prefix\n uint32_t qs32[4];\n memcpy(qs32, x[ib].qs, sizeof(qs32));\nsuffix\n' + patch = ''.join(difflib.unified_diff(before.decode().splitlines(True), after.decode().splitlines(True), + fromfile='a/' + native.CPU_ORIGINAL_PATH, tofile='b/' + native.CPU_ORIGINAL_PATH)).encode() + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / 'source' + original = source / native.CPU_ORIGINAL_PATH + original.parent.mkdir(parents=True) + original.write_bytes(before) + patch_path = root / 'wrapper/native-cpu/q1-aligned-load.patch' + patch_path.parent.mkdir(parents=True) + patch_path.write_bytes(patch) + with mock.patch.object(builder, 'HERE', root / 'wrapper'), \ + mock.patch.object(native, 'CPU_ORIGINAL_SHA', sha(before)), \ + mock.patch.object(native, 'CPU_EFFECTIVE_SHA', sha(after)), \ + mock.patch.object(native, 'CPU_PATCH_SHA', sha(patch)): + self.assertEqual(builder.patched_cpu(source), after) + self.assertEqual(original.read_bytes(), before) + original.write_bytes(before + b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_CPU_ORIGINAL'): + builder.patched_cpu(source) + original.write_bytes(before) + patch_path.write_bytes(patch + b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_CPU_PATCH'): + builder.patched_cpu(source) + patch_path.write_bytes(patch) + with mock.patch.object(native, 'CPU_EFFECTIVE_SHA', '0' * 64), \ + self.assertRaisesRegex(ValueError, 'NATIVE_CPU_EFFECTIVE'): + builder.patched_cpu(source) + + def test_staging_is_fresh_read_only_and_never_replaces_source(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + with mock.patch.object(builder, 'patched_cpu', return_value=b'inert reviewed source'): + staged = builder.stage_cpu(root / 'original', root) + self.assertEqual(staged.read_bytes(), b'inert reviewed source') + self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o444) + self.assertEqual(stat.S_IMODE(staged.parent.stat().st_mode), 0o700) + with self.assertRaises(FileExistsError): + builder.stage_cpu(root / 'original', root) + self.assertEqual(staged.read_bytes(), b'inert reviewed source') + + def test_compilation_binds_only_the_staged_x86_unit_and_cpu_target(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / 'original' + output = root / 'output' + staged = output / 'cpu-overlay/quants.c' + staged.parent.mkdir(parents=True) + staged.write_bytes(b'reviewed CPU source') + database = output / 'build/compile_commands.json' + database.parent.mkdir() + row = dict(directory=str(database.parent), file=str(staged), + command='cc -o CMakeFiles/ggml-cpu.dir/cpu-overlay/quants.c.o -c ' + str(staged)) + generic = dict(directory=str(database.parent), file=str(source / 'ggml/src/ggml-cpu/quants.c'), + command='cc -o CMakeFiles/ggml-cpu.dir/generic.o -c generic.c') + with mock.patch.object(native, 'CPU_EFFECTIVE_SHA', sha(staged.read_bytes())): + database.write_text(json.dumps([row, generic])) + builder.verify_cpu_compilation(output, source) + relative = dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c ../cpu-overlay/quants.c') + database.write_text(json.dumps([relative, generic])) + builder.verify_cpu_compilation(output, source) + bad_rows = [[], [generic], [row, row], + [row, dict(generic, file=str(source / native.CPU_ORIGINAL_PATH))], + [dict(row, command='cc -o CMakeFiles/other.dir/quants.c.o -c ignored')], + [dict(row, command='cc -c ignored')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c ' + str(source / native.CPU_ORIGINAL_PATH))], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c different.c')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c')], + [dict(row, command=row['command'] + ' -c ' + str(staged))]] + for rows in bad_rows: + database.write_text(json.dumps(rows)) + with self.subTest(rows=rows), self.assertRaisesRegex(ValueError, 'NATIVE_'): + builder.verify_cpu_compilation(output, source) + database.write_text(json.dumps([row])) + staged.write_bytes(b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_COMPILED_CPU'): + builder.verify_cpu_compilation(output, source) + + def test_cmake_preserves_other_units_and_uses_original_private_headers(self): + text = (builder.HERE / 'native-cpu/CMakeLists.txt').read_text() + self.assertIn('if(source STREQUAL "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86/quants.c")', text) + self.assertIn('if(NOT cpu_replacements EQUAL 1)', text) + self.assertIn('list(APPEND effective_cpu_sources "${source}")', text) + self.assertIn('target_include_directories(ggml-cpu PRIVATE "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86")', text) + self.assertIn('q1-aligned-load.patch', builder.WRAPPER_FILES) + + +if __name__ == '__main__': + unittest.main() From ded03e5f6b0eea87b29c22d6c1a84a6bfe03a695 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:00:07 +0200 Subject: [PATCH 38/39] docs: record real coding task and storage trial boundaries --- docs/IMPLEMENTATION_STATUS.md | 87 +++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 41ab9ca4e..b9e31313e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -33,9 +33,9 @@ The immediate milestones build on the retained candidates and evidence below: | --- | --- | --- | | Integrated cache and Weather delivery — `volparossa`, `volparossa-weather` | Preserve the real provider sequence and both joined web-client proofs. | Core PR227, PR229 and PR230, and Weather PR14–PR18 are merged. Drizz.li run `38228710535` and Overmorrow run `38241212407` pass actual acquisition, separate protected cold transfers and exact initial/refresh UI checks. Independent reconstruction, Quality and CodeQL pass. Automatic acquisition, browser-initiated cold retrieval and the complete weather service remain open. | | Native Overmorrow — `volparossa`, `volparossa-weather` | Preserve actual app-triggered core delivery and verify expiry behavior with real products. | Actual `38717988016` passes on exact core `aac5be7f` / Weather `3b90442a`: initial cold peer retrieval, two cache-backed refreshes, joined native tables, background/resume and cleanup. Source Quality `38714089083` and CodeQL `38714082172` pass. Actual expiry, physical devices, iOS and the complete service remain open. | -| Persistent private storage maintenance — `volparossa`, then Image/Cloud | Complete consumer publication, repair and restoration after provider loss. | Actual `38766434111` fails during Image FINALIZE with `stream_open / flow_acquire`; the inner cause was not retained. Source run `38799870413` passes all 167 targeted Rust cases, strict Clippy and formatting; full Quality `38800337179` also passes on consumer profile 9 source `fc75a42d`. Actual trial `38804192575` now tests these pinned Image/Cloud diagnostics; its result is outstanding. The previous Cloud-catalog failure remains unresolved; combined repair, restoration, remote cleanup and reciprocal capacity are not yet established. | +| Persistent private storage maintenance — `volparossa`, then Image/Cloud | Complete consumer publication, repair and restoration after provider loss. | Targeted source run `38799870413` passes on `0e17aff5`; full Quality `38800337179` passes on profile 9 source `fc75a42d`. Actual `38804192575` reaches Cloud FINALIZE after Image publication, then fails at flow acquisition: first `helper_kernel`, then `helper_cleanup_incomplete`. The exact kernel or cleanup suboperation is unknown. A separate listener-rejection correction is under targeted verification, not established as this failure's cause. Combined repair, restoration, remote cleanup and reciprocal capacity remain open. | | Automatic private uploads — `volparossa`, Image/Cloud | Enroll a new encrypted upload under finite service authority, then resume maintenance and restore it after provider loss. | Core PR233 adds an absolute authorization ceiling; Image PR7 forwards it without rebasing. Cloud PR14 implements finite shared admission budgets, durable reservation/enrollment, restart reconciliation and an encrypted empty catalog for a first upload. Actual OpenCloud SDK upload/list/read checks pass with a synthetic core; this does not prove peer maintenance. Join these candidates to the real upload-to-repair-to-restore trial after the existing storage milestone; its pinned consumers stay unchanged. | -| Native coding execution — `volparossa`, `volparossa-code` | Complete an actual OpenCode read, edit and independently verified test through the core. | Actual `38797816570` completes three function-call responses and two reads, but rejects the forwarded edit permission. Code PR17 corrects the native edit-target binding after source acceptance; actual `38806073909` now tests exact Code `cce5a211` / core `6d65bb12`, with its result outstanding. The core candidate is being joined to current `main`; that integration still needs its own source acceptance. The ordinary toolset, 4B/BF16 model, workspace approval and resource limits are unchanged. Protected peer execution remains open. | +| Native coding execution — `volparossa`, `volparossa-code` | Complete the post-task resource checks around the successful real read/edit/test task, then integrate the accepted native candidate. | Actual `38806073909` on Code `cce5a211` / core `6d65bb12` completes two reads, an approved edit, a test command and independent verification. The outer trial fails afterward; task memory and subsequent input/core-stop checks are absent. A bounded task-holder correction is being developed to preserve final cgroup observation. Joined core candidate `985774f9` passes its normal/sanitizer native suite and CodeQL; full Quality remains outstanding. The ordinary toolset, 4B/BF16 model, approvals and resource limits remain unchanged. Protected peer execution remains open. | | Protected remote compute — `volparossa`, then Code/Browser | Join an authorized immutable guest, the resident coding worker and a guest-terminated TLS 1.3 session. | PR258's authenticated refusal path has actual route evidence; PR262 integrates explicit same-connection key confirmation. The next implementation reuses the native worker lifecycle and existing TLS stack, rather than extending the probe into task authority. Boot/runtime/model integrity, production roots and current vendor appraisal must be checked before private input; task authority needs its own finite grant. Those functions and actual protected execution remain open; suitable confidential-computing hardware is unavailable. | Detailed earlier Android and storage checkpoints remain in the @@ -44,7 +44,75 @@ The latest completed outcomes are summarized below. ### Latest completed integration evidence — 15 October -- **The native edit-target correction passes source checks; its actual trial remains open.** +- **A real coding task succeeds; its outer postflight still fails.** + [Trial `38806073909`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38806073909) + uses exact Code `cce5a211a0624fdcc05a787bd4ff31422b30ae65` and core + `6d65bb12ed7b8720d43868c9306313133a5b96ae`. Its original task receipt records + five completed core requests: four function-call responses and a final answer. + Two reads, one edit and one test command complete; both forwarded permissions + are accepted. The fixture changes, the original test remains unchanged and + independent verification passes. No canned model answers or synthetic core are used. + + The task exits successfully after 2,236,512 ms, but the enclosing guest reports + `stage_failed`. Its task-memory receipt and later `inputs_unchanged` and + `core_clean_stop` checks are absent. Source review identifies a post-exit + cgroup-observation vulnerability; the original generic exception does not + establish which read failed. The correction must preserve the original task + status and collect real counters before releasing the last task-cgroup owner, + without extending the existing deadline or treating missing values as zero. + + All five core requests confirm session cleanup. Task runtime/project cleanup, + guest process cleanup, QEMU/observer joins, scratch removal and restoration of + CI changes pass. Routes/DNS match under the existing normalization, not raw + IPv6 equality. This establishes the bounded coding task, not complete trial + acceptance, general coding quality or private peer execution. Original artifact + and log ZIP SHA-256 values are respectively + `8c31054298ef59f0a50c360d4f772afaa010b3c66144e4af85013f49d8565925` + and `5e21e3f3ec2b6310c58079e81eacb1b35a2cf06858650afa8176463196804d30`. + +- **Storage reaches Cloud publication and retains a narrower flow failure.** + [Actual `38804192575`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38804192575) + fails on exact core `fc75a42d0c458472dc3aeee28311b4e9003803e9`, with unchanged + Image `9694a2b9` / Cloud `47b0f03b` consumers. Cloud FINALIZE fragment 0/copy 0 + reports `stream_open / flow_acquire`, with two acquisition rounds: first + `helper_kernel`, last `helper_cleanup_incomplete`. No specific errno or helper + suboperation is retained. A kernel-category result can also represent parent + descriptor rejection; it does not uniquely identify a failed kernel call. + + The source-bound Cloud stage establishes that Image creation, deposit, policy + and enrollment checks, and local core close were reached successfully first; + it is not a separate Image repair/restore acceptance receipt. The original base + lifecycle components still prove restart/resume, two non-consuming restores, + removal of all eleven copies and zero provider usage for that base fixture. + Consumer-local cleanup and guest-owned network cleanup pass, but remote + consumer cleanup and usage are explicitly unconfirmed. The complete consumer + maintenance trial remains failed. Original artifact and log ZIP SHA-256 values: + `7fb60d72ba2d9efc18582d259697cd9c7e46c80c8d6892119182f4fef0750114` + and `75f8be9d4e6c622801ed80d04d63a43aa0af4d70731ce7a212f97386f5c44260`. + + Separate source review identifies an availability defect: rejection of one + newly accepted stream ends the reusable exit listener. Candidate `74f0b16a` + distinguishes a closed rejected stream from a listener failure while retaining + fail-closed MPTCP validation, cancellation and failure reporting. Targeted run + `38813474802` is outstanding. Its predecessor `38813245298` fails only two + formatting checks before compilation; no tests ran there. Neither source review + nor this candidate establishes the cause of the original storage failure. + +- **The native candidate joined to current main passes native source checks.** + [Run `38811039580`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38811039580) + checks merge `985774f9fbbec377f90d986fc74ff55ea3adc244`, tree + `887840a5a8ad3fa5d2fae6259969d04d621420fb`, in draft PR212. Each of normal and + ASan/UBSan modes passes the same 98 unique Python cases without skips and all + six native targets, including the complete 35-type upstream tensor suite. + Twelve source records, eight wrappers and the actual patched CPU compile input + match Git. All four CodeQL analyses in `38811040412` pass; their build-free + analysis is not compilation evidence. Full Quality `38811047918` remains + outstanding; this candidate is not merged into main. The separate original coding + trial remains pinned to `6d65bb12`. Normal and sanitizer artifact ZIP SHA-256: + `3ccc0565d589015ff0866afd569cab739fa1b2f017b16d42197a2c24661f3cbd` + and `19bad8cb5240fc35eb4193fbda9215e6c94a73b45faa731bd83a934b00b809bd`. + +- **The native edit-target correction passes source checks.** [Code PR17](https://github.com/VOLPAROSSA/volparossa-code/pull/17) merges as `cce5a211a0624fdcc05a787bd4ff31422b30ae65` into the existing native candidate, not application `main`. It normalizes an edit permission only when its single @@ -60,9 +128,9 @@ The latest completed outcomes are summarized below. `b6a011e3ff6215019f9482e30dcae36d11677f35bf6dcb9feeeda6bb304fcdaa`. [Actual trial `38806073909`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38806073909) uses that Code revision and core `6d65bb12`, including the source-verified - eight-file wrapper inventory and Q1 correction. This trial has no accepted - outcome yet. Joining the core candidate to `main` does not alter its pinned - sources or turn earlier failures into successes. + eight-file wrapper inventory and Q1 correction. Its bounded task success and + failed outer postflight are recorded above. Joining the core candidate to + `main` does not alter its pinned sources or turn earlier failures into successes. - **The native diagnostic correction passes targeted source checks, not a model trial.** [Run `38779519182`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38779519182) @@ -105,7 +173,7 @@ The latest completed outcomes are summarized below. `78195a02e2170f0ac0709c2f07dffef2bb8f672454df1769d56e55d562e61e93` and `7e95442ded158971e860d07e2dcd7ef90aebc223c4f65d8e23884d453ccee949`. -- **The latest coding trial reaches the edit-permission boundary.** +- **The preceding coding trial reaches the edit-permission boundary.** [Trial `38797816570`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38797816570) fails on exact Code `ffc7c3ef88aafd8ffc2c5652dbae5cf598ef9390` and core `95ef4541e518f9f9050dedc5d24bd4176b70db71`. All three submitted core requests @@ -215,8 +283,9 @@ The latest completed outcomes are summarized below. `10f6d39bfcc3162ee64d60204941e9ca125fa46fa174ae41fc342e6ddb6dcbaf`. These source/component checks do not prove that the underlying acquisition or Cloud-catalog failures are fixed. [Actual trial `38804192575`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38804192575) - has been dispatched once on the same `fc75a42d` source with the existing - `private-storage-maintenance` scenario; its acceptance remains outstanding. + ran once on the same `fc75a42d` source with the existing + `private-storage-maintenance` scenario and failed during Cloud publication, + with the original failure and cleanup boundaries recorded above. - **Same-connection key confirmation passes targeted source checks.** [Run `38781307814`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38781307814) From a410b0154032476a8544a0cbdc8d37ed0b3734b5 Mon Sep 17 00:00:00 2001 From: Erik Le Blansch <58982453+erikleblansch@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:12:11 +0200 Subject: [PATCH 39/39] docs: record joined native checks and listener trial outcome --- docs/IMPLEMENTATION_STATUS.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b9e31313e..3bfec839e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -12,7 +12,7 @@ detailed evidence ledger, including historical failures—not an installation gu [Original v1 scorecard](#fixed-alpha-v1-scorecard) · [Completion requirements](#definition-of-done) -Last updated: 2026-10-15 +Last updated: 2026-10-16 ## Current integration and active work @@ -35,14 +35,14 @@ The immediate milestones build on the retained candidates and evidence below: | Native Overmorrow — `volparossa`, `volparossa-weather` | Preserve actual app-triggered core delivery and verify expiry behavior with real products. | Actual `38717988016` passes on exact core `aac5be7f` / Weather `3b90442a`: initial cold peer retrieval, two cache-backed refreshes, joined native tables, background/resume and cleanup. Source Quality `38714089083` and CodeQL `38714082172` pass. Actual expiry, physical devices, iOS and the complete service remain open. | | Persistent private storage maintenance — `volparossa`, then Image/Cloud | Complete consumer publication, repair and restoration after provider loss. | Targeted source run `38799870413` passes on `0e17aff5`; full Quality `38800337179` passes on profile 9 source `fc75a42d`. Actual `38804192575` reaches Cloud FINALIZE after Image publication, then fails at flow acquisition: first `helper_kernel`, then `helper_cleanup_incomplete`. The exact kernel or cleanup suboperation is unknown. A separate listener-rejection correction is under targeted verification, not established as this failure's cause. Combined repair, restoration, remote cleanup and reciprocal capacity remain open. | | Automatic private uploads — `volparossa`, Image/Cloud | Enroll a new encrypted upload under finite service authority, then resume maintenance and restore it after provider loss. | Core PR233 adds an absolute authorization ceiling; Image PR7 forwards it without rebasing. Cloud PR14 implements finite shared admission budgets, durable reservation/enrollment, restart reconciliation and an encrypted empty catalog for a first upload. Actual OpenCloud SDK upload/list/read checks pass with a synthetic core; this does not prove peer maintenance. Join these candidates to the real upload-to-repair-to-restore trial after the existing storage milestone; its pinned consumers stay unchanged. | -| Native coding execution — `volparossa`, `volparossa-code` | Complete the post-task resource checks around the successful real read/edit/test task, then integrate the accepted native candidate. | Actual `38806073909` on Code `cce5a211` / core `6d65bb12` completes two reads, an approved edit, a test command and independent verification. The outer trial fails afterward; task memory and subsequent input/core-stop checks are absent. A bounded task-holder correction is being developed to preserve final cgroup observation. Joined core candidate `985774f9` passes its normal/sanitizer native suite and CodeQL; full Quality remains outstanding. The ordinary toolset, 4B/BF16 model, approvals and resource limits remain unchanged. Protected peer execution remains open. | +| Native coding execution — `volparossa`, `volparossa-code` | Complete the post-task resource checks around the successful real read/edit/test task, then integrate the accepted native candidate. | Actual `38806073909` on Code `cce5a211` / core `6d65bb12` completes two reads, an approved edit, a test command and independent verification. The outer trial fails afterward; task memory and subsequent input/core-stop checks are absent. A bounded task-holder correction is under source review to preserve final cgroup observation. Joined core candidate `985774f9` passes its normal/sanitizer native suite, CodeQL and full Quality. The ordinary toolset, 4B/BF16 model, approvals and resource limits remain unchanged. Protected peer execution remains open. | | Protected remote compute — `volparossa`, then Code/Browser | Join an authorized immutable guest, the resident coding worker and a guest-terminated TLS 1.3 session. | PR258's authenticated refusal path has actual route evidence; PR262 integrates explicit same-connection key confirmation. The next implementation reuses the native worker lifecycle and existing TLS stack, rather than extending the probe into task authority. Boot/runtime/model integrity, production roots and current vendor appraisal must be checked before private input; task authority needs its own finite grant. Those functions and actual protected execution remain open; suitable confidential-computing hardware is unavailable. | Detailed earlier Android and storage checkpoints remain in the [pre-merge evidence ledger](https://github.com/VOLPAROSSA/volparossa/blob/2f3fa1179373b4e71ed42715bebff9bdc9c4a3ff/docs/IMPLEMENTATION_STATUS.md#android-to-core-integration-boundary). The latest completed outcomes are summarized below. -### Latest completed integration evidence — 15 October +### Latest completed integration evidence — 15–16 October - **A real coding task succeeds; its outer postflight still fails.** [Trial `38806073909`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38806073909) @@ -94,9 +94,16 @@ The latest completed outcomes are summarized below. newly accepted stream ends the reusable exit listener. Candidate `74f0b16a` distinguishes a closed rejected stream from a listener failure while retaining fail-closed MPTCP validation, cancellation and failure reporting. Targeted run - `38813474802` is outstanding. Its predecessor `38813245298` fails only two - formatting checks before compilation; no tests ran there. Neither source review - nor this candidate establishes the cause of the original storage failure. + [`38813474802`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38813474802) + passes all 17 unique tests, including the real kernel-listener test in a + disposable network namespace, plus formatting and strict Clippy. Its final + host comparison fails: routes/DNS match, but raw firewall-dump hashes differ. + Those dumps contain volatile timestamps and traffic counters; the original + hashes cannot establish whether configuration also changed. The run remains + failed. A workflow-only correction tests narrowly normalized configuration; + it does not change the Rust candidate. The predecessor `38813245298` fails only + two formatting checks before compilation. Neither targeted run establishes the + cause of the original storage failure or complete storage acceptance. - **The native candidate joined to current main passes native source checks.** [Run `38811039580`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38811039580) @@ -106,11 +113,18 @@ The latest completed outcomes are summarized below. six native targets, including the complete 35-type upstream tensor suite. Twelve source records, eight wrappers and the actual patched CPU compile input match Git. All four CodeQL analyses in `38811040412` pass; their build-free - analysis is not compilation evidence. Full Quality `38811047918` remains - outstanding; this candidate is not merged into main. The separate original coding - trial remains pinned to `6d65bb12`. Normal and sanitizer artifact ZIP SHA-256: + analysis is not compilation evidence. Full Quality + [`38811047918`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38811047918) + passes on a merge checkout with the identical tree: 3,830 unique workspace + Rust tests pass, with seven ignored. Formatter, strict Clippy, dependency and + license checks, and the non-mutating harness pass. Three live namespace proofs + and one optional CLI case explicitly skip; they are not runtime evidence. + This candidate is not merged into main. The separate original coding trial + remains pinned to `6d65bb12`. Normal and sanitizer artifact ZIP SHA-256: `3ccc0565d589015ff0866afd569cab739fa1b2f017b16d42197a2c24661f3cbd` and `19bad8cb5240fc35eb4193fbda9215e6c94a73b45faa731bd83a934b00b809bd`. + Original Quality log ZIP SHA-256: + `c2f28db51232f608e9531555086efdb821940c41c5b923d644e85ddb2bff7f1d`. - **The native edit-target correction passes source checks.** [Code PR17](https://github.com/VOLPAROSSA/volparossa-code/pull/17) merges as