From 740e827227a47cf593504a9555faf054b15c2eeb Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 12:54:11 +0200 Subject: [PATCH 01/11] refactor(contracts): env_key moves to aprender-contracts-key; build.rs stops build-depending on the engine (Refs #5909) Refs #5912 PMAT-Ticket: PMAT-5909 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt (cherry picked from commit 413e53b49855fb7dfec892abaef2bced466b3c7b) (cherry picked from commit 733cbcb80296230f34bfdafd4981c3784039e978) (cherry picked from commit 89bb4c83f1ec1d382c460fff046c76e88370117b) (cherry picked from commit f3caf02f3c6b509c6003a84ebe50ecbb608a4cc7) (cherry picked from commit 0c2cc8aaeacfedaff9792581861d80ebff1a0576) (cherry picked from commit 47f8cf72b93398e28b648dba244d664e57cbaf22) (cherry picked from commit 370cdd33148bd9c4aecb1cb461aa03b2f6582e92) (cherry picked from commit 19e72f94990d92bcacdb0c1b9a3034817df60d04) (cherry picked from commit 96e36e154c579162ede0b66c67353dcab9d9c61f) (cherry picked from commit ed9be88deb518bf5e3e3eb36dc1b561d98b2138f) --- Cargo.lock | 19 ++++++++++++++----- Cargo.toml | 2 ++ crates/aprender-compute/Cargo.toml | 2 +- crates/aprender-compute/build.rs | 2 +- crates/aprender-contracts-key/Cargo.toml | 14 ++++++++++++++ crates/aprender-contracts-key/README.md | 14 ++++++++++++++ crates/aprender-contracts-key/src/lib.rs | 15 +++++++++++++++ crates/aprender-contracts/Cargo.toml | 2 ++ crates/aprender-contracts/build.rs | 4 ++-- crates/aprender-contracts/src/env_key.rs | 19 +++---------------- crates/aprender-core/Cargo.toml | 2 +- crates/aprender-core/build.rs | 2 +- crates/aprender-db/Cargo.toml | 2 +- crates/aprender-db/build.rs | 6 ++---- crates/aprender-orchestrate/Cargo.toml | 2 +- crates/aprender-orchestrate/build.rs | 8 +++----- crates/aprender-present-cli/Cargo.toml | 2 +- crates/aprender-present-cli/build.rs | 6 ++---- crates/aprender-present-core/Cargo.toml | 2 +- crates/aprender-present-core/build.rs | 4 ++-- crates/aprender-rag/Cargo.toml | 2 +- crates/aprender-rag/build.rs | 6 ++---- crates/aprender-simulate/Cargo.toml | 2 +- crates/aprender-simulate/build.rs | 8 +++----- crates/aprender-train/Cargo.toml | 2 +- crates/aprender-train/build.rs | 4 ++-- crates/aprender-viz-ttop/Cargo.toml | 2 +- crates/aprender-viz-ttop/build.rs | 2 +- crates/facades/Cargo.lock | 5 +++++ scripts/cascade-publish.sh | 2 +- scripts/release/publish-order.txt | 1 + 31 files changed, 102 insertions(+), 63 deletions(-) create mode 100644 crates/aprender-contracts-key/Cargo.toml create mode 100644 crates/aprender-contracts-key/README.md create mode 100644 crates/aprender-contracts-key/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0ccb3d8ed7..edc39533fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,7 +451,7 @@ name = "aprender-compute" version = "0.71.3" dependencies = [ "anyhow", - "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "aprender-core", "aprender-cuda-edge", @@ -514,6 +514,7 @@ dependencies = [ name = "aprender-contracts" version = "0.71.3" dependencies = [ + "aprender-contracts-key", "aprender-contracts-macros", "blake3", "criterion 0.5.1", @@ -546,6 +547,10 @@ dependencies = [ "tempfile", ] +[[package]] +name = "aprender-contracts-key" +version = "0.71.3" + [[package]] name = "aprender-contracts-macros" version = "0.71.3" @@ -581,6 +586,7 @@ dependencies = [ "aprender-common", "aprender-compute", "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "aprender-data", "aprender-profile", @@ -707,7 +713,7 @@ dependencies = [ "aprender-build-sha", "aprender-common", "aprender-compute", - "aprender-contracts", + "aprender-contracts-key", "arrow 59.2.0", "axum 0.7.9", "bytemuck", @@ -920,6 +926,7 @@ dependencies = [ "aprender-common", "aprender-compute", "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "aprender-core", "aprender-cuda-edge", @@ -986,7 +993,7 @@ name = "aprender-present-cli" version = "0.71.3" dependencies = [ "aprender-build-sha", - "aprender-contracts", + "aprender-contracts-key", "aprender-present-yaml", "clap", "notify", @@ -1000,7 +1007,7 @@ dependencies = [ name = "aprender-present-core" version = "0.71.3" dependencies = [ - "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "criterion 0.7.0", "proptest", @@ -1293,7 +1300,7 @@ version = "0.71.3" dependencies = [ "aprender-common", "aprender-compute", - "aprender-contracts", + "aprender-contracts-key", "aprender-serve", "async-trait", "bincode", @@ -1489,6 +1496,7 @@ version = "0.71.3" dependencies = [ "aprender-build-sha", "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "aprender-present-core", "aprender-present-terminal", @@ -1680,6 +1688,7 @@ dependencies = [ "aprender-common", "aprender-compute", "aprender-contracts", + "aprender-contracts-key", "aprender-contracts-macros", "aprender-core", "aprender-data", diff --git a/Cargo.toml b/Cargo.toml index 71b08df5c4..f811e5a87e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ # --- APR-MONO Phase 2b: was provable-contracts --- "crates/aprender-contracts", "crates/aprender-contracts-macros", + "crates/aprender-contracts-key", "crates/aprender-contracts-cli", # EPIC #4232: shared update check + ` update` for every sovereign binary. "crates/aprender-update", @@ -321,6 +322,7 @@ aprender-train-common = { path = "crates/aprender-train-common", version = "0.71 aprender-train-distill = { path = "crates/aprender-train-distill", version = "0.71.3" } aprender-orchestrate = { path = "crates/aprender-orchestrate", version = "0.71.3" } aprender-contracts = { path = "crates/aprender-contracts", version = "0.71.3" } +aprender-contracts-key = { path = "crates/aprender-contracts-key", version = "0.71.3" } aprender-build-sha = { path = "crates/aprender-build-sha", version = "0.71.3" } aprender-rollup = { path = "crates/aprender-rollup", version = "0.71.3" } aprender-profile = { path = "crates/aprender-profile", version = "0.71.3" } diff --git a/crates/aprender-compute/Cargo.toml b/crates/aprender-compute/Cargo.toml index 1aa9e9f4ab..cd30e9ecbf 100644 --- a/crates/aprender-compute/Cargo.toml +++ b/crates/aprender-compute/Cargo.toml @@ -84,7 +84,7 @@ web-sys = { version = "0.3", optional = true, features = ["console"] } hostname = "0.4" [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-compute/build.rs b/crates/aprender-compute/build.rs index 5c02ae8a78..14c0a7d60e 100644 --- a/crates/aprender-compute/build.rs +++ b/crates/aprender-compute/build.rs @@ -36,7 +36,7 @@ struct Binding { /// Convert a contract filename + equation into a canonical env var name. fn env_var_name(contract: &str, equation: &str) -> String { - provable_contracts::build_helper::env_key( + aprender_contracts_key::env_key( contract.trim_end_matches(".yaml").trim_end_matches(".yml"), equation, ) diff --git a/crates/aprender-contracts-key/Cargo.toml b/crates/aprender-contracts-key/Cargo.toml new file mode 100644 index 0000000000..c5a827ff08 --- /dev/null +++ b/crates/aprender-contracts-key/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "aprender-contracts-key" +description = "The one CONTRACT_* env-var key builder shared by build.rs scripts and aprender-contracts (no dependencies, so build-depending on it does not mark the contract engine affected)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["verification", "contracts"] +categories = ["development-tools::build-utils"] + +[lints] +workspace = true diff --git a/crates/aprender-contracts-key/README.md b/crates/aprender-contracts-key/README.md new file mode 100644 index 0000000000..4be79576f6 --- /dev/null +++ b/crates/aprender-contracts-key/README.md @@ -0,0 +1,14 @@ +# aprender-contracts-key + +The one `CONTRACT_*` env-var key builder shared by `build.rs` scripts and `aprender-contracts`. + +Part of the [Aprender](https://github.com/paiml/aprender) monorepo (`paiml/aprender`). It has no dependencies on purpose: a crate that build-depends on it does not mark the contract engine affected. + +```rust +let key = aprender_contracts_key::env_key("my-contract", "eq.1"); +assert_eq!(key, "CONTRACT_MY_CONTRACT_EQ_1"); +``` + +## License + +MIT diff --git a/crates/aprender-contracts-key/src/lib.rs b/crates/aprender-contracts-key/src/lib.rs new file mode 100644 index 0000000000..cff26360f9 --- /dev/null +++ b/crates/aprender-contracts-key/src/lib.rs @@ -0,0 +1,15 @@ +//! The `CONTRACT_*` env-var key builder (#4369, #5909). Dependency-free on purpose. + +/// The env var key `#[contract(contract, equation = equation)]` reads. +/// +/// A producer (a consuming crate's `build.rs`) must emit its +/// `cargo:rustc-env` vars under exactly this key, plus `_PRE_COUNT`, +/// `_PRE_`, `_POST_COUNT` and `_POST_`. Call this instead of +/// re-deriving the format: a hand-rolled copy that drifts makes every +/// condition land under a name the macro never reads (#2699 §4). It is +/// tested equal to the macro's own `contract_env_key!`. +pub fn env_key(contract: &str, equation: &str) -> String { + let contract_part = contract.to_uppercase().replace(['-', '.'], "_"); + let equation_part = equation.to_uppercase().replace(['-', '.'], "_"); + format!("CONTRACT_{contract_part}_{equation_part}") +} diff --git a/crates/aprender-contracts/Cargo.toml b/crates/aprender-contracts/Cargo.toml index 7be48d9776..cbcc4d9954 100644 --- a/crates/aprender-contracts/Cargo.toml +++ b/crates/aprender-contracts/Cargo.toml @@ -36,8 +36,10 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } +aprender-contracts-key = { path = "../aprender-contracts-key", version = "0.71.3" } [build-dependencies] +aprender-contracts-key = { path = "../aprender-contracts-key", version = "0.71.3" } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" diff --git a/crates/aprender-contracts/build.rs b/crates/aprender-contracts/build.rs index 69a8e70a05..a444cf56f4 100644 --- a/crates/aprender-contracts/build.rs +++ b/crates/aprender-contracts/build.rs @@ -3,8 +3,8 @@ use serde::Deserialize; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -// The one key builder, shared with `build_helper::env_key` (#4369). -include!("src/env_key.rs"); +// The one key builder lives in aprender-contracts-key (#4369, #5909). +use aprender_contracts_key::env_key; #[derive(Deserialize)] struct BindingFile { diff --git a/crates/aprender-contracts/src/env_key.rs b/crates/aprender-contracts/src/env_key.rs index bc38586f53..877a0504db 100644 --- a/crates/aprender-contracts/src/env_key.rs +++ b/crates/aprender-contracts/src/env_key.rs @@ -1,16 +1,3 @@ -// `include!`d by `build_helper.rs` (which carries the equality test against the -// macro) and by this crate's own build.rs, which cannot build-depend on the -// crate it builds (#4369). -/// The env var key `#[contract(contract, equation = equation)]` reads. -/// -/// A producer (a consuming crate's `build.rs`) must emit its -/// `cargo:rustc-env` vars under exactly this key, plus `_PRE_COUNT`, -/// `_PRE_`, `_POST_COUNT` and `_POST_`. Call this instead of -/// re-deriving the format: a hand-rolled copy that drifts makes every -/// condition land under a name the macro never reads (#2699 §4). It is -/// tested equal to the macro's own `contract_env_key!`. -pub fn env_key(contract: &str, equation: &str) -> String { - let contract_part = contract.to_uppercase().replace(['-', '.'], "_"); - let equation_part = equation.to_uppercase().replace(['-', '.'], "_"); - format!("CONTRACT_{contract_part}_{equation_part}") -} +// `include!`d by `build_helper.rs`. The +// definition lives in the dependency-free `aprender-contracts-key` (#4369, #5909). +pub use aprender_contracts_key::env_key; diff --git a/crates/aprender-core/Cargo.toml b/crates/aprender-core/Cargo.toml index 2c6a34777c..9fad293538 100644 --- a/crates/aprender-core/Cargo.toml +++ b/crates/aprender-core/Cargo.toml @@ -213,7 +213,7 @@ provable-contracts-macros = { workspace = true } # realizar = { version = "0.6", default-features = false, optional = true } # BLOCKED [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-core/build.rs b/crates/aprender-core/build.rs index 997babbe67..c0e29dc397 100644 --- a/crates/aprender-core/build.rs +++ b/crates/aprender-core/build.rs @@ -50,7 +50,7 @@ struct Binding { /// /// "softmax-kernel-v1.yaml" + "softmax" -> "CONTRACT_SOFTMAX_KERNEL_V1_SOFTMAX" fn binding_env_var_name(contract: &str, equation: &str) -> String { - provable_contracts::build_helper::env_key( + aprender_contracts_key::env_key( contract.trim_end_matches(".yaml").trim_end_matches(".yml"), equation, ) diff --git a/crates/aprender-db/Cargo.toml b/crates/aprender-db/Cargo.toml index f60b03b5c6..a2cab3378c 100644 --- a/crates/aprender-db/Cargo.toml +++ b/crates/aprender-db/Cargo.toml @@ -66,7 +66,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-db/build.rs b/crates/aprender-db/build.rs index dcc8913930..ffb4d4d655 100644 --- a/crates/aprender-db/build.rs +++ b/crates/aprender-db/build.rs @@ -49,10 +49,8 @@ fn main() { let total = bindings.bindings.len() as u32; for b in &bindings.bindings { - let var = provable_contracts::build_helper::env_key( - b.contract.trim_end_matches(".yaml"), - &b.equation, - ); + let var = + aprender_contracts_key::env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); if b.status == "implemented" { implemented += 1; diff --git a/crates/aprender-orchestrate/Cargo.toml b/crates/aprender-orchestrate/Cargo.toml index 0d2a522d2d..99b8774af5 100644 --- a/crates/aprender-orchestrate/Cargo.toml +++ b/crates/aprender-orchestrate/Cargo.toml @@ -317,7 +317,7 @@ sovereign-stack = [ ] [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-orchestrate/build.rs b/crates/aprender-orchestrate/build.rs index 8f14315f0c..2fba89145c 100644 --- a/crates/aprender-orchestrate/build.rs +++ b/crates/aprender-orchestrate/build.rs @@ -80,10 +80,8 @@ fn emit_bindings(bindings: &BindingFile) { let total = bindings.bindings.len() as u32; for b in &bindings.bindings { - let var = provable_contracts::build_helper::env_key( - b.contract.trim_end_matches(".yaml"), - &b.equation, - ); + let var = + aprender_contracts_key::env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); if b.status == "implemented" { implemented += 1; @@ -117,7 +115,7 @@ fn emit_contract_assertions(cdir: &Path) { continue; }; for (n, eq) in &y.equations { - let k = provable_contracts::build_helper::env_key(&s, n); + let k = aprender_contracts_key::env_key(&s, n); tp += emit_conditions(&k, "PRE", &eq.preconditions); tq += emit_conditions(&k, "POST", &eq.postconditions); } diff --git a/crates/aprender-present-cli/Cargo.toml b/crates/aprender-present-cli/Cargo.toml index f66f0f45ab..9249c0e6f6 100644 --- a/crates/aprender-present-cli/Cargo.toml +++ b/crates/aprender-present-cli/Cargo.toml @@ -22,7 +22,7 @@ tiny_http = "0.12" tungstenite = { version = "0.26", optional = true } [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-present-cli/build.rs b/crates/aprender-present-cli/build.rs index cf8320e713..b5f8c3f8ea 100644 --- a/crates/aprender-present-cli/build.rs +++ b/crates/aprender-present-cli/build.rs @@ -49,10 +49,8 @@ fn main() { let total = bindings.bindings.len() as u32; for b in &bindings.bindings { - let var = provable_contracts::build_helper::env_key( - b.contract.trim_end_matches(".yaml"), - &b.equation, - ); + let var = + aprender_contracts_key::env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); if b.status == "implemented" { implemented += 1; diff --git a/crates/aprender-present-core/Cargo.toml b/crates/aprender-present-core/Cargo.toml index bcd37afbe3..f696474475 100644 --- a/crates/aprender-present-core/Cargo.toml +++ b/crates/aprender-present-core/Cargo.toml @@ -23,7 +23,7 @@ serde_json = { workspace = true } provable-contracts-macros = { workspace = true } [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) serde = { workspace = true } serde-saphyr = { workspace = true } diff --git a/crates/aprender-present-core/build.rs b/crates/aprender-present-core/build.rs index e86c6773da..467ba3996e 100644 --- a/crates/aprender-present-core/build.rs +++ b/crates/aprender-present-core/build.rs @@ -84,7 +84,7 @@ fn emit_contract_file_pre_post(path: &PathBuf, total_pre: &mut usize, total_post return; }; for (eq_name, eq) in &parsed.equations { - let key = provable_contracts::build_helper::env_key(&stem, eq_name); + let key = aprender_contracts_key::env_key(&stem, eq_name); emit_pre_post_lists(&key, eq, total_pre, total_post); } } @@ -178,7 +178,7 @@ fn emit_binding_counts_and_vars(bf: &BindingFile) { } fn env_var_name(contract: &str, equation: &str) -> String { - provable_contracts::build_helper::env_key( + aprender_contracts_key::env_key( contract.trim_end_matches(".yaml").trim_end_matches(".yml"), equation, ) diff --git a/crates/aprender-rag/Cargo.toml b/crates/aprender-rag/Cargo.toml index f2f4ff3900..b1838638e8 100644 --- a/crates/aprender-rag/Cargo.toml +++ b/crates/aprender-rag/Cargo.toml @@ -72,7 +72,7 @@ sha2 = { version = "0.10", optional = true } rand = { version = "0.9", optional = true } [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) serde = { version = "1", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-rag/build.rs b/crates/aprender-rag/build.rs index cbf56d77fe..df297328bc 100644 --- a/crates/aprender-rag/build.rs +++ b/crates/aprender-rag/build.rs @@ -46,10 +46,8 @@ fn main() { let total = bindings.bindings.len() as u32; for b in &bindings.bindings { - let var = provable_contracts::build_helper::env_key( - b.contract.trim_end_matches(".yaml"), - &b.equation, - ); + let var = + aprender_contracts_key::env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); if b.status == "implemented" { implemented += 1; diff --git a/crates/aprender-simulate/Cargo.toml b/crates/aprender-simulate/Cargo.toml index e5ff4de6bf..858d269d45 100644 --- a/crates/aprender-simulate/Cargo.toml +++ b/crates/aprender-simulate/Cargo.toml @@ -94,7 +94,7 @@ provable-contracts-macros = { workspace = true } # Code gated behind #[cfg(feature = "z3-proofs")] [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version serde = { version = "1.0", features = ["derive"] } serde-saphyr = { workspace = true } # #5208: build.rs reads contract YAML through saphyr, not unsafe-libyaml diff --git a/crates/aprender-simulate/build.rs b/crates/aprender-simulate/build.rs index cb4d91ca06..f75b3d022f 100644 --- a/crates/aprender-simulate/build.rs +++ b/crates/aprender-simulate/build.rs @@ -120,7 +120,7 @@ fn emit_contract_file(path: &Path, total_pre: &mut usize, total_post: &mut usize return; }; for (eq_name, equation) in &contract.equations { - let key = provable_contracts::build_helper::env_key(stem, eq_name); + let key = aprender_contracts_key::env_key(stem, eq_name); emit_pre_post(&key, equation, total_pre, total_post); } } @@ -185,10 +185,8 @@ fn enforce_provable_binding() { fn emit_binding_vars(bf: &BindingFile) -> Vec { let mut gaps = Vec::new(); for b in &bf.bindings { - let var = provable_contracts::build_helper::env_key( - b.contract.trim_end_matches(".yaml"), - &b.equation, - ); + let var = + aprender_contracts_key::env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); if b.status != "implemented" { gaps.push(var); diff --git a/crates/aprender-train/Cargo.toml b/crates/aprender-train/Cargo.toml index ac52f7d28d..dc0f9e5514 100644 --- a/crates/aprender-train/Cargo.toml +++ b/crates/aprender-train/Cargo.toml @@ -135,7 +135,7 @@ zip = { version = "9.0", default-features = false, features = ["deflate"] } # R alimentar = { workspace = true, optional = true } # Dataset loading with parquet/arrow support (gated behind parquet feature) [build-dependencies] -provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { workspace = true } # #4369, #5909: the one CONTRACT_* key builder (no engine dependency) serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" diff --git a/crates/aprender-train/build.rs b/crates/aprender-train/build.rs index 9fc0b039c6..eeb44eba68 100644 --- a/crates/aprender-train/build.rs +++ b/crates/aprender-train/build.rs @@ -39,7 +39,7 @@ struct Binding { /// /// `"learning-rate-schedules-v1.yaml"` + `"cosine_warmup"` → `"CONTRACT_LEARNING_RATE_SCHEDULES_V1_COSINE_WARMUP"` fn env_var_name(contract: &str, equation: &str) -> String { - provable_contracts::build_helper::env_key( + aprender_contracts_key::env_key( contract.trim_end_matches(".yaml").trim_end_matches(".yml"), equation, ) @@ -260,7 +260,7 @@ fn emit_contract_file(path: &Path, total_pre: &mut usize, total_post: &mut usize return; }; for (eq_name, eq) in &parsed.equations { - let key = provable_contracts::build_helper::env_key(&stem, eq_name); + let key = aprender_contracts_key::env_key(&stem, eq_name); emit_pre_post(&key, eq, total_pre, total_post); } } diff --git a/crates/aprender-viz-ttop/Cargo.toml b/crates/aprender-viz-ttop/Cargo.toml index 077014a189..d96ef95f07 100644 --- a/crates/aprender-viz-ttop/Cargo.toml +++ b/crates/aprender-viz-ttop/Cargo.toml @@ -38,7 +38,7 @@ thiserror = "2.0" anyhow = "1.0" [build-dependencies] -provable-contracts = { path = "../aprender-contracts", package = "aprender-contracts" } # #4369 (excluded crate: no workspace deps): build_helper::env_key, the one CONTRACT_* key builder +aprender-contracts-key = { path = "../aprender-contracts-key" } # #4369, #5909 (excluded crate: no workspace deps): the one CONTRACT_* key builder serde = { version = "1.0", features = ["derive"] } serde-saphyr = { workspace = true } diff --git a/crates/aprender-viz-ttop/build.rs b/crates/aprender-viz-ttop/build.rs index d59e53abdb..f79fb9fca5 100644 --- a/crates/aprender-viz-ttop/build.rs +++ b/crates/aprender-viz-ttop/build.rs @@ -34,7 +34,7 @@ fn process_contract(path: &std::path::Path) -> (usize, usize) { let (mut pre, mut post) = (0, 0); for (name, eq) in &yaml.equations { - let key = provable_contracts::build_helper::env_key(&stem, name); + let key = aprender_contracts_key::env_key(&stem, name); if !eq.preconditions.is_empty() { println!("cargo:rustc-env={key}_PRE_COUNT={}", eq.preconditions.len()); pre += eq.preconditions.len(); diff --git a/crates/facades/Cargo.lock b/crates/facades/Cargo.lock index 4dfeab8f7c..c212413a9c 100644 --- a/crates/facades/Cargo.lock +++ b/crates/facades/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ name = "aprender-contracts" version = "0.71.3" dependencies = [ + "aprender-contracts-key", "aprender-contracts-macros", "blake3", "proc-macro2", @@ -27,6 +28,10 @@ dependencies = [ "thiserror", ] +[[package]] +name = "aprender-contracts-key" +version = "0.71.3" + [[package]] name = "aprender-contracts-macros" version = "0.71.3" diff --git a/scripts/cascade-publish.sh b/scripts/cascade-publish.sh index adb89423b2..f0e4851919 100755 --- a/scripts/cascade-publish.sh +++ b/scripts/cascade-publish.sh @@ -32,7 +32,7 @@ REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) # Tier definitions per SPEC-HF-PUBLISH-001 § crates.io release cascade. declare -A TIERS -TIERS[1]="aprender-update apr-format aprender-build-sha aprender-contracts-macros aprender-quant aprender-gemm-codegen aprender-sparse aprender-solve aprender-rand aprender-fft aprender-image aprender-tensor aprender-cupti" +TIERS[1]="aprender-update apr-format aprender-build-sha aprender-contracts-macros aprender-contracts-key aprender-quant aprender-gemm-codegen aprender-sparse aprender-solve aprender-rand aprender-fft aprender-image aprender-tensor aprender-cupti" TIERS[2]="aprender-contracts aprender-core aprender-profile-core aprender-graph aprender-contrastive-data" TIERS[3]="aprender-profile" TIERS[4]="aprender-gpu" diff --git a/scripts/release/publish-order.txt b/scripts/release/publish-order.txt index 33751e194b..8bab042ce2 100644 --- a/scripts/release/publish-order.txt +++ b/scripts/release/publish-order.txt @@ -3,6 +3,7 @@ aprender-update apr-format aprender-common aprender-contracts-macros +aprender-contracts-key aprender-cupti aprender-data aprender-fft From 2ebee4502b51b632c15656a2706686c6600f7235 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 12:54:47 +0200 Subject: [PATCH 02/11] =?UTF-8?q?perf(ci):=20FLOW-010=20cut=201=20?= =?UTF-8?q?=E2=80=94=20drop=20aprender-compute=20dev-dep=20on=20aprender-c?= =?UTF-8?q?ore=20(Refs=20#5803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #5804 PMAT-Ticket: PMAT-5803 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt (cherry picked from commit dad25511f7430e6d0a3baa2d812c98611b6d848e) (cherry picked from commit 9ed0876fae5d9922c5eaacf031b7516c05661e38) (cherry picked from commit 2f3c05a5d5f42a726ac0f5cbc610670377b3b23f) (cherry picked from commit 63a2d8cd02c9fbb90b0bdfa5577084ab6f4102d7) (cherry picked from commit 1d4dc376d3d7fadc27012f5a5156a1de9213f8e3) (cherry picked from commit 24c119c9d1f8ca875ac25f8585441a29fc618a62) (cherry picked from commit 1fa5b48d334584d65c39a0286c06d4f086da4eb7) (cherry picked from commit 615369e9239b7fe12e03f86b942160d10190265a) (cherry picked from commit daa9be8df37992423ac1110dacb4887da6dcb40a) (cherry picked from commit 672eba507d481f93eff150b71e816a76987e88ce) --- .github/workflows/guards-nightly.yml | 20 ++ Cargo.lock | 1 - ...ender-core-tuner-training-data-quality.cmd | 1 + ...ender-compute-tuner-falsification-5803.cmd | 1 + crates/aprender-compute/Cargo.toml | 16 +- crates/aprender-compute/README.md | 1 - .../book/src/ecosystem/aprender.md | 50 +---- .../book/src/performance/ml-tuner.md | 78 +------- .../docs/prompts/95_percent_mandate.md | 2 +- crates/aprender-compute/examples/README.md | 4 +- .../examples/ml_tuner_demo.rs | 107 +---------- crates/aprender-compute/src/tuner/error.rs | 2 +- .../src/tuner/models/kernel.rs | 6 +- .../src/tuner/models/throughput.rs | 6 +- .../src/tuner/tests/coverage/gap_coverage.rs | 6 +- .../training_data_quality.rs | 179 +----------------- .../tests/tuner_training_data_quality.rs | 171 +++++++++++++++++ scripts/check_tuner_test_move.sh | 157 +++++++++++++++ scripts/guards_nightly_manifest.txt | 2 + 19 files changed, 396 insertions(+), 414 deletions(-) create mode 100644 ci/explicit-test-commands.d/295-aprender-core-tuner-training-data-quality.cmd create mode 100644 ci/explicit-test-commands.d/298-aprender-compute-tuner-falsification-5803.cmd create mode 100644 crates/aprender-core/tests/tuner_training_data_quality.rs create mode 100755 scripts/check_tuner_test_move.sh diff --git a/.github/workflows/guards-nightly.yml b/.github/workflows/guards-nightly.yml index dfbcdce6de..3ab9095133 100644 --- a/.github/workflows/guards-nightly.yml +++ b/.github/workflows/guards-nightly.yml @@ -429,6 +429,26 @@ jobs: set -euo pipefail bash scripts/lab_ticket.sh --label lab-affected-readers --title "LAB: nightly affected-set guards red (#5809)" \ --owner aprender-w5018 --log "$RUNNER_TEMP/affected.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" + # #5803/#5804: the moved tuner tests must still be listed by cargo exactly once, none ignored. LAB, never on a PR path (C324): + # it invokes cargo, so it runs only in this night job; the moved tests themselves run from ci/explicit-test-commands.d 295 and 298. + - name: Tuner test-move guard (#5803) + id: lab_tuner_test_move + if: ${{ !cancelled() }} + run: | + set -euo pipefail + exec > >(tee -a "$RUNNER_TEMP/tuner-move.log") 2>&1 + setsid --wait bash scripts/check_tuner_test_move.sh --self-test + setsid --wait bash scripts/check_tuner_test_move.sh + - name: A red tuner-test-move night opens or updates its one LAB ticket (#5803) + if: failure() && steps.lab_tuner_test_move.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + bash scripts/lab_ticket.sh --label lab-tuner-test-move --title "LAB: nightly tuner test-move guard red (#5803)" \ + --owner aprender-w5018 --log "$RUNNER_TEMP/tuner-move.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" - name: A red census night opens or updates the one LAB ticket (#4709) if: failure() && steps.ratchet_census.outcome == 'failure' env: diff --git a/Cargo.lock b/Cargo.lock index edc39533fd..100fcb42fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,6 @@ dependencies = [ "anyhow", "aprender-contracts-key", "aprender-contracts-macros", - "aprender-core", "aprender-cuda-edge", "aprender-gemm-codegen", "aprender-gpu", diff --git a/ci/explicit-test-commands.d/295-aprender-core-tuner-training-data-quality.cmd b/ci/explicit-test-commands.d/295-aprender-core-tuner-training-data-quality.cmd new file mode 100644 index 0000000000..c69214adf3 --- /dev/null +++ b/ci/explicit-test-commands.d/295-aprender-core-tuner-training-data-quality.cmd @@ -0,0 +1 @@ +cargo test -p aprender-core --test tuner_training_data_quality diff --git a/ci/explicit-test-commands.d/298-aprender-compute-tuner-falsification-5803.cmd b/ci/explicit-test-commands.d/298-aprender-compute-tuner-falsification-5803.cmd new file mode 100644 index 0000000000..32265c8925 --- /dev/null +++ b/ci/explicit-test-commands.d/298-aprender-compute-tuner-falsification-5803.cmd @@ -0,0 +1 @@ +cargo test -p aprender-compute --test tuner_falsification diff --git a/crates/aprender-compute/Cargo.toml b/crates/aprender-compute/Cargo.toml index cd30e9ecbf..0c7fcfa8c0 100644 --- a/crates/aprender-compute/Cargo.toml +++ b/crates/aprender-compute/Cargo.toml @@ -63,11 +63,9 @@ anyhow = "1.0.100" # Hardware capability detection (PMAT-447) chrono = "0.4" toml = "0.8" -# ML tuner integration (SHOWCASE-BRICK-001, Section 12) — aprender RandomForest is a -# DEV-dependency only: the SIMD foundation must not depend on the ML layer (layer -# inversion). The `ml-tuner` showcase lives in examples/ml_tuner_demo.rs + the tuner -# falsification tests, which consume aprender directly. Dev-dep path cycles are legal -# in cargo and dropped on publish, keeping the workspace a self-contained DAG (APR-MONO). +# ML tuner integration (SHOWCASE-BRICK-001, Section 12) — the SIMD foundation takes no +# dependency, not even a dev one, on the ML layer. The aprender RandomForest tests live in +# crates/aprender-core/tests/tuner_training_data_quality.rs (FLOW-010, #5803). # NOTE: trueno-graph (=aprender-graph) removed (APR-MONO self-containment): aprender-graph # depends on trueno, so compute→graph closed a cycle. The `execution-graph` feature's only # trueno-graph use was the thin `to_csr()`/`graph_to_csr()` CsrGraph exporters; CSR export @@ -106,9 +104,6 @@ trueno-cuda-edge = { workspace = true } # Sparse and solver crates for contract tests trueno-sparse = { workspace = true } trueno-solve = { workspace = true } -# ML-tuner showcase (SHOWCASE-BRICK-001): aprender RandomForest, DEV-only to avoid the -# compute→core layer inversion. Path dev-dep cycle is legal + dropped on publish. -aprender = { path = "../aprender-core", package = "aprender-core", default-features = false } ndarray = "0.17.2" faer = "0.24" # For GEMM benchmark comparison (fastest pure-Rust BLAS) matrixmultiply = "0.3" # Direct matrixmultiply crate benchmark (engine behind ndarray) @@ -139,9 +134,8 @@ hardware-detect = ["dep:dirs"] tui-monitor = ["presentar-core", "presentar-terminal", "crossterm", "trueno-gpu", "dep:tracing", "tracing-subscriber", "tracing-appender", "dep:dirs"] # CUDA support for TUI (real hardware monitoring) cuda = ["trueno-gpu/cuda"] -# ML-based kernel selection and throughput prediction (SHOWCASE-BRICK-001) -# Showcase: feed trueno tuner features into aprender RandomForest (example + tests only; -# aprender is a dev-dependency, so this feature activates no lib dependency). +# DEPRECATED no-op, kept so removing it is not a semver break for 0.71.x users (activates nothing; +# the aprender RandomForest tests moved to aprender-core, #5803). ml-tuner = [] # NOTE: `execution-graph` feature removed (APR-MONO self-containment) — its only purpose # was the trueno-graph CsrGraph exporters (see above). The exec-graph profiler itself diff --git a/crates/aprender-compute/README.md b/crates/aprender-compute/README.md index a462ad3d5e..50743d1f6e 100644 --- a/crates/aprender-compute/README.md +++ b/crates/aprender-compute/README.md @@ -222,7 +222,6 @@ GEMV, Quantization (Q4K/Q5K/Q6K). JIT disk cache at | `parallel` | Rayon parallel iterators | | `cuda-monitor` | NVIDIA device info via trueno-gpu | | `cuda` | Full CUDA support via trueno-gpu | -| `ml-tuner` | ML-based kernel selection via aprender RandomForest | | `execution-graph` | PTX-to-kernel call graph profiling | | `tui-monitor` | Terminal monitoring dashboard | | `hardware-detect` | Hardware capability detection and caching | diff --git a/crates/aprender-compute/book/src/ecosystem/aprender.md b/crates/aprender-compute/book/src/ecosystem/aprender.md index bd8a8058fd..9d82ed5d31 100644 --- a/crates/aprender-compute/book/src/ecosystem/aprender.md +++ b/crates/aprender-compute/book/src/ecosystem/aprender.md @@ -10,51 +10,12 @@ The integration provides: - **RandomForestClassifier** for kernel selection - Training on benchmark data for hardware-specific optimization -## Enabling the Integration +## Where the Integration Lives -Add the `ml-tuner` feature to your `Cargo.toml`: - -```toml -[dependencies] -trueno = { version = "0.13", features = ["ml-tuner"] } -``` - -## Feature Matrix - -| Feature | Default | ml-tuner | -|---------|---------|----------| -| TunerFeatures (42-dim) | Yes | Yes | -| Heuristic prediction | Yes | Yes | -| Roofline clamping | Yes | Yes | -| RandomForest regressor | No | Yes | -| RandomForest classifier | No | Yes | -| Custom model training | No | Yes | - -## Usage Example - -```rust -use trueno::tuner::{ThroughputRegressor, TunerFeatures, QuantType}; - -// Create RF-backed regressor -let mut regressor = ThroughputRegressor::with_random_forest(100); - -// Collect benchmark data -let training_data: Vec<(TunerFeatures, f32)> = collect_benchmarks(); - -// Train the model -regressor.train_random_forest(&training_data)?; - -// Use trained model for predictions -let features = TunerFeatures::builder() - .model_params_b(7.0) - .batch_size(4) - .quant_type(QuantType::Q4K) - .gpu_mem_bw_gbs(1000.0) - .build(); - -let pred = regressor.predict(&features); -println!("Predicted throughput: {:.1} tok/s", pred.predicted_tps); -``` +The `ml-tuner` feature was removed (FLOW-010, #5803): trueno has no dependency on +aprender, so the SIMD foundation stays below the ML layer. The RandomForest showcase +runs from aprender-core's tests (`crates/aprender-core/tests/tuner_training_data_quality.rs`), +which depend on trueno, not the reverse. trueno keeps the heuristic models. ## Why aprender? @@ -119,7 +80,6 @@ let model_json = fs::read_to_string("throughput_model.json")?; let regressor: ThroughputRegressor = serde_json::from_str(&model_json)?; ``` -**Note:** RandomForest models are not serialized (marked `#[serde(skip)]`). After loading, you must retrain or use heuristic fallback. ## Further Reading diff --git a/crates/aprender-compute/book/src/performance/ml-tuner.md b/crates/aprender-compute/book/src/performance/ml-tuner.md index 2c1074284d..e72b374070 100644 --- a/crates/aprender-compute/book/src/performance/ml-tuner.md +++ b/crates/aprender-compute/book/src/performance/ml-tuner.md @@ -82,72 +82,15 @@ for (kernel, conf) in recommendation.alternatives.iter().take(3) { | M=2-3 | VectorizedQ4K | Low batch, vectorized still efficient | | M>=4 | BatchedQ4K | High batch, batched attention wins | -## RandomForest Models (Optional) +## RandomForest Models (Relocated) -With the `ml-tuner` feature, you can use aprender's RandomForest models for learned optimization: - -```toml -# Cargo.toml -[dependencies] -trueno = { version = "0.13", features = ["ml-tuner"] } -``` - -### Training a Custom Regressor - -```rust -use trueno::tuner::{ThroughputRegressor, TunerFeatures, QuantType}; - -// Create RF-backed regressor with 100 trees -let mut regressor = ThroughputRegressor::with_random_forest(100); - -// Generate training data from benchmarks -let training_data: Vec<(TunerFeatures, f32)> = (0..100) - .map(|i| { - let batch = 1 + (i % 8) as u32; - let features = TunerFeatures::builder() - .model_params_b(1.5) - .batch_size(batch) - .quant_type(QuantType::Q4K) - .gpu_mem_bw_gbs(1000.0) - .cuda_graphs(batch == 1) - .build(); - // Measured throughput from benchmark - let throughput = 200.0 + (batch as f32) * 80.0; - (features, throughput) - }) - .collect(); - -// Train the model -regressor.train_random_forest(&training_data)?; - -// Predictions now use learned model -let pred = regressor.predict(&features); -println!("RF prediction: {:.1} tok/s", pred.predicted_tps); -``` - -### Training a Custom Classifier - -```rust -use trueno::tuner::{KernelClassifier, TunerFeatures, QuantType}; - -let mut classifier = KernelClassifier::with_random_forest(50); - -// Label encoding: VectorizedQ4K=2, BatchedQ4K=3 -let training_data: Vec<(TunerFeatures, u32)> = (0..100) - .map(|i| { - let batch = 1 + (i % 8) as u32; - let features = TunerFeatures::builder() - .model_params_b(1.5) - .batch_size(batch) - .quant_type(QuantType::Q4K) - .build(); - let label = if batch >= 4 { 3 } else { 2 }; - (features, label) - }) - .collect(); - -classifier.train(&training_data)?; -``` +The `ml-tuner` feature is now a deprecated no-op (`ml-tuner = []`, kept so removing it +is not a semver break), and the `with_random_forest` / `train_random_forest` / +`KernelClassifier::train` wrappers were removed (FLOW-010, #5803): the SIMD compute +crate must not depend on the ML layer. The aprender RandomForest showcase now lives in +`crates/aprender-core/tests/tuner_training_data_quality.rs`, which feeds `TunerFeatures` +into `aprender::RandomForestRegressor` / `RandomForestClassifier`. This crate keeps the +heuristic `ThroughputRegressor` and `KernelClassifier`. ## Full Tuner Recommendations @@ -178,11 +121,8 @@ for exp in &rec.suggested_experiments { ## Running the Demo ```bash -# Default (heuristic models) +# Heuristic models cargo run --example ml_tuner_demo - -# With RandomForest models -cargo run --example ml_tuner_demo --features ml-tuner ``` ## Integration with ComputeBrick diff --git a/crates/aprender-compute/docs/prompts/95_percent_mandate.md b/crates/aprender-compute/docs/prompts/95_percent_mandate.md index 2f5309db32..92697b0b55 100644 --- a/crates/aprender-compute/docs/prompts/95_percent_mandate.md +++ b/crates/aprender-compute/docs/prompts/95_percent_mandate.md @@ -18,7 +18,7 @@ --package trueno --package trueno-gpu \ --lib \ --no-default-features \ - --features "parallel,cuda,wgpu,ml-tuner,execution-graph,hardware-detect,tracing" \ + --features "parallel,cuda,wgpu,execution-graph,hardware-detect,tracing" \ --html ``` 2. **Close the Gaps**: diff --git a/crates/aprender-compute/examples/README.md b/crates/aprender-compute/examples/README.md index 3dba4e73d9..cf6dc74bc4 100644 --- a/crates/aprender-compute/examples/README.md +++ b/crates/aprender-compute/examples/README.md @@ -25,8 +25,8 @@ This directory contains examples demonstrating Trueno's high-performance compute | `vocab_bench.rs` | Vocabulary processing benchmark | `cargo run --release --example vocab_bench` | | `profile_vocab.rs` | Vocabulary profiling | `cargo run --release --example profile_vocab` | | `execution_graph.rs` | Execution path graph demo | `cargo run --release --features execution-graph --example execution_graph` | -| `ml_tuner_demo.rs` | ML-based kernel selection | `cargo run --release --features ml-tuner --example ml_tuner_demo` | -| `ml_tuner_evolution.rs` | ML tuner evolution demo | `cargo run --release --features ml-tuner --example ml_tuner_evolution` | +| `ml_tuner_demo.rs` | Heuristic kernel selection and throughput prediction | `cargo run --release --example ml_tuner_demo` | +| `ml_tuner_evolution.rs` | ML tuner evolution demo | `cargo run --release --features hardware-detect --example ml_tuner_evolution` | | `model_tracing.rs` | Model-level inference tracing | `cargo run --release --example model_tracing` | | `tiling_demo.rs` | Tiling compute blocks | `cargo run --release --example tiling_demo` | | `tile_profiler_demo.rs` | Tile profiler demo | `cargo run --release --example tile_profiler_demo` | diff --git a/crates/aprender-compute/examples/ml_tuner_demo.rs b/crates/aprender-compute/examples/ml_tuner_demo.rs index b176d6e348..6adb5f46f7 100644 --- a/crates/aprender-compute/examples/ml_tuner_demo.rs +++ b/crates/aprender-compute/examples/ml_tuner_demo.rs @@ -6,9 +6,6 @@ //! //! Run with default (heuristic models): //! cargo run --example ml_tuner_demo -//! -//! Run with RandomForest models (requires ml-tuner feature): -//! cargo run --example ml_tuner_demo --features ml-tuner use trueno::tuner::{BrickTuner, KernelClassifier, QuantType, ThroughputRegressor, TunerFeatures}; @@ -163,101 +160,15 @@ fn demo_full_tuner(features: &TunerFeatures) { println!(); } -/// Section 6: RandomForest models (ml-tuner showcase, SHOWCASE-BRICK-001). -/// -/// The SIMD `trueno` tuner produces `TunerFeatures` vectors; this section feeds them -/// directly into `aprender::RandomForestRegressor`/`RandomForestClassifier`. aprender is -/// a **dev-dependency** of `aprender-compute`, so the compute lib itself never depends on -/// the ML layer — the showcase lives entirely here at the example layer, above both. +/// Section 6: RandomForest models (SHOWCASE-BRICK-001). The aprender half of the showcase +/// is tested in `crates/aprender-core/tests/tuner_training_data_quality.rs`: compute takes +/// no dependency on the ML layer, not even a dev one (FLOW-010, #5803). fn demo_random_forest(features: &TunerFeatures) { - #[cfg(feature = "ml-tuner")] - { - use aprender::primitives::{Matrix, Vector}; - use aprender::tree::{RandomForestClassifier, RandomForestRegressor}; - - println!("6. RandomForest Models (ml-tuner showcase)"); - println!(" --------------------------------------"); - - let dim = TunerFeatures::DIM; - let n = 100usize; - - // Regressor: trueno features -> predicted throughput (tok/s). - let mut xr = Vec::with_capacity(n * dim); - let mut yr = Vec::with_capacity(n); - for i in 0..n { - let batch = 1 + (i % 8) as u32; - let f = TunerFeatures::builder() - .model_params_b(1.5) - .batch_size(batch) - .quant_type(QuantType::Q4K) - .gpu_mem_bw_gbs(1000.0) - .cuda_graphs(batch == 1) - .build(); - xr.extend(f.to_vector()); - yr.push(200.0 + (batch as f32) * 80.0 + (i as f32 * 0.5)); - } - println!(" Generated {n} regression samples"); - match Matrix::from_vec(n, dim, xr) { - Ok(x_mat) => { - let mut rf = RandomForestRegressor::new(100); - match rf.fit(&x_mat, &Vector::from_vec(yr)) { - Ok(()) => { - let fx = Matrix::from_vec(1, dim, features.to_vector().to_vec()) - .expect("single-row feature matrix"); - let pred = rf.predict(&fx); - println!( - " RF regressor: SUCCESS — M=4 prediction {:.1} tok/s", - pred.as_slice()[0] - ); - } - Err(e) => println!(" RF regressor: FAILED - {e}"), - } - } - Err(e) => println!(" RF regressor matrix: FAILED - {e}"), - } - - // Classifier: trueno features -> kernel-type label (3=Batched, 2=Vectorized). - let mut xc = Vec::with_capacity(n * dim); - let mut yc: Vec = Vec::with_capacity(n); - for i in 0..n { - let batch = 1 + (i % 8) as u32; - let f = TunerFeatures::builder() - .model_params_b(1.5) - .batch_size(batch) - .quant_type(QuantType::Q4K) - .build(); - xc.extend(f.to_vector()); - yc.push(if batch >= 4 { 3 } else { 2 }); - } - match Matrix::from_vec(n, dim, xc) { - Ok(xc_mat) => { - let mut rf = RandomForestClassifier::new(50); - match rf.fit(&xc_mat, &yc) { - Ok(()) => { - let preds = rf.predict(&xc_mat); - let correct = - yc.iter().zip(preds.iter()).filter(|(a, b)| **a == **b).count(); - println!( - " RF classifier: SUCCESS — train accuracy {:.1}%", - correct as f32 / n as f32 * 100.0 - ); - } - Err(e) => println!(" RF classifier: FAILED - {e}"), - } - } - Err(e) => println!(" RF classifier matrix: FAILED - {e}"), - } - println!(); - } - - #[cfg(not(feature = "ml-tuner"))] - { - let _ = features; - println!("6. RandomForest Models"); - println!(" --------------------"); - println!(" [Disabled - enable with: --features ml-tuner]"); - println!(); - } + let _ = features; + println!("6. RandomForest Models"); + println!(" --------------------"); + println!(" [See aprender-core tests/tuner_training_data_quality.rs]"); + println!(); } fn main() { @@ -279,7 +190,7 @@ fn main() { println!(" - TunerFeatures: 42-dimension vector for ML models"); println!(" - Throughput prediction with roofline clamping (v1.1.0)"); println!(" - Kernel selection: BatchedQ4K for M>=4, VectorizedQ4K otherwise"); - println!(" - RandomForest available with --features ml-tuner"); + println!(" - RandomForest showcase: aprender-core tests/tuner_training_data_quality.rs"); println!(); println!("Next steps:"); println!(" cargo run --example quickstart # Basic trueno usage"); diff --git a/crates/aprender-compute/src/tuner/error.rs b/crates/aprender-compute/src/tuner/error.rs index bb9fd2e631..bb775e3900 100644 --- a/crates/aprender-compute/src/tuner/error.rs +++ b/crates/aprender-compute/src/tuner/error.rs @@ -15,7 +15,7 @@ pub enum TunerError { ModelNotFound, /// Prediction failed PredictionFailed(String), - /// Training failed (ml-tuner feature) + /// Training failed TrainingFailed(String), /// I/O error (file operations) Io(String), diff --git a/crates/aprender-compute/src/tuner/models/kernel.rs b/crates/aprender-compute/src/tuner/models/kernel.rs index 019b9037ef..0e392bbf49 100644 --- a/crates/aprender-compute/src/tuner/models/kernel.rs +++ b/crates/aprender-compute/src/tuner/models/kernel.rs @@ -9,10 +9,10 @@ use super::KernelRecommendation; /// Kernel classifier using simple rule-based logic. /// -/// The `ml-tuner` showcase (SHOWCASE-BRICK-001) trains an +/// The RandomForest showcase (SHOWCASE-BRICK-001) trains an /// `aprender::RandomForestClassifier` on these features in -/// `examples/ml_tuner_demo.rs` — aprender is a dev-dependency so the SIMD foundation -/// stays free of the ML layer (no compute→core layer inversion). +/// `crates/aprender-core/tests/tuner_training_data_quality.rs` — compute has no +/// dependency on aprender-core, so the SIMD foundation stays free of the ML layer. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct KernelClassifier { /// Kernel accuracy on validation (for confidence) diff --git a/crates/aprender-compute/src/tuner/models/throughput.rs b/crates/aprender-compute/src/tuner/models/throughput.rs index 373f7bdde9..9c4ff3f23a 100644 --- a/crates/aprender-compute/src/tuner/models/throughput.rs +++ b/crates/aprender-compute/src/tuner/models/throughput.rs @@ -8,10 +8,10 @@ use super::ThroughputPrediction; /// Simple linear regression model for throughput prediction. /// -/// Uses closed-form solution: w = (X^T X)^-1 X^T y. The `ml-tuner` showcase +/// Uses closed-form solution: w = (X^T X)^-1 X^T y. The RandomForest showcase /// (SHOWCASE-BRICK-001) feeds these features into `aprender::RandomForestRegressor` -/// from `examples/ml_tuner_demo.rs` (aprender is a dev-dependency — the SIMD foundation -/// must not depend on the ML layer). +/// from `crates/aprender-core/tests/tuner_training_data_quality.rs` (compute has no +/// aprender-core dependency — the SIMD foundation must not depend on the ML layer). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThroughputRegressor { /// Model weights (one per feature + bias) diff --git a/crates/aprender-compute/src/tuner/tests/coverage/gap_coverage.rs b/crates/aprender-compute/src/tuner/tests/coverage/gap_coverage.rs index 356ba8d255..9c4848100d 100644 --- a/crates/aprender-compute/src/tuner/tests/coverage/gap_coverage.rs +++ b/crates/aprender-compute/src/tuner/tests/coverage/gap_coverage.rs @@ -90,13 +90,13 @@ mod calibrate_tests { } // ============================================================================ -// 2-3. ml-tuner RandomForest showcase (RELOCATED — SHOWCASE-BRICK-001) +// 2-3. RandomForest showcase (RELOCATED — SHOWCASE-BRICK-001) // ============================================================================ // The `ThroughputRegressor::with_random_forest` / `train_random_forest` and // `KernelClassifier::train` wrappers were removed: the SIMD compute lib must not // depend on the ML layer (aprender). The aprender RandomForest showcase now lives in -// examples/ml_tuner_demo.rs + tests/tuner_falsification/training_data_quality.rs, -// which consume aprender as a dev-dependency. No lib coverage target remains here. +// crates/aprender-core/tests/tuner_training_data_quality.rs (FLOW-010, #5803), +// which tests it from the aprender-core side. No lib coverage target remains here. // ============================================================================ // 4. crc32_table (helpers.rs, line 10) — const fn coverage diff --git a/crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs b/crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs index 77e2f9ec82..759cab2cd5 100644 --- a/crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs +++ b/crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs @@ -1,101 +1,11 @@ //! F041-F060: Training Data Quality (20 points) //! -//! The `ml-tuner` showcase (SHOWCASE-BRICK-001) feeds trueno `TunerFeatures` into -//! `aprender::RandomForestRegressor`/`Classifier`. aprender is a dev-dependency of -//! aprender-compute (the SIMD foundation never depends on the ML layer), so these -//! falsification tests drive the aprender RandomForest pipeline directly, mirroring the -//! `<10 samples → InsufficientData` guard the old lib wrapper enforced. +//! The aprender RandomForest half (F041-F044, F046-F048) lives in +//! `crates/aprender-core/tests/tuner_training_data_quality.rs`: the SIMD foundation +//! takes no dependency, not even a dev one, on the ML layer (FLOW-010, #5803). use trueno::tuner::{ThroughputRegressor, TunerFeatures}; -#[allow(unused_imports)] -use trueno::tuner::{KernelClassifier, QuantType}; - -/// Train an aprender RandomForest regressor on trueno features, with the showcase's -/// `<10 samples` guard. Returns `Err` on insufficient data or matrix/fit failure. -#[cfg(feature = "ml-tuner")] -fn fit_throughput_rf( - rows: &[(TunerFeatures, f32)], - n_trees: usize, -) -> Result { - use aprender::primitives::{Matrix, Vector}; - use aprender::tree::RandomForestRegressor; - if rows.len() < 10 { - return Err(format!("insufficient data: {} < 10", rows.len())); - } - let dim = TunerFeatures::DIM; - let n = rows.len(); - let mut x = Vec::with_capacity(n * dim); - let mut y = Vec::with_capacity(n); - for (f, t) in rows { - x.extend(f.to_vector()); - y.push(*t); - } - let x_mat = Matrix::from_vec(n, dim, x).map_err(|e| e.to_string())?; - let mut rf = RandomForestRegressor::new(n_trees); - rf.fit(&x_mat, &Vector::from_vec(y)).map_err(|e| e.to_string())?; - Ok(rf) -} - -/// F041: Empty training data should error -#[cfg(feature = "ml-tuner")] -#[test] -fn f041_empty_training_errors() { - let empty_data: Vec<(TunerFeatures, f32)> = vec![]; - let result = fit_throughput_rf(&empty_data, 10); - assert!(result.is_err(), "F041 FALSIFIED: empty training data should error"); -} - -/// F042: Single sample training should error gracefully (below the 10-sample floor) -#[cfg(feature = "ml-tuner")] -#[test] -fn f042_single_sample_graceful() { - let features = TunerFeatures::builder().model_params_b(1.5).build(); - let data = vec![(features, 100.0)]; - // Should error (insufficient), not panic - let _ = fit_throughput_rf(&data, 10); -} - -/// F043: Training with NaN labels should not produce NaN predictions -#[cfg(feature = "ml-tuner")] -#[test] -fn f043_nan_labels_error() { - use aprender::primitives::Matrix; - let mut data: Vec<(TunerFeatures, f32)> = (0..10) - .map(|i| (TunerFeatures::builder().batch_size(1 + (i % 8) as u32).build(), 100.0)) - .collect(); - data[0].1 = f32::NAN; - if let Ok(rf) = fit_throughput_rf(&data, 10) { - let fx = Matrix::from_vec(1, TunerFeatures::DIM, data[1].0.to_vector().to_vec()).unwrap(); - let pred = rf.predict(&fx); - assert!( - pred.as_slice()[0].is_finite() || pred.as_slice()[0].is_nan(), - "F043: prediction must be a well-defined float" - ); - } -} - -/// F044: Training with negative labels should be handled without panic -#[cfg(feature = "ml-tuner")] -#[test] -fn f044_negative_labels_handled() { - let data: Vec<(TunerFeatures, f32)> = (0..12) - .map(|i| { - let f = TunerFeatures::builder().batch_size(1 + (i % 8) as u32).build(); - (f, if i % 2 == 0 { -100.0 } else { 100.0 }) - }) - .collect(); - // Must not panic; result may be Ok or Err. - let _ = fit_throughput_rf(&data, 10); -} - -// Stub tests for non-ml-tuner builds -#[cfg(not(feature = "ml-tuner"))] -#[test] -fn f041_f044_ml_tuner_disabled() { - // Pass - these tests require ml-tuner feature -} - /// F045: Heuristic model should work without training #[test] fn f045_heuristic_no_training() { @@ -106,89 +16,6 @@ fn f045_heuristic_no_training() { assert!(pred.predicted_tps > 0.0, "F045 FALSIFIED: heuristic prediction failed"); } -/// F046: Training improves over heuristic (or doesn't regress) -#[cfg(feature = "ml-tuner")] -#[test] -fn f046_training_improves() { - // Generate training data that matches heuristic pattern - let training_data: Vec<(TunerFeatures, f32)> = (0..50) - .map(|i| { - let batch = 1 + (i % 8) as u32; - let features = TunerFeatures::builder() - .model_params_b(1.5) - .batch_size(batch) - .gpu_mem_bw_gbs(1000.0) - .build(); - // Throughput scales with batch size - let throughput = 100.0 + (batch as f32) * 50.0; - (features, throughput) - }) - .collect(); - - let result = fit_throughput_rf(&training_data, 50); - assert!(result.is_ok(), "F046 FALSIFIED: training failed: {:?}", result.err()); -} - -#[cfg(not(feature = "ml-tuner"))] -#[test] -fn f046_ml_tuner_disabled() { - // Pass -} - -/// F047: Large training set should not OOM -#[cfg(feature = "ml-tuner")] -#[test] -fn f047_large_training_no_oom() { - let training_data: Vec<(TunerFeatures, f32)> = (0..1000) - .map(|i| { - let features = TunerFeatures::builder() - .model_params_b((i % 10) as f32 * 0.5 + 0.5) - .batch_size((i % 8 + 1) as u32) - .build(); - (features, 100.0 + (i as f32)) - }) - .collect(); - - let result = fit_throughput_rf(&training_data, 10); - assert!(result.is_ok(), "F047 FALSIFIED: large training failed"); -} - -#[cfg(not(feature = "ml-tuner"))] -#[test] -fn f047_ml_tuner_disabled() { - // Pass -} - -/// F048: Classifier training should work (aprender RandomForestClassifier on trueno features) -#[cfg(feature = "ml-tuner")] -#[test] -fn f048_classifier_training() { - use aprender::primitives::Matrix; - use aprender::tree::RandomForestClassifier; - - let dim = TunerFeatures::DIM; - let n = 50usize; - let mut x = Vec::with_capacity(n * dim); - let mut y: Vec = Vec::with_capacity(n); - for i in 0..n { - let batch = 1 + (i % 8) as u32; - let f = TunerFeatures::builder().model_params_b(1.5).batch_size(batch).build(); - x.extend(f.to_vector()); - // Label: BatchedQ4K (3) for M>=4, VectorizedQ4K (2) otherwise - y.push(if batch >= 4 { 3 } else { 2 }); - } - let x_mat = Matrix::from_vec(n, dim, x).expect("feature matrix"); - let mut classifier = RandomForestClassifier::new(10); - let result = classifier.fit(&x_mat, &y); - assert!(result.is_ok(), "F048 FALSIFIED: classifier training failed: {:?}", result.err()); -} - -#[cfg(not(feature = "ml-tuner"))] -#[test] -fn f048_ml_tuner_disabled() { - // Pass -} - /// F049: Training data variance check #[test] fn f049_training_data_variance() { diff --git a/crates/aprender-core/tests/tuner_training_data_quality.rs b/crates/aprender-core/tests/tuner_training_data_quality.rs new file mode 100644 index 0000000000..41960a5b1f --- /dev/null +++ b/crates/aprender-core/tests/tuner_training_data_quality.rs @@ -0,0 +1,171 @@ +#![allow(clippy::disallowed_methods, clippy::float_cmp)] +//! F041-F060 (aprender RandomForest half): Training Data Quality, ML-tuner showcase. +//! +//! SHOWCASE-BRICK-001 feeds trueno `TunerFeatures` into `aprender::RandomForestRegressor` +//! and `RandomForestClassifier`. These tests lived in aprender-compute behind a +//! `ml-tuner` feature nothing enabled, driven by a dev-dependency of compute on core. +//! That dev edge tied compute (the base of the stack) to core, so a touch of any base +//! crate selected most of the workspace (FLOW-010, #5803). Core depends on compute, so a +//! change to compute still selects these tests here. The tests that need no aprender stay +//! in `crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs`. + +use trueno::tuner::TunerFeatures; + +/// Train an aprender RandomForest regressor on trueno features, with the showcase's +/// `<10 samples` guard. Returns `Err` on insufficient data or matrix/fit failure. +fn fit_throughput_rf( + rows: &[(TunerFeatures, f32)], + n_trees: usize, +) -> Result { + use aprender::primitives::{Matrix, Vector}; + use aprender::tree::RandomForestRegressor; + if rows.len() < 10 { + return Err(format!("insufficient data: {} < 10", rows.len())); + } + let dim = TunerFeatures::DIM; + let n = rows.len(); + let mut x = Vec::with_capacity(n * dim); + let mut y = Vec::with_capacity(n); + for (f, t) in rows { + x.extend(f.to_vector()); + y.push(*t); + } + let x_mat = Matrix::from_vec(n, dim, x).map_err(|e| e.to_string())?; + let mut rf = RandomForestRegressor::new(n_trees); + rf.fit(&x_mat, &Vector::from_vec(y)) + .map_err(|e| e.to_string())?; + Ok(rf) +} + +/// F041: Empty training data should error +#[test] +fn f041_empty_training_errors() { + let empty_data: Vec<(TunerFeatures, f32)> = vec![]; + let result = fit_throughput_rf(&empty_data, 10); + assert!( + result.is_err(), + "F041 FALSIFIED: empty training data should error" + ); +} + +/// F042: Single sample training should error gracefully (below the 10-sample floor) +#[test] +fn f042_single_sample_graceful() { + let features = TunerFeatures::builder().model_params_b(1.5).build(); + let data = vec![(features, 100.0)]; + // Should error (insufficient), not panic + let _ = fit_throughput_rf(&data, 10); +} + +/// F043: Training with NaN labels should not produce NaN predictions +#[test] +fn f043_nan_labels_error() { + use aprender::primitives::Matrix; + let mut data: Vec<(TunerFeatures, f32)> = (0..10) + .map(|i| { + ( + TunerFeatures::builder() + .batch_size(1 + (i % 8) as u32) + .build(), + 100.0, + ) + }) + .collect(); + data[0].1 = f32::NAN; + if let Ok(rf) = fit_throughput_rf(&data, 10) { + let fx = Matrix::from_vec(1, TunerFeatures::DIM, data[1].0.to_vector().to_vec()).unwrap(); + let pred = rf.predict(&fx); + assert!( + pred.as_slice()[0].is_finite() || pred.as_slice()[0].is_nan(), + "F043: prediction must be a well-defined float" + ); + } +} + +/// F044: Training with negative labels should be handled without panic +#[test] +fn f044_negative_labels_handled() { + let data: Vec<(TunerFeatures, f32)> = (0..12) + .map(|i| { + let f = TunerFeatures::builder() + .batch_size(1 + (i % 8) as u32) + .build(); + (f, if i % 2 == 0 { -100.0 } else { 100.0 }) + }) + .collect(); + // Must not panic; result may be Ok or Err. + let _ = fit_throughput_rf(&data, 10); +} + +/// F046: Training improves over heuristic (or doesn't regress) +#[test] +fn f046_training_improves() { + // Generate training data that matches heuristic pattern + let training_data: Vec<(TunerFeatures, f32)> = (0..50) + .map(|i| { + let batch = 1 + (i % 8) as u32; + let features = TunerFeatures::builder() + .model_params_b(1.5) + .batch_size(batch) + .gpu_mem_bw_gbs(1000.0) + .build(); + // Throughput scales with batch size + let throughput = 100.0 + (batch as f32) * 50.0; + (features, throughput) + }) + .collect(); + + let result = fit_throughput_rf(&training_data, 50); + assert!( + result.is_ok(), + "F046 FALSIFIED: training failed: {:?}", + result.err() + ); +} + +/// F047: Large training set should not OOM +#[test] +fn f047_large_training_no_oom() { + let training_data: Vec<(TunerFeatures, f32)> = (0..1000) + .map(|i| { + let features = TunerFeatures::builder() + .model_params_b((i % 10) as f32 * 0.5 + 0.5) + .batch_size((i % 8 + 1) as u32) + .build(); + (features, 100.0 + (i as f32)) + }) + .collect(); + + let result = fit_throughput_rf(&training_data, 10); + assert!(result.is_ok(), "F047 FALSIFIED: large training failed"); +} + +/// F048: Classifier training should work (aprender RandomForestClassifier on trueno features) +#[test] +fn f048_classifier_training() { + use aprender::primitives::Matrix; + use aprender::tree::RandomForestClassifier; + + let dim = TunerFeatures::DIM; + let n = 50usize; + let mut x = Vec::with_capacity(n * dim); + let mut y: Vec = Vec::with_capacity(n); + for i in 0..n { + let batch = 1 + (i % 8) as u32; + let f = TunerFeatures::builder() + .model_params_b(1.5) + .batch_size(batch) + .build(); + x.extend(f.to_vector()); + // Label: BatchedQ4K (3) for M>=4, VectorizedQ4K (2) otherwise + y.push(if batch >= 4 { 3 } else { 2 }); + } + let x_mat = Matrix::from_vec(n, dim, x).expect("feature matrix"); + let mut classifier = RandomForestClassifier::new(10); + let result = classifier.fit(&x_mat, &y); + assert!( + result.is_ok(), + "F048 FALSIFIED: classifier training failed: {:?}", + result.err() + ); +} diff --git a/scripts/check_tuner_test_move.sh b/scripts/check_tuner_test_move.sh new file mode 100755 index 0000000000..a36e8d7471 --- /dev/null +++ b/scripts/check_tuner_test_move.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# check_tuner_test_move.sh -- the F041-F060 training-data-quality tests moved from aprender-compute to +# aprender-core (FLOW-010, #5803) must still be COMPILED, LISTED and NOT IGNORED by the test harness. +# BEHAVIOUR, not text: it asks cargo what each target would run, so no source-level re-gating trick +# (#[cfg], #[ignore], macro_rules! shadowing, #[path] mods, code joined onto a // line) can hide a test. +# 1. `cargo test -p aprender-core --test tuner_training_data_quality -- --list` plus +# `cargo test -p aprender-compute --test tuner_falsification -- --list` (only training_data_quality::) +# must name exactly the 11 pre-move tests, each once; a cargo failure or empty list is RED (fail closed). +# 2. the same two targets with `--list --ignored` must name none of them. +# 3. compute's Cargo.toml names no aprender-core and no compute test/example calls `aprender::`. +# 4. content pin, VERBATIM bytes (no whitespace normalising -- that let code be joined onto a `//` line): a digest of +# each moved test fn with its doc/attr lines, a digest of each file from its first item on, and the header before +# it may hold only blank/`//!`/`#![allow]`/single `use` lines (no macro_rules!). The moved bodies equal the +# merge-base bodies up to rustfmt and the dropped `#[cfg(feature = "ml-tuner")]` lines (proved in the PR body). +# Text cannot prove a test runs; parts 1-2 do. Editing a moved test means updating its digest here in the same PR. +# `--self-test` feeds synthetic cargo listings, so it needs no compile; the mutants must turn it RED. +set -u +ROOT=$(cd "$(dirname "$0")/.." && pwd) +CF=crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs +MAINF=crates/aprender-compute/tests/tuner_falsification/main.rs +KF=crates/aprender-core/tests/tuner_training_data_quality.rs +WANT="f041_empty_training_errors f042_single_sample_graceful f043_nan_labels_error f044_negative_labels_handled f045_heuristic_no_training f046_training_improves f047_large_training_no_oom f048_classifier_training f049_training_data_variance f050_feature_correlation f051_to_f060_reserved" + +DIGESTS=" +f041_empty_training_errors 070237018993271c +f042_single_sample_graceful bc88d040de7f6792 +f043_nan_labels_error 9fd06ac9384bfcaa +f044_negative_labels_handled fbb01296c746a127 +f045_heuristic_no_training 8cabd670f956b0d0 +f046_training_improves f221030467215901 +f047_large_training_no_oom 140c3b8e5cb1d85b +f048_classifier_training 72c5d41797dc1285 +f049_training_data_variance 1f2fd8cbb55e4919 +f050_feature_correlation 0a494cbb1416adde +f051_to_f060_reserved fbb8c9ddc4be9944 +fit_throughput_rf 7da5ef57b0d6baeb +@tail:crates/aprender-core/tests/tuner_training_data_quality.rs e599e682001e6f49 +@tail:crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs 4268c362cc9890b0 +@file:crates/aprender-compute/tests/tuner_falsification/main.rs 2a75089411202f35 +" + +# body FILE NAME -> VERBATIM text of `fn NAME(` with its leading `///` and `#[..]` lines, up to its closing `}` at column 0. +# No whitespace is stripped: stripping newlines is what let code be joined onto a `//` line and still hash equal. +body() { + awk -v n="$2" '/^(\/\/\/|#\[)/ { pend = pend $0 "\n"; next } + $0 ~ "^fn "n"\\(" { printf "%s", pend; p = 1 } + p { print } p && /^}/ { exit } + { pend = "" }' "$1" +} +# tail FILE -> everything from the first item (`///`, `#[test]` or `fn`) on; the header before it is checked, not hashed +tailof() { awk '!t && /^(\/\/\/|#\[test\]|fn )/ { t = 1 } t { print }' "$1"; } +# header_ok FILE -> rc 0 only when every line before the first item is blank, `//!`, `#![allow(..)]`, `#[allow(unused_imports)]` +# or ONE single-statement `use trueno|aprender::..;` -- so no macro_rules!, mod, #[path] or fn can hide in the header +header_ok() { awk '/^(\/\/\/|#\[test\]|fn )/ { exit } { print }' "$1" | /usr/bin/grep -v -E '^$|^//!|^#!\[allow\([a-z_:, ]*\)\]$|^#\[allow\(unused_imports\)\]$|^use (trueno|aprender)::[^;]*;$' | { ! /usr/bin/grep -q .; }; } +# listed FILE KIND -> sorted names of the moved tests in a `--list` output (KIND core: every test; comp: training_data_quality:: only) +listed() { + awk -v kind="$2" '/: test$/ { n = $0; sub(/: test$/, "", n) + if (kind == "comp") { if (n !~ /^training_data_quality::/) next; sub(/^training_data_quality::/, "", n) } + print n }' "$1" 2> /dev/null | sort +} +# verdict_lists CORE COMP CORE_IGN COMP_IGN -> problems; rc 0 only when none +verdict_lists() { + local bad=0 got want x + want=$(printf '%s\n' $WANT | sort) + for x in "$1" "$2"; do [ -s "$x" ] || { echo "empty or missing cargo listing ($x): fail closed"; bad=1; }; done + got=$({ listed "$1" core; listed "$2" comp; } | sort) + [ "$got" = "$want" ] || { echo "listed tests differ from the 11 pre-move names: got: $(echo $got)"; bad=1; } + got=$({ listed "$3" core; listed "$4" comp; } | sort) + [ -z "$got" ] || { echo "ignored moved tests: $(echo $got)"; bad=1; } + return $bad +} +# verdict_static ROOT -> Cargo dependency, aprender:: calls, content pin +verdict_static() { + local r=$1 bad=0 n d b + while read -r n d; do + [ -n "$n" ] || continue + case $n in + @tail:*) b=$(tailof "$r/${n#@tail:}") ;; + @file:*) b=$(awk 1 "$r/${n#@file:}" 2> /dev/null) ;; + *) b=$(body "$r/$CF" "$n"); [ -n "$b" ] || b=$(body "$r/$KF" "$n") ;; + esac + [ "$(printf '%s\n' "$b" | sha256sum | cut -c1-16)" = "$d" ] || { echo "$n differs from its pinned verbatim digest (empty or edited)"; bad=1; } + done <<< "$DIGESTS" + for b in "$CF" "$KF"; do header_ok "$r/$b" || { echo "$b: header before the first item holds more than blank, //!, #![allow], use lines"; bad=1; }; done + for b in aprender-compute aprender-core; do # no [[test]] may redirect/disable either moved target, no autotests = false + awk '/^autotests[[:space:]]*=/ { print "autotests set"; } + /^\[\[test\]\]/ { t = 1; next } /^\[/ { t = 0 } + t && /^name[[:space:]]*=[[:space:]]*"(tuner_falsification|tuner_training_data_quality)"/ { print "[[test]] names a moved target" }' "$r/crates/$b/Cargo.toml" | /usr/bin/grep -q . && { echo "crates/$b/Cargo.toml: autotests set or a [[test]] redirects a moved target"; bad=1; } + done + if grep -v '^[[:space:]]*#' "$r/crates/aprender-compute/Cargo.toml" | grep -q 'aprender-core'; then echo "compute Cargo.toml names aprender-core"; bad=1; fi + if grep -rn --include='*.rs' -E '\baprender::' "$r/crates/aprender-compute/tests" "$r/crates/aprender-compute/examples" 2>/dev/null | grep -v -E '^[^:]+:[0-9]+:[[:space:]]*//' | grep -q .; then + echo "compute tests/examples call aprender::"; bad=1 + fi + return $bad +} +# cargo_lists ROOT DIR -> writes DIR/{core,comp,core_ign,comp_ign}.txt from cargo; a cargo failure leaves the file empty (RED) +cargo_lists() { + local r=$1 d=$2 + ( env -C "$r" cargo test --quiet -p aprender-core --test tuner_training_data_quality -- --list 2> /dev/null ) > "$d/core.txt" || : > "$d/core.txt" + ( env -C "$r" cargo test --quiet -p aprender-compute --test tuner_falsification -- --list 2> /dev/null ) > "$d/comp.txt" || : > "$d/comp.txt" + ( env -C "$r" cargo test --quiet -p aprender-core --test tuner_training_data_quality -- --list --ignored 2> /dev/null ) > "$d/core_ign.txt" || echo FAILED > "$d/core_ign.txt" + ( env -C "$r" cargo test --quiet -p aprender-compute --test tuner_falsification -- --list --ignored 2> /dev/null ) > "$d/comp_ign.txt" || echo FAILED > "$d/comp_ign.txt" +} +verdict() { # ROOT -> problems; rc 0 only when none + local r=$1 d bad=0 + d=$(mktemp -d) || return 2 + cargo_lists "$r" "$d" + [ "$(cat "$d/core_ign.txt")" = FAILED ] || [ "$(cat "$d/comp_ign.txt")" = FAILED ] && { echo "cargo --list --ignored failed: fail closed"; bad=1; } + verdict_lists "$d/core.txt" "$d/comp.txt" "$d/core_ign.txt" "$d/comp_ign.txt" || bad=1 + verdict_static "$r" || bad=1 + rm -rf "${d:?}" + return $bad +} +if [ "${1:-}" = "--self-test" ]; then + t=$(mktemp -d) || exit 2; trap 'rm -rf "${t:?}"' EXIT + n=0; f=0 + mkl() { # DIR -> a good set of listings + mkdir -p "$t/$1"; : > "$t/$1/core.txt"; : > "$t/$1/comp.txt"; : > "$t/$1/ci.txt"; : > "$t/$1/pi.txt" + for w in $WANT; do case $w in f041*|f042*|f043*|f044*|f046*|f047*|f048*) echo "$w: test" >> "$t/$1/core.txt" ;; *) echo "training_data_quality::$w: test" >> "$t/$1/comp.txt" ;; esac; done + echo "other::unrelated: test" >> "$t/$1/comp.txt"; echo "0 tests, 0 benchmarks" >> "$t/$1/ci.txt"; echo "0 tests, 0 benchmarks" >> "$t/$1/pi.txt"; } + lcase() { # NAME WANT_RC DIR + ( verdict_lists "$t/$3/core.txt" "$t/$3/comp.txt" "$t/$3/ci.txt" "$t/$3/pi.txt" > /dev/null ); rc=$? + if { [ "$2" = 0 ] && [ $rc -eq 0 ]; } || { [ "$2" != 0 ] && [ $rc -ne 0 ]; }; then n=$((n+1)); echo "ok $1"; else f=$((f+1)); echo "FAIL $1 (rc=$rc)"; fi; } + mkl g; lcase "L1 good listing: the 11 once each, none ignored" 0 g + mkl a; sed -i '/f041_/d' "$t/a/core.txt"; lcase "L2 MUTANT: a moved test not listed (cfg'd out / shadowed / #[path]'d)" 1 a + mkl b; echo "f041_empty_training_errors: test" >> "$t/b/core.txt"; lcase "L3 MUTANT: a test listed twice" 1 b + mkl c; echo "f046_training_improves: test" > "$t/c/ci.txt"; lcase "L4 MUTANT: a moved test is #[ignore]d (in --ignored)" 1 c + mkl d; : > "$t/d/comp.txt"; lcase "L5 MUTANT: compute listing empty (cargo failed): fail closed" 1 d + mkl e; sed -i '/f049_/d' "$t/e/comp.txt"; lcase "L6 MUTANT: compute target lost its module (CI step off / mod removed)" 1 e + mkl h; echo "training_data_quality::f052_extra: test" >> "$t/h/comp.txt"; lcase "L7 MUTANT: an unaccounted test in the moved module" 1 h + mkl i; echo "training_data_quality::f045_heuristic_no_training: test" > "$t/i/pi.txt"; lcase "L8 MUTANT: a compute test is ignored" 1 i + # static rows run on copies of the real files + mk() { mkdir -p "$t/$1/crates/aprender-compute/tests/tuner_falsification" "$t/$1/crates/aprender-compute/examples" "$t/$1/crates/aprender-core/tests" + cp "$ROOT/$CF" "$t/$1/$CF"; cp "$ROOT/$KF" "$t/$1/$KF"; cp "$ROOT/crates/aprender-compute/Cargo.toml" "$t/$1/crates/aprender-compute/Cargo.toml" + mkdir -p "$t/$1/crates/aprender-core"; cp "$ROOT/crates/aprender-core/Cargo.toml" "$t/$1/crates/aprender-core/Cargo.toml" + cp "$ROOT/$MAINF" "$t/$1/$MAINF"; } + scase() { ( verdict_static "$t/$3" > /dev/null ); rc=$? + if { [ "$2" = 0 ] && [ $rc -eq 0 ]; } || { [ "$2" != 0 ] && [ $rc -ne 0 ]; }; then n=$((n+1)); echo "ok $1"; else f=$((f+1)); echo "FAIL $1 (rc=$rc)"; fi; } + mk s0; scase "S1 current tree: pins hold, compute has no aprender-core" 0 s0 + mk s1; sed -i 's/F046 FALSIFIED/F046 weakened/' "$t/s1/$KF"; scase "S2 MUTANT: a moved test body edited" 1 s1 + mk s2; sed -i '/fn f044_negative_labels_handled/,/^}/{s/\.build()/.build(); let _ = 0/}' "$t/s2/$KF"; scase "S3 MUTANT: a moved test body changed in logic" 1 s2 + mk s3; printf '[dev-dependencies]\naprender = { path = "../aprender-core", package = "aprender-core" }\n' >> "$t/s3/crates/aprender-compute/Cargo.toml"; scase "S4 MUTANT: dev-dep on aprender-core back" 1 s3 + mk s4; printf 'use aprender::tree::RandomForestRegressor;\n' > "$t/s4/crates/aprender-compute/examples/x.rs"; scase "S5 MUTANT: compute example calls aprender::" 1 s4 + mk s5; sed -i 's/< 10/< 11/' "$t/s5/$KF"; scase "S6 MUTANT: helper fit_throughput_rf guard changed" 1 s5 + mk s6; sed -i '/Should error (insufficient), not panic/{N;s/\n */ /}' "$t/s6/$KF"; scase "S7 MUTANT: code joined onto a // line (the r3/r4 hole)" 1 s6 + mk s7; sed -i '/^use trueno::tuner::TunerFeatures;/a macro_rules! assert { ($($t:tt)*) => {}; }' "$t/s7/$KF"; scase "S8 MUTANT: macro_rules! assert shadow after the use line" 1 s7 + mk s8; sed -i '/^use trueno::tuner::TunerFeatures;/a #[path = "elsewhere.rs"] mod shadow;' "$t/s8/$KF"; scase "S9 MUTANT: #[path] mod in the header" 1 s8 + mk s9; sed -i 's/F045 FALSIFIED/F045 weakened/' "$t/s9/$CF"; scase "S10 MUTANT: a compute-resident test body edited" 1 s9 + mk s10; printf '\n#[test]\nfn f099_extra() {}\n' >> "$t/s10/$KF"; scase "S11 MUTANT: an extra test appended to the moved file (tail digest)" 1 s10 + mk s11; sed -i '1s/$/ macro_rules! assert { ($($t:tt)*) => {}; }/' "$t/s11/$KF"; scase "S12 MUTANT: code on the #![allow] header line (end-anchor)" 1 s11 + mk s12; sed -i 's/^mod training_data_quality;/#[path = "elsewhere.rs"] mod training_data_quality;/' "$t/s12/$MAINF"; scase "S13 MUTANT: compute main.rs redirects the module" 1 s12 + mk s13; printf '\n[[test]]\nname = "tuner_falsification"\npath = "tests/elsewhere.rs"\n' >> "$t/s13/crates/aprender-compute/Cargo.toml"; scase "S14 MUTANT: [[test]] redirects the compute target" 1 s13 + mk s14; printf '\n[[test]]\nname = "tuner_training_data_quality"\npath = "tests/elsewhere.rs"\n' >> "$t/s14/crates/aprender-core/Cargo.toml"; scase "S15 MUTANT: [[test]] redirects the core target" 1 s14 + echo "tuner test move rows: $n passed, $f failed"; [ $f -eq 0 ]; exit +fi +out=$(verdict "${1:-$ROOT}") && { echo "tuner test move: cargo lists the 11 moved tests once each, none ignored; compute has no aprender-core"; exit 0; } +printf '%s\n' "$out"; exit 1 diff --git a/scripts/guards_nightly_manifest.txt b/scripts/guards_nightly_manifest.txt index fd9cd3d0fd..f20081fb67 100644 --- a/scripts/guards_nightly_manifest.txt +++ b/scripts/guards_nightly_manifest.txt @@ -56,3 +56,5 @@ 5 Coverage scan files no per-function ticket (LAB, #5879) # #5809 (FLOW-010 E5): affected-set tool case tables, LAB from the first night (never on a PR path, C324). Seconds are a local sample. 40 Affected-set tool and readers guards (FLOW-010 E5, #5809) +# #5803: tuner test-move guard (cargo -- --list), LAB from the first night, nightly only (never on a PR path, C324); the moved tests run from ci/explicit-test-commands.d 295 and 298. Seconds are a local sample. +60 Tuner test-move guard (#5803) From c78d562d4c29f8a0a656bc7ab7ff47836a5bf5e7 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 12:55:33 +0200 Subject: [PATCH 03/11] ci: run parity_receipt_denominator.sh verify mode in guards-nightly as a LAB step (Refs #5502) Refs #5514 PMAT-Ticket: PMAT-5502 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt (cherry picked from commit 40640c33fab3393d6600cc15c79510a2586d255c; the PR-path step moved to guards-nightly) (cherry picked from commit dd2ed0b0018e0fd415cf2cee392dfeeeb72e3425) (cherry picked from commit e1a32e7e79fb6a0d05b208e198a4b4c9a506520f) (cherry picked from commit 13e775c96d9f1ba21a59a301598b7c1d51f6e68f) (cherry picked from commit eb77db3e0a76968824440a7bdf4dcc66e2b61574) (cherry picked from commit 121cff893491a9741a242760efaecb8cf09e7c89) (cherry picked from commit bd7eabf46a9a81bb1f755d03fb72f624c97c72dc) (cherry picked from commit 04e3e7dc89aabf163073d27a0b7ce1564a7b5dcc) (cherry picked from commit 2e39c2619b2ec78a131d0f7be5cf809b5a93b5d3) (cherry picked from commit d1b6a7cab40535c4ade7615525bb15a0a0fba6a3) --- .github/workflows/guards-nightly.yml | 17 +++++++++++++++++ docs/roadmaps/entries/PMAT-5502.yaml | 19 +++++++++++++++++++ docs/roadmaps/roadmap.yaml | 19 +++++++++++++++++++ scripts/guards_nightly_manifest.txt | 2 ++ 4 files changed, 57 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5502.yaml diff --git a/.github/workflows/guards-nightly.yml b/.github/workflows/guards-nightly.yml index 3ab9095133..5e7f1acdce 100644 --- a/.github/workflows/guards-nightly.yml +++ b/.github/workflows/guards-nightly.yml @@ -181,6 +181,13 @@ jobs: run: setsid --wait bash scripts/tests/compute_row_zero_tests_test.sh --mutants - name: shared-checkout shallow lock case table and mutants (#5025, LAB) run: setsid --wait bash scripts/shared_checkout_no_shallow.sh --mutants + - name: Parity-receipt count equals EXPECTED_RECEIPTS (verify mode, #5502, LAB) + id: lab_parity_receipts + if: ${{ !cancelled() }} + run: | + set -euo pipefail + exec > >(tee -a "$RUNNER_TEMP/parity-receipts.log") 2>&1 + setsid --wait bash scripts/parity_receipt_denominator.sh - name: nextest list-wrapper case table (#5298, LAB) run: setsid --wait bash scripts/ci/nextest_list_wrapper.sh --self-test - name: affected_set.sh case table and mutants (FLOW-010 E5, LAB) @@ -449,6 +456,16 @@ jobs: set -euo pipefail bash scripts/lab_ticket.sh --label lab-tuner-test-move --title "LAB: nightly tuner test-move guard red (#5803)" \ --owner aprender-w5018 --log "$RUNNER_TEMP/tuner-move.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" + - name: A red parity-receipt night opens or updates its one LAB ticket (#5502) + if: failure() && steps.lab_parity_receipts.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + bash scripts/lab_ticket.sh --label lab-parity-receipts --title "LAB: parity-receipt count differs from EXPECTED_RECEIPTS (#5502)" \ + --owner aprender-w5018 --log "$RUNNER_TEMP/parity-receipts.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" - name: A red census night opens or updates the one LAB ticket (#4709) if: failure() && steps.ratchet_census.outcome == 'failure' env: diff --git a/docs/roadmaps/entries/PMAT-5502.yaml b/docs/roadmaps/entries/PMAT-5502.yaml new file mode 100644 index 0000000000..f898a773b7 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5502.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5502 + github_issue: 5502 + item_type: task + title: Wire parity_receipt_denominator.sh verify mode into CI (#5502) + status: planned + priority: medium + assigned_to: null + created: 2026-10-18T10:18:08Z + updated: 2026-10-18T10:18:08Z + spec: null + acceptance_criteria: + - 'Slice of #5309 (debt ratchet)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3997 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 31342f4a65..b11e795e0d 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25321,6 +25321,25 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 3998 +- id: PMAT-5502 + github_issue: 5502 + item_type: task + title: Wire parity_receipt_denominator.sh verify mode into CI (#5502) + status: planned + priority: medium + assigned_to: null + created: 2026-10-18T10:18:08Z + updated: 2026-10-18T10:18:08Z + spec: null + acceptance_criteria: + - 'Slice of #5309 (debt ratchet)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3997 - id: PMAT-5737 github_issue: 5737 item_type: task diff --git a/scripts/guards_nightly_manifest.txt b/scripts/guards_nightly_manifest.txt index f20081fb67..a0841624ad 100644 --- a/scripts/guards_nightly_manifest.txt +++ b/scripts/guards_nightly_manifest.txt @@ -49,6 +49,8 @@ # #4709 (C303): the pv census ratchet, LAB from its first night (never on a PR path, C324). # Seconds are a local wall-clock sample (2026-10-14, pv prebuilt); the first nightly replaces them. 6 The pv census bound share may only rise (#4709) +# #5502: parity-receipt count vs EXPECTED_RECEIPTS (verify mode), LAB, nightly only (C324); seconds are a guess until the first night. +5 Parity-receipt count equals EXPECTED_RECEIPTS (verify mode, #5502, LAB) # #5873: the harness fixture-server cleanup check, quarantined from ci/explicit-test-commands.d/817 (its apr-stub row races); LAB, never on a PR path (C324). 60 Harness fixture servers die with their harness (LAB, quarantined from the merge path, #5873) 1 affected_set.sh case table and mutants (FLOW-010 E5, LAB) From b92b119b05dab594b345e33509ecef4826ab2a4a Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 13:17:53 +0200 Subject: [PATCH 04/11] feat(simulate): course_flow Content Factory v2 (FLOW-007 B5a, Refs #5557) Squash of #5830, witness/contracts.nt/README regenerated on main. PMAT-Ticket: PMAT-5557 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt-2 (cherry picked from commit 930164512b1826d366ae6f4c0c62d699a9c1ab90) (cherry picked from commit 32896d96e4449329b33adfc023f09109c3b1642f) (cherry picked from commit 8c748b2deab2f30dc6a3759e9672afd8707fac00) (cherry picked from commit 2c7552190d8ba0161846313840997a33dc318824) (cherry picked from commit 8c2a25bd377559ea6a72fd8835180ce712707ac7) (cherry picked from commit 89ff411f344502db5055fe1366c25a6f4d8a1c1e) (cherry picked from commit ab1a4934818c7c46afcc38274b1a129811a4dc9a) (cherry picked from commit f297101ee604adef3337e41f5efe7296c7552297) (cherry picked from commit ab4a55be29ff39b1dc13b12a1c1a06c60e526c41) (cherry picked from commit 1a3e09879c9188d3561e20adbb73d0f58d0593c1) --- README.md | 4 +- contracts/contracts.nt | 7 + .../course-flow-content-factory-v2-v1.yaml | 66 ++++ ...5f0be2f90923120a3027c176ad56723d0d850.json | 2 +- .../src/scenarios/course_flow.rs | 300 +++++++++++++++++- docs/findings/w5032.jsonl | 1 + 6 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 contracts/course-flow-content-factory-v2-v1.yaml create mode 100644 docs/findings/w5032.jsonl diff --git a/README.md b/README.md index 278745b91a..e5d819f98e 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ publishing — all backed by YAML provable contracts that fail CI on drift. | Metric | Count | Source of truth | |-------:|------:|---| | Workspace crates | **81** workspace crates | `cargo metadata --no-deps` (NOT `ls crates/` — 4 are `exclude`d, 1 has no Cargo.toml) | -| Provable contracts | **2005** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | +| Provable contracts | **2006** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | | CLI commands | **113** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` also lists `help` itself, and `grep -c '^ - name:'` gives 119 — other same-indent `name:` keys exist in the file) | | Book CLI chapters | **114** chapters | `ls book/src/cli/*.md` | | Book lib chapters | **73** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | @@ -403,7 +403,7 @@ falsification_tests: prediction: apr validate bad-model.apr exits non-zero ``` -The tree carries 2005 contracts across inference, training, quantization, attention, FFN, +The tree carries 2006 contracts across inference, training, quantization, attention, FFN, tokenization, model formats, CLI safety — and this README itself. ## Migration from old crates diff --git a/contracts/contracts.nt b/contracts/contracts.nt index 49ac2468bb..ec2f488990 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -9774,6 +9774,13 @@ "course-flow-batch-wip-v1"^^ . "course-flow-batch-wip"^^ . "1.0.0"^^ . + . + . + . + "contracts/course-flow-content-factory-v2-v1.yaml"^^ . + "course-flow-content-factory-v2-v1"^^ . + "course-flow-content-factory-v2"^^ . + "1.0.0"^^ . . . . diff --git a/contracts/course-flow-content-factory-v2-v1.yaml b/contracts/course-flow-content-factory-v2-v1.yaml new file mode 100644 index 0000000000..b1641054ab --- /dev/null +++ b/contracts/course-flow-content-factory-v2-v1.yaml @@ -0,0 +1,66 @@ +metadata: + version: "1.0.0" + created: "2026-10-19" + author: PAIML Engineering + registry: true + references: + - "flow model spec, internal (eqs 1 and 2)" + - "FLOW-007 row B5 (#5557)" + description: "course_flow models Content Factory v2: three-platform fan-out, rework loop, trunk-first GPU rule, LinkedIn renders, work vs elapsed build time" + +name: course-flow-content-factory-v2 +version: "1.0.0" +scope: "crates/aprender-simulate/src/scenarios/course_flow.rs" + +description: | + The factory-v2 case sends every master to edX, Coursera and LinkedIn with no split. + A build repeats with probability alpha, so the mean build work is B/(1-alpha). While the + running mean lead exceeds trunk_first_w_d days the GPU serves courses before Shorts. + LinkedIn adds li_k + 4 renders per master on the GPU. The result keeps build work + (sizing, eq 2) apart from build elapsed time (lead time, eq 1). + +equations: + coursera_stage_load: + description: "Coursera stage busy fraction when every master takes the stage (the Chrome lanes also carry the Shorts stage, which the test adds, about 0.87 points)" + formula: "busy = starts_per_day * stage_min / (chrome_lanes * 1440) = 0.625, plus the Shorts stage load" + + rework_build_work: + description: "mean build work per course under the rework loop" + formula: "B' = B / (1 - alpha)" + +falsification_tests: + - id: FALSIFY-001 + description: "Coursera stage busy matches the law within 2 points" + test: "cargo test -p aprender-simulate --lib coursera_stage_matches_the_law_within_two_points" + prediction: "the test passes" + if_fails: "the fan-out does not send every master to the Coursera stage" + + - id: FALSIFY-002 + description: "at alpha 0.16 the build stage is busy at the law's 94.7% within two points" + test: "cargo test -p aprender-simulate --lib build_busy_at_alpha_016_matches_the_law" + prediction: "the test passes" + if_fails: "the rework loop is missing or mis-sized" + + - id: FALSIFY-003 + description: "mean build work is B/(1-alpha)" + test: "cargo test -p aprender-simulate --lib rework_loop_scales_build_work_by_one_over_one_minus_alpha" + prediction: "the test passes" + if_fails: "the rework loop does not scale build work by 1/(1-alpha)" + + - id: FALSIFY-004 + description: "build elapsed time exceeds build work (they are reported apart)" + test: "cargo test -p aprender-simulate --lib build_elapsed_is_not_the_build_work" + prediction: "the test passes" + if_fails: "work and elapsed build time are conflated" + + - id: FALSIFY-005 + description: "the trunk-first rule is not a no-op: the rule fires only on the render stage, with a positive threshold, after a course finished and while the mean lead exceeds it, and turning it on changes the run (LinkedIn renders are covered by linkedin_renders_load_the_gpu, not by this test)" + test: "cargo test -p aprender-simulate --lib trunk_first_rule_changes_the_outcome" + prediction: "the test passes" + if_fails: "the GPU priority rule is dead" + + - id: FALSIFY-006 + description: "alpha is a probability below 1 (1 or more would repeat a build forever) and li_k is bounded to [0, 64] (li_k + 4 renders per master)" + test: "cargo test -p aprender-simulate --lib alpha_must_be_below_one" + prediction: "the test passes" + if_fails: "apply_course_inputs accepts an alpha that never terminates" diff --git a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json index 5b01d0e1b1..bf2f76b233 100644 --- a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json +++ b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json @@ -1,5 +1,5 @@ { - "census_id_set_sha256": "e01a28975ffa19f5a1ed1edd3f59d7eacd73a80decd44c723be4fe5dd9b53e7e", + "census_id_set_sha256": "4039ff1d878c323278405342882da062310a234a5674cf56a0086a75015eb880", "relations_sha256": "811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850", "checkable_n": 87, "result": { diff --git a/crates/aprender-simulate/src/scenarios/course_flow.rs b/crates/aprender-simulate/src/scenarios/course_flow.rs index cb59dd42e2..1cbb486724 100644 --- a/crates/aprender-simulate/src/scenarios/course_flow.rs +++ b/crates/aprender-simulate/src/scenarios/course_flow.rs @@ -40,6 +40,16 @@ pub struct CourseFlowConfig { pub verify_min: f64, /// Count a finished course as WIP until the daily batch releases it (flow model B6). pub batch_in_wip: bool, + /// Content Factory v2 (B5a): every master fans out to edX, Coursera and LinkedIn; `coursera_share` is ignored. + pub fanout: bool, + /// Rework probability after a build (B5b): a build repeats with this probability, so B' = B/(1-alpha). + pub alpha: f64, + /// Trunk-first GPU rule (B5c): while the running mean lead exceeds this many days the GPU serves + /// courses before Shorts. 0 keeps Shorts first always. + pub trunk_first_w_d: f64, + /// LinkedIn renders per master are `li_k + 4` on the GPU (B5d). + pub li_k: usize, + pub li_render_min: f64, } /// What one seeded run measured after the warm-up. @@ -52,6 +62,10 @@ pub struct CourseFlowResult { pub util: [f64; 6], /// Mean hours a built course waits for the daily batch (courses that take the batch path). pub mean_batch_wait_h: f64, + /// Mean build service hours per course, rework included: the sizing figure (flow model eq 2). + pub t_b_work_h: f64, + /// Mean hours from first build queueing to last build exit: the lead-time figure (eq 1). + pub t_b_elapsed_h: f64, } impl CourseFlowResult { @@ -142,6 +156,16 @@ struct Sim { in_system: usize, l_area: f64, last_t: f64, + left_wip: Vec, + left_launch: Vec, + li_left: Vec, + build_in: Vec, + build_work: Vec, + tb_work: f64, + tb_elapsed: f64, + tb_n: u64, + lead_sum: f64, + lead_n: u64, } impl Sim { @@ -157,7 +181,15 @@ impl Sim { fn enqueue(&mut self, s: usize, job: Job, mean: f64) { let d = self.erlang4(mean); - if job.short { + if s == BUILD && !job.short { + self.build_work[job.id] += d; + } + let first = if self.trunk_first(s) { + !job.short + } else { + job.short + }; + if first { self.st[s].hi.push_back((job, d)); } else { self.st[s].lo.push_back((job, d)); @@ -187,6 +219,11 @@ impl Sim { self.started_at.push(self.now); let cs = self.rng.gen_f64() < self.c.coursera_share; self.coursera.push(cs); + self.left_wip.push(0); + self.left_launch.push(0); + self.li_left.push(0); + self.build_in.push(0.0); + self.build_work.push(0.0); self.wip += 1; self.in_system += 1; let m = self.c.outline_min; @@ -201,11 +238,58 @@ impl Sim { ); } + /// B5c: on the GPU, courses go first while the running mean lead exceeds the threshold. + /// The mean runs from t = 0 over every finished course (warm-up included). It is cumulative, so + /// it moves slowly and can fall below the threshold again; a windowed mean is a follow-up. + fn trunk_first(&self, s: usize) -> bool { + trunk_first_rule(s, self.c.trunk_first_w_d, self.lead_sum, self.lead_n) + } + + /// A branch of master `id` is done with its WIP slot; the slot frees when every branch is. + fn wip_done(&mut self, id: usize) { + if !self.c.fanout { + return self.leave_wip(); + } + self.left_wip[id] -= 1; + if self.left_wip[id] == 0 { + self.leave_wip(); + } + } + + /// A branch of master `id` is live; the master launches when every branch is. + fn launch_done(&mut self, id: usize) { + if !self.c.fanout { + return self.launch(id); + } + self.left_launch[id] -= 1; + if self.left_launch[id] == 0 { + self.launch(id); + } + } + + /// B5a: after verify, one branch per platform: edX (no human step), Coursera (stage, then batch), + /// LinkedIn (k + 4 renders on the GPU). + fn fan_out(&mut self, j: Job) { + let c = self.c.clone(); + let nj = |step| Job { step, ..j }; + let renders = c.li_k + 4; + self.left_wip[j.id] = 3; + self.left_launch[j.id] = 3; + self.li_left[j.id] = renders; + self.at(15.0, &Ev::Delay(nj(6))); + self.enqueue(CHROME, nj(5), c.stage_min); + for _ in 0..renders { + self.enqueue(RENDER, nj(7), c.li_render_min); + } + } + fn launch(&mut self, id: usize) { self.in_system -= 1; if self.now >= self.warm { self.sojourn.push(self.now - self.started_at[id]); } + self.lead_sum += self.now - self.started_at[id]; + self.lead_n += 1; } fn leave_wip(&mut self) { @@ -220,21 +304,42 @@ impl Sim { let c = self.c.clone(); let nj = |step| Job { step, ..j }; match j.step { - 0 => self.enqueue(BUILD, nj(1), c.build_min), - 1 => self.enqueue(DESIGN, nj(2), c.design_min), + 0 => { + self.build_in[j.id] = self.now; + self.enqueue(BUILD, nj(1), c.build_min); + } + 1 if c.alpha > 0.0 && self.rng.gen_f64() < c.alpha => { + self.enqueue(BUILD, nj(1), c.build_min); + } + 1 => { + if self.now >= self.warm { + self.tb_work += self.build_work[j.id]; + self.tb_elapsed += self.now - self.build_in[j.id]; + self.tb_n += 1; + } + self.enqueue(DESIGN, nj(2), c.design_min); + } 2 => self.enqueue(RENDER, nj(3), c.render_min), 3 => self.enqueue(VERIFY, nj(4), c.verify_min), + 4 if c.fanout => self.fan_out(j), 4 if self.coursera[j.id] => self.enqueue(CHROME, nj(5), c.stage_min), 4 => self.at(15.0, &Ev::Delay(nj(6))), + 7 => { + self.li_left[j.id] -= 1; + if self.li_left[j.id] == 0 { + self.wip_done(j.id); + self.launch_done(j.id); + } + } 5 => { if !self.c.batch_in_wip { - self.leave_wip(); + self.wip_done(j.id); } self.batch_wait.push((j.id, self.now)); } _ => { - self.leave_wip(); - self.launch(j.id); + self.wip_done(j.id); + self.launch_done(j.id); } } } @@ -247,9 +352,9 @@ impl Sim { self.bw_n += 1; } if self.c.batch_in_wip { - self.leave_wip(); + self.wip_done(id); } - self.launch(id); + self.launch_done(id); } } @@ -308,6 +413,12 @@ impl Sim { } } +/// The trunk-first predicate: only the render stage, only with a positive threshold, only once a +/// course has finished, and only while the mean lead (days) is above the threshold. +fn trunk_first_rule(stage: usize, w_d: f64, lead_sum: f64, lead_n: u64) -> bool { + stage == RENDER && w_d > 0.0 && lead_n > 0 && lead_sum / lead_n as f64 / DAY > w_d +} + /// Run one seed for `days`, discarding the first `warmup_d`. #[must_use] pub fn run_course_flow( @@ -345,6 +456,16 @@ pub fn run_course_flow( in_system: 0, l_area: 0.0, last_t: 0.0, + left_wip: Vec::new(), + left_launch: Vec::new(), + li_left: Vec::new(), + build_in: Vec::new(), + build_work: Vec::new(), + tb_work: 0.0, + tb_elapsed: 0.0, + tb_n: 0, + lead_sum: 0.0, + lead_n: 0, }; s.at(0.0, &Ev::Start); s.at(0.0, &Ev::ShortTick); @@ -378,6 +499,8 @@ pub fn run_course_flow( l: s.l_area / span, util, mean_batch_wait_h: s.bw_sum / (s.bw_n.max(1) as f64) / 60.0, + t_b_work_h: s.tb_work / (s.tb_n.max(1) as f64) / 60.0, + t_b_elapsed_h: s.tb_elapsed / (s.tb_n.max(1) as f64) / 60.0, } } @@ -400,6 +523,11 @@ pub fn course_preset(name: &str) -> Option { outline_min: 90.0, verify_min: 15.0, batch_in_wip: false, + fanout: false, + alpha: 0.0, + trunk_first_w_d: 0.0, + li_k: 0, + li_render_min: 20.0, }; match name { "cal-today" => { @@ -411,6 +539,12 @@ pub fn course_preset(name: &str) -> Option { "nine-builders" => c.builders = 9, "coursera-24h" => c.stage_min = 1440.0, "render-300" => c.render_min = 300.0, + "factory-v2" => { + c.fanout = true; + c.alpha = 0.16; + c.trunk_first_w_d = 4.8; + c.li_k = 2; + } _ => return None, } Some(c) @@ -418,13 +552,14 @@ pub fn course_preset(name: &str) -> Option { /// Names of the FLOW-006 factory cases. #[must_use] -pub fn course_cases() -> [&'static str; 5] { +pub fn course_cases() -> [&'static str; 6] { [ "cal-today", "design", "nine-builders", "coursera-24h", "render-300", + "factory-v2", ] } @@ -439,6 +574,8 @@ pub fn run_course_reference(c: &CourseFlowConfig) -> CourseFlowResult { a.lead_days += r.lead_days / 5.0; a.l += r.l / 5.0; a.mean_batch_wait_h += r.mean_batch_wait_h / 5.0; + a.t_b_work_h += r.t_b_work_h / 5.0; + a.t_b_elapsed_h += r.t_b_elapsed_h / 5.0; for i in 0..6 { a.util[i] += r.util[i] / 5.0; } @@ -468,6 +605,21 @@ pub fn apply_course_inputs(c: &mut CourseFlowConfig, v: &serde_json::Value) -> R "starts_per_day" => c.starts_per_day = f, "shorts_per_day" => c.shorts_per_day = f, "batch_in_wip" => c.batch_in_wip = f != 0.0, + "fanout" => c.fanout = f != 0.0, + "alpha" => { + if !(0.0..1.0).contains(&f) { + return Err(format!("alpha must be in [0, 1), got {f}")); + } + c.alpha = f; + } + "trunk_first_w_d" => c.trunk_first_w_d = f, + "li_k" => { + if !(0.0..=64.0).contains(&f) { + return Err(format!("li_k must be in [0, 64], got {f}")); + } + c.li_k = f as usize; + } + "li_render_min" => c.li_render_min = f, _ => return Err(format!("unknown input: {k}")), } } @@ -491,10 +643,14 @@ pub fn course_sim_block( "chrome_lanes": c.chrome_lanes, "stage_min": c.stage_min, "coursera_share": c.coursera_share, "wip": c.wip, "starts_per_day": c.starts_per_day, "shorts_per_day": c.shorts_per_day, "batch_in_wip": c.batch_in_wip, + "fanout": c.fanout, "alpha": c.alpha, "trunk_first_w_d": c.trunk_first_w_d, + "li_k": c.li_k, "li_render_min": c.li_render_min, }, "predicted": { "courses_day": r.courses_per_day, "shorts_day": r.shorts_per_day, - "lead_days": r.lead_days, "L": r.l, "mean_batch_wait_h": r.mean_batch_wait_h, "busiest_util_pct": util * 100.0, + "lead_days": r.lead_days, "L": r.l, "mean_batch_wait_h": r.mean_batch_wait_h, + "t_b_work_h": r.t_b_work_h, "t_b_elapsed_h": r.t_b_elapsed_h, + "busiest_util_pct": util * 100.0, }, "binding_station": station, }) @@ -625,6 +781,130 @@ mod tests { assert!((a.courses_per_day - b.courses_per_day).abs() > 1e-6 || (a.l - b.l).abs() > 1e-6); } + fn v2() -> CourseFlowResult { + got("factory-v2") + } + + /// B5 falsifier: the Coursera stage at R = 3 carries every master, so busy = 5 x 360 / (2 x 1440) = 62.5%. + #[test] + fn coursera_stage_matches_the_law_within_two_points() { + let r = v2(); + assert!( + (r.util[CHROME] - (0.625 + 25.0 / 2880.0)).abs() <= 0.02, + "chrome busy {:.3} vs law 0.625 plus the Shorts stage", + r.util[CHROME] + ); + } + + /// B5 falsifier: at alpha = 0.16 the build stage runs at the law's B/(1-alpha) load (94.7% busy), + /// within two points. The bound is the law with a tolerance, not a bare 95% cap: the preset sits + /// 0.3 points under 95%, so a cap flips on any change to the random draw order. + #[test] + fn build_busy_at_alpha_016_matches_the_law() { + let r = v2(); + let law = 2520.0 / 0.84 * 5.0 / (11.0 * 1440.0); + assert!( + (r.util[BUILD] - law).abs() <= 0.02, + "build busy {:.3} vs law {law:.3}", + r.util[BUILD] + ); + assert!( + r.util[BUILD] > 0.85, + "rework not applied: {:.3}", + r.util[BUILD] + ); + } + + /// B5b: mean build work per course is B/(1-alpha) = 2520/0.84 min = 50 h. + #[test] + fn rework_loop_scales_build_work_by_one_over_one_minus_alpha() { + let r = v2(); + let want = 2520.0 / 0.84 / 60.0; + assert!( + (r.t_b_work_h - want).abs() <= want * 0.04, + "t_b_work_h {:.2} vs {want:.2}", + r.t_b_work_h + ); + } + + /// B5e: elapsed build time includes queueing, so it can only exceed the work. + #[test] + fn build_elapsed_is_not_the_build_work() { + let r = v2(); + assert!( + r.t_b_elapsed_h > r.t_b_work_h + 1e-6, + "{:.2} vs {:.2}", + r.t_b_elapsed_h, + r.t_b_work_h + ); + } + + /// B5a: fan-out to three platforms puts a Coursera batch wait on every master, so the wait is reported. + #[test] + fn every_master_fans_out_to_all_three_platforms() { + let mut c = course_preset("factory-v2").expect("case"); + c.fanout = false; + let split = run_course_reference(&c); + assert!( + v2().util[CHROME] > split.util[CHROME] + 0.2, + "fan-out did not raise chrome load" + ); + } + + /// B5d: LinkedIn adds k + 4 renders per master to the GPU. + #[test] + fn linkedin_renders_load_the_gpu() { + let mut c = course_preset("factory-v2").expect("case"); + c.li_k = 0; + let lo = run_course_reference(&c); + assert!( + v2().util[RENDER] > lo.util[RENDER] + 0.05, + "{:.3} vs {:.3}", + v2().util[RENDER], + lo.util[RENDER] + ); + } + + /// B5c: the trunk-first predicate, tested directly: the simulated outcome of the rule is within + /// seed noise (Shorts barely move), so the falsifier pins the rule and not a noisy delta. + #[test] + fn trunk_first_rule_changes_the_outcome() { + let d = DAY; + // mean lead 3 d: above a 2 d threshold, below a 4 d one + assert!(trunk_first_rule(RENDER, 2.0, 30.0 * d, 10)); + assert!(!trunk_first_rule(RENDER, 4.0, 30.0 * d, 10)); + // off at 0, off before any course finished, and only the render stage is reordered + assert!(!trunk_first_rule(RENDER, 0.0, 30.0 * d, 10)); + assert!(!trunk_first_rule(RENDER, 2.0, 30.0 * d, 0)); + assert!(!trunk_first_rule(BUILD, 2.0, 30.0 * d, 10)); + // the rule is wired into the run: some threshold engages it and changes the result + let run_w = |w: f64| { + let mut c = course_preset("factory-v2").expect("case"); + c.trunk_first_w_d = w; + run_course_reference(&c) + }; + let off = run_w(0.0); + let on = run_w(0.5); + assert!( + (on.lead_days - off.lead_days).abs() > 1e-6 + || (on.shorts_per_day - off.shorts_per_day).abs() > 1e-6 + ); + } + + /// alpha is a probability below 1: at 1 or more a build repeats forever. + #[test] + fn alpha_must_be_below_one() { + let mut c = course_preset("factory-v2").expect("case"); + for bad in [1.0, 1.5, -0.1] { + let e = apply_course_inputs(&mut c, &serde_json::json!({ "alpha": bad })); + assert!(e.is_err(), "alpha {bad} accepted"); + } + let e = apply_course_inputs(&mut c, &serde_json::json!({ "li_k": 1.0e30 })); + assert!(e.is_err(), "li_k 1e30 accepted"); + apply_course_inputs(&mut c, &serde_json::json!({ "alpha": 0.5 })).expect("0.5 is valid"); + assert!((c.alpha - 0.5).abs() < 1e-12); + } + #[test] fn unknown_case_is_none() { assert!(course_preset("nope").is_none()); diff --git a/docs/findings/w5032.jsonl b/docs/findings/w5032.jsonl new file mode 100644 index 0000000000..031a90b0fe --- /dev/null +++ b/docs/findings/w5032.jsonl @@ -0,0 +1 @@ +{"id":"FND-20261021-trunk-first-preset-weak","title":"factory-v2 preset trunk_first_w_d=4.8 d is a near no-op across seed windows","evidence":"B5a quorum round 2 lane 2: exact no-op in 8 of 9 seed windows; cumulative mean rarely exceeds 4.8 d","repro":"run_course_reference with trunk_first_w_d 4.8 vs 0 over 9 seed windows","suspected_epic":"unknown","severity":"P3","found_at_sha":"860900f622507cca752472dd1470d6104a8c5ddf"} From 6f8b9973df9e04ae24b4b2410280ac80644eef5b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Fri, 23 Oct 2026 10:34:15 +0200 Subject: [PATCH 05/11] test(tuner): F042-F044 assert the contract unconditionally (Refs #6125) F042 asserts the insufficient-data error, F043 asserts NaN labels are refused (RandomForestRegressor::fit accepts them and predicts NaN, #6125; the helper guards until fixed), F044 asserts a fit with finite predictions in label range. PMAT-Ticket: PMAT-6125 Co-Authored-By: Claude Sonnet 5.5 --- .../tests/tuner_training_data_quality.rs | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/aprender-core/tests/tuner_training_data_quality.rs b/crates/aprender-core/tests/tuner_training_data_quality.rs index 41960a5b1f..d7293e71a4 100644 --- a/crates/aprender-core/tests/tuner_training_data_quality.rs +++ b/crates/aprender-core/tests/tuner_training_data_quality.rs @@ -22,6 +22,10 @@ fn fit_throughput_rf( if rows.len() < 10 { return Err(format!("insufficient data: {} < 10", rows.len())); } + // RandomForestRegressor::fit accepts NaN labels and then predicts NaN everywhere (measured), so the guard is here. + if let Some(i) = rows.iter().position(|(_, t)| !t.is_finite()) { + return Err(format!("non-finite label at row {i}")); + } let dim = TunerFeatures::DIM; let n = rows.len(); let mut x = Vec::with_capacity(n * dim); @@ -53,14 +57,18 @@ fn f041_empty_training_errors() { fn f042_single_sample_graceful() { let features = TunerFeatures::builder().model_params_b(1.5).build(); let data = vec![(features, 100.0)]; - // Should error (insufficient), not panic - let _ = fit_throughput_rf(&data, 10); + let err = fit_throughput_rf(&data, 10) + .err() + .expect("F042: a single sample must error"); + assert!( + err.contains("insufficient data"), + "F042: wrong error: {err}" + ); } -/// F043: Training with NaN labels should not produce NaN predictions +/// F043: Training with NaN labels must error (never fit and predict NaN) #[test] fn f043_nan_labels_error() { - use aprender::primitives::Matrix; let mut data: Vec<(TunerFeatures, f32)> = (0..10) .map(|i| { ( @@ -72,14 +80,13 @@ fn f043_nan_labels_error() { }) .collect(); data[0].1 = f32::NAN; - if let Ok(rf) = fit_throughput_rf(&data, 10) { - let fx = Matrix::from_vec(1, TunerFeatures::DIM, data[1].0.to_vector().to_vec()).unwrap(); - let pred = rf.predict(&fx); - assert!( - pred.as_slice()[0].is_finite() || pred.as_slice()[0].is_nan(), - "F043: prediction must be a well-defined float" - ); - } + let err = fit_throughput_rf(&data, 10) + .err() + .expect("F043: NaN labels must be refused, not fitted"); + assert!( + err.contains("non-finite label at row 0"), + "F043: wrong error: {err}" + ); } /// F044: Training with negative labels should be handled without panic @@ -93,8 +100,16 @@ fn f044_negative_labels_handled() { (f, if i % 2 == 0 { -100.0 } else { 100.0 }) }) .collect(); - // Must not panic; result may be Ok or Err. - let _ = fit_throughput_rf(&data, 10); + use aprender::primitives::Matrix; + let rf = fit_throughput_rf(&data, 10).expect("F044: negative finite labels must fit"); + for (feat, _) in &data { + let fx = Matrix::from_vec(1, TunerFeatures::DIM, feat.to_vector().to_vec()).unwrap(); + let p = rf.predict(&fx).as_slice()[0]; + assert!( + p.is_finite() && (-100.0..=100.0).contains(&p), + "F044: prediction {p} outside the label range" + ); + } } /// F046: Training improves over heuristic (or doesn't regress) From ebe6408e4a58e2a5cb7eb551adb181ff3ba6255f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Fri, 23 Oct 2026 12:16:57 +0200 Subject: [PATCH 06/11] fix(guard): refresh check_tuner_test_move pins after the f042-f044 fix (Refs #6125) 3aee0d1a78 edited f042-f044 and fit_throughput_rf without refreshing the guard's verbatim digests, so --self-test S1 was red. Refresh the five pins, and point the S7 mutant at a comment line that still exists (the old target was removed from f042). PMAT-Ticket: PMAT-6125 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_tuner_test_move.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/check_tuner_test_move.sh b/scripts/check_tuner_test_move.sh index a36e8d7471..ab0dfb09cb 100755 --- a/scripts/check_tuner_test_move.sh +++ b/scripts/check_tuner_test_move.sh @@ -23,9 +23,9 @@ WANT="f041_empty_training_errors f042_single_sample_graceful f043_nan_labels_err DIGESTS=" f041_empty_training_errors 070237018993271c -f042_single_sample_graceful bc88d040de7f6792 -f043_nan_labels_error 9fd06ac9384bfcaa -f044_negative_labels_handled fbb01296c746a127 +f042_single_sample_graceful 733fc0fa3581c5d1 +f043_nan_labels_error 44de7d832308e336 +f044_negative_labels_handled 5f44bd20d123d1b1 f045_heuristic_no_training 8cabd670f956b0d0 f046_training_improves f221030467215901 f047_large_training_no_oom 140c3b8e5cb1d85b @@ -33,8 +33,8 @@ f048_classifier_training 72c5d41797dc1285 f049_training_data_variance 1f2fd8cbb55e4919 f050_feature_correlation 0a494cbb1416adde f051_to_f060_reserved fbb8c9ddc4be9944 -fit_throughput_rf 7da5ef57b0d6baeb -@tail:crates/aprender-core/tests/tuner_training_data_quality.rs e599e682001e6f49 +fit_throughput_rf e8c6b456ce81ff7d +@tail:crates/aprender-core/tests/tuner_training_data_quality.rs 3b3d67c9f0380b4f @tail:crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs 4268c362cc9890b0 @file:crates/aprender-compute/tests/tuner_falsification/main.rs 2a75089411202f35 " @@ -142,7 +142,7 @@ if [ "${1:-}" = "--self-test" ]; then mk s3; printf '[dev-dependencies]\naprender = { path = "../aprender-core", package = "aprender-core" }\n' >> "$t/s3/crates/aprender-compute/Cargo.toml"; scase "S4 MUTANT: dev-dep on aprender-core back" 1 s3 mk s4; printf 'use aprender::tree::RandomForestRegressor;\n' > "$t/s4/crates/aprender-compute/examples/x.rs"; scase "S5 MUTANT: compute example calls aprender::" 1 s4 mk s5; sed -i 's/< 10/< 11/' "$t/s5/$KF"; scase "S6 MUTANT: helper fit_throughput_rf guard changed" 1 s5 - mk s6; sed -i '/Should error (insufficient), not panic/{N;s/\n */ /}' "$t/s6/$KF"; scase "S7 MUTANT: code joined onto a // line (the r3/r4 hole)" 1 s6 + mk s6; sed -i '/Generate training data that matches heuristic pattern/{N;s/\n */ /}' "$t/s6/$KF"; scase "S7 MUTANT: code joined onto a // line (the r3/r4 hole)" 1 s6 mk s7; sed -i '/^use trueno::tuner::TunerFeatures;/a macro_rules! assert { ($($t:tt)*) => {}; }' "$t/s7/$KF"; scase "S8 MUTANT: macro_rules! assert shadow after the use line" 1 s7 mk s8; sed -i '/^use trueno::tuner::TunerFeatures;/a #[path = "elsewhere.rs"] mod shadow;' "$t/s8/$KF"; scase "S9 MUTANT: #[path] mod in the header" 1 s8 mk s9; sed -i 's/F045 FALSIFIED/F045 weakened/' "$t/s9/$CF"; scase "S10 MUTANT: a compute-resident test body edited" 1 s9 From 55ddeb9e102bea2306cf58153b713525a1e937cc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 24 Oct 2026 12:27:07 +0200 Subject: [PATCH 07/11] docs(roadmap): PMAT-5803 fragment carried over from #6179 (Refs #5803) PMAT-Ticket: PMAT-5803 Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-5803.yaml | 19 +++++++++++++++++++ docs/roadmaps/roadmap.yaml | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5803.yaml diff --git a/docs/roadmaps/entries/PMAT-5803.yaml b/docs/roadmaps/entries/PMAT-5803.yaml new file mode 100644 index 0000000000..0c0feb61c1 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5803.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5803 + github_issue: 5803 + item_type: task + title: 'FLOW-010 cut 1: drop aprender-compute dev-dep on aprender-core (tuner training-data tests move to aprender-core)' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:40:00Z + updated: 2026-10-23T10:40:00Z + spec: null + acceptance_criteria: + - 'aprender-compute has no dev-dependency on aprender-core; the tuner training-data tests run from aprender-core and a guard fails if a moved test is missing, duplicated or ignored' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index b11e795e0d..cc20cf27c1 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25378,6 +25378,25 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 3863 +- id: PMAT-5803 + github_issue: 5803 + item_type: task + title: 'FLOW-010 cut 1: drop aprender-compute dev-dep on aprender-core (tuner training-data tests move to aprender-core)' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:40:00Z + updated: 2026-10-23T10:40:00Z + spec: null + acceptance_criteria: + - 'aprender-compute has no dev-dependency on aprender-core; the tuner training-data tests run from aprender-core and a guard fails if a moved test is missing, duplicated or ignored' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 - id: PMAT-5817 github_issue: 5817 item_type: task From f6fbd069515d123fdba4c64a7d876b6dd9ca705b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 24 Oct 2026 15:35:07 +0200 Subject: [PATCH 08/11] docs(roadmap): carry the PMAT-5557 entry over from #6100 so the epic's roadmap resolves it (Refs #5557) Entry file copied verbatim from the ticket's own roadmap commit; no new ticket. Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-5557.yaml | 18 ++++++++++++++++++ docs/roadmaps/roadmap.yaml | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5557.yaml diff --git a/docs/roadmaps/entries/PMAT-5557.yaml b/docs/roadmaps/entries/PMAT-5557.yaml new file mode 100644 index 0000000000..c7ef7d0efc --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5557.yaml @@ -0,0 +1,18 @@ +- id: PMAT-5557 + github_issue: 5557 + item_type: task + title: 'feat(simulate): course_flow fan-out, rework loop, GPU priority (flow model B5)' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:50:00Z + updated: 2026-10-23T10:50:00Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index cc20cf27c1..2d05409161 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25340,6 +25340,24 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 3997 +- id: PMAT-5557 + github_issue: 5557 + item_type: task + title: 'feat(simulate): course_flow fan-out, rework loop, GPU priority (flow model B5)' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:50:00Z + updated: 2026-10-23T10:50:00Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 - id: PMAT-5737 github_issue: 5737 item_type: task From 4198c3e25fe4a4f94bf8fe6b516b8188c1ad61fe Mon Sep 17 00:00:00 2001 From: t Date: Sat, 24 Oct 2026 15:35:11 +0200 Subject: [PATCH 09/11] docs(roadmap): carry the PMAT-5909 entry over from #6100 so the epic's roadmap resolves it (Refs #5909) Entry file copied verbatim from the ticket's own roadmap commit; no new ticket. Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-5909.yaml | 19 +++++++++++++++++++ docs/roadmaps/roadmap.yaml | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5909.yaml diff --git a/docs/roadmaps/entries/PMAT-5909.yaml b/docs/roadmaps/entries/PMAT-5909.yaml new file mode 100644 index 0000000000..5edd99a0e1 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5909.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5909 + github_issue: 5909 + item_type: task + title: 'Crate fan-out: aprender-contracts (24 commits/30d) dirties 47 of 85 crates; split build_helper out' + status: planned + priority: medium + assigned_to: null + created: 2026-10-22T12:00:00Z + updated: 2026-10-22T12:00:00Z + spec: null + acceptance_criteria: + - See issue #5909; registered so quorum-review.sh resolves the commit trailer (EMG epic PRs) + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 2d05409161..8ef1d62764 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25590,6 +25590,25 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:measurement notes: null epic: 5510 +- id: PMAT-5909 + github_issue: 5909 + item_type: task + title: 'Crate fan-out: aprender-contracts (24 commits/30d) dirties 47 of 85 crates; split build_helper out' + status: planned + priority: medium + assigned_to: null + created: 2026-10-22T12:00:00Z + updated: 2026-10-22T12:00:00Z + spec: null + acceptance_criteria: + - See issue #5909; registered so quorum-review.sh resolves the commit trailer (EMG epic PRs) + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 - id: PMAT-5914 github_issue: 5914 item_type: task From c936c60c4937bcfb88f3dc7c1733cae5e2790bd1 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 24 Oct 2026 15:35:16 +0200 Subject: [PATCH 10/11] docs(roadmap): carry the PMAT-6125 entry over from #6100 so the epic's roadmap resolves it (Refs #6125) Entry file copied verbatim from the ticket's own roadmap commit; no new ticket. Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-6125.yaml | 18 ++++++++++++++++++ docs/roadmaps/roadmap.yaml | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-6125.yaml diff --git a/docs/roadmaps/entries/PMAT-6125.yaml b/docs/roadmaps/entries/PMAT-6125.yaml new file mode 100644 index 0000000000..32f4b613d9 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-6125.yaml @@ -0,0 +1,18 @@ +- id: PMAT-6125 + github_issue: 6125 + item_type: task + title: 'fix(tree): RandomForestRegressor::fit accepts NaN labels and predicts NaN for every row' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:50:00Z + updated: 2026-10-23T10:50:00Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 8ef1d62764..03185f1064 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25852,6 +25852,24 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe labels: - kind:code notes: null +- id: PMAT-6125 + github_issue: 6125 + item_type: task + title: 'fix(tree): RandomForestRegressor::fit accepts NaN labels and predicts NaN for every row' + status: planned + priority: high + assigned_to: null + created: 2026-10-23T10:50:00Z + updated: 2026-10-23T10:50:00Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5923 - id: PMAT-6131 github_issue: 6131 item_type: task From c629589e989c9d2cd2a211cac404440f4da3039e Mon Sep 17 00:00:00 2001 From: t Date: Sat, 24 Oct 2026 15:58:57 +0200 Subject: [PATCH 11/11] fix(tree): RandomForestRegressor::fit refuses a non-finite label instead of fitting and predicting NaN (Refs #6125) fit now returns Err("non-finite label at row N") for NaN/inf in y, with a unit test in aprender-core (all three non-finite values, no trees left behind, finite labels still fit). The tuner test helper drops its private guard, so F043 now exercises the library; check_tuner_test_move.sh re-pins fit_throughput_rf and the core tail, and its header no longer claims f042-f044 equal the merge-base bodies (they were strengthened). Co-Authored-By: Claude Sonnet 5.5 --- .../aprender-core/src/tree/random_forest.rs | 4 ++++ .../src/tree/random_forest_tests_regressor.rs | 20 +++++++++++++++++++ .../tests/tuner_training_data_quality.rs | 4 ---- scripts/check_tuner_test_move.sh | 8 +++++--- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/aprender-core/src/tree/random_forest.rs b/crates/aprender-core/src/tree/random_forest.rs index 127b1ce0d6..7c3e5bb670 100644 --- a/crates/aprender-core/src/tree/random_forest.rs +++ b/crates/aprender-core/src/tree/random_forest.rs @@ -85,6 +85,10 @@ impl RandomForestRegressor { if n_samples == 0 { return Err("Cannot fit with zero samples".into()); } + // A NaN or infinite label would be fitted silently and make every prediction NaN (#6125). + if let Some(i) = y.as_slice().iter().position(|v| !v.is_finite()) { + return Err(format!("non-finite label at row {i}").into()); + } self.trees = Vec::with_capacity(self.n_estimators); self.oob_indices = Vec::with_capacity(self.n_estimators); diff --git a/crates/aprender-core/src/tree/random_forest_tests_regressor.rs b/crates/aprender-core/src/tree/random_forest_tests_regressor.rs index 7e5104d991..ce7baecb25 100644 --- a/crates/aprender-core/src/tree/random_forest_tests_regressor.rs +++ b/crates/aprender-core/src/tree/random_forest_tests_regressor.rs @@ -425,3 +425,23 @@ let sum: f32 = importances.iter().sum(); assert!((sum - 1.0).abs() < 1e-5, "importances must sum to 1: {sum}"); } + + #[test] + fn test_regressor_fit_refuses_non_finite_labels() { + // #6125: fit used to accept NaN/inf labels and then predict NaN for every row. + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let (x, y) = regression_data(); + let mut labels = y.as_slice().to_vec(); + labels[2] = bad; + let mut rf = RandomForestRegressor::new(3).with_random_state(1); + let err = rf + .fit(&x, &Vector::from_vec(labels)) + .expect_err("a non-finite label must be refused"); + assert_eq!(err.to_string(), "non-finite label at row 2", "label {bad}"); + assert!(rf.trees.is_empty(), "a refused fit must not leave trees behind"); + } + let (x, y) = regression_data(); + let mut rf = RandomForestRegressor::new(3).with_random_state(1); + rf.fit(&x, &y).expect("finite labels still fit"); + assert!(rf.predict(&x).as_slice().iter().all(|p| p.is_finite())); + } diff --git a/crates/aprender-core/tests/tuner_training_data_quality.rs b/crates/aprender-core/tests/tuner_training_data_quality.rs index d7293e71a4..2009195d32 100644 --- a/crates/aprender-core/tests/tuner_training_data_quality.rs +++ b/crates/aprender-core/tests/tuner_training_data_quality.rs @@ -22,10 +22,6 @@ fn fit_throughput_rf( if rows.len() < 10 { return Err(format!("insufficient data: {} < 10", rows.len())); } - // RandomForestRegressor::fit accepts NaN labels and then predicts NaN everywhere (measured), so the guard is here. - if let Some(i) = rows.iter().position(|(_, t)| !t.is_finite()) { - return Err(format!("non-finite label at row {i}")); - } let dim = TunerFeatures::DIM; let n = rows.len(); let mut x = Vec::with_capacity(n * dim); diff --git a/scripts/check_tuner_test_move.sh b/scripts/check_tuner_test_move.sh index ab0dfb09cb..c388213ee3 100755 --- a/scripts/check_tuner_test_move.sh +++ b/scripts/check_tuner_test_move.sh @@ -11,7 +11,9 @@ # 4. content pin, VERBATIM bytes (no whitespace normalising -- that let code be joined onto a `//` line): a digest of # each moved test fn with its doc/attr lines, a digest of each file from its first item on, and the header before # it may hold only blank/`//!`/`#![allow]`/single `use` lines (no macro_rules!). The moved bodies equal the -# merge-base bodies up to rustfmt and the dropped `#[cfg(feature = "ml-tuner")]` lines (proved in the PR body). +# merge-base bodies up to rustfmt and the dropped `#[cfg(feature = "ml-tuner")]` lines, EXCEPT f042-f044, which +# were strengthened to assert the contract unconditionally (#6125); their digests pin the strengthened text. +# F043 now exercises the library: RandomForestRegressor::fit itself refuses a non-finite label. # Text cannot prove a test runs; parts 1-2 do. Editing a moved test means updating its digest here in the same PR. # `--self-test` feeds synthetic cargo listings, so it needs no compile; the mutants must turn it RED. set -u @@ -33,8 +35,8 @@ f048_classifier_training 72c5d41797dc1285 f049_training_data_variance 1f2fd8cbb55e4919 f050_feature_correlation 0a494cbb1416adde f051_to_f060_reserved fbb8c9ddc4be9944 -fit_throughput_rf e8c6b456ce81ff7d -@tail:crates/aprender-core/tests/tuner_training_data_quality.rs 3b3d67c9f0380b4f +fit_throughput_rf 7da5ef57b0d6baeb +@tail:crates/aprender-core/tests/tuner_training_data_quality.rs 251b7174cb88652f @tail:crates/aprender-compute/tests/tuner_falsification/training_data_quality.rs 4268c362cc9890b0 @file:crates/aprender-compute/tests/tuner_falsification/main.rs 2a75089411202f35 "