diff --git a/contracts/shapes.ttl b/contracts/shapes.ttl index a718856fa0..bed403e57d 100644 --- a/contracts/shapes.ttl +++ b/contracts/shapes.ttl @@ -1983,11 +1983,11 @@ ] ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:in ( "8.9" "12.1" ) ; @@ -2067,11 +2067,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2084,11 +2084,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2103,11 +2103,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2194,7 +2194,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2217,7 +2217,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2239,7 +2239,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2257,7 +2257,7 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2269,7 +2269,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2283,7 +2283,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2347,11 +2347,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2392,11 +2392,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2448,11 +2448,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2511,11 +2511,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2543,11 +2543,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2569,11 +2569,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2595,11 +2595,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2626,11 +2626,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2721,7 +2721,7 @@ sh:minCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2733,7 +2733,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2768,11 +2768,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; diff --git a/crates/apr-cli/src/commands/serve/auth.rs b/crates/apr-cli/src/commands/serve/auth.rs index 5452422a80..6c56c49c67 100644 --- a/crates/apr-cli/src/commands/serve/auth.rs +++ b/crates/apr-cli/src/commands/serve/auth.rs @@ -11,6 +11,10 @@ use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +/// Digest held by [`AuthGate::deny_all`]: all zeros, which no SHA-256 preimage +/// is known to produce, so no bearer token matches it. +const DENY_ALL_HASH: [u8; 32] = [0u8; 32]; + /// Configured authentication state for an `apr serve` instance. /// /// `expected_hash == None` means auth is disabled — every request passes. @@ -46,31 +50,60 @@ impl AuthGate { Self::from_hash(sha256_32(key.as_bytes())) } + /// A gate that is enabled and rejects every request. Fail-closed stand-in + /// for a configuration that was set but unusable (#5479): the digest is + /// not reachable from any bearer token. + #[must_use] + pub fn deny_all() -> Self { + Self { + expected_hash: Some(DENY_ALL_HASH), + } + } + + /// Pure core of [`Self::try_from_env`]. An empty value counts as unset. + /// A non-empty `APR_API_KEY_HASH` must be 64 hex chars, else `Err` — a + /// plaintext key never papers over a malformed hash. + /// + /// # Errors + /// + /// Returns the reason when `hash` is set, non-empty and not a SHA-256 hex digest. + pub fn from_values(hash: Option<&str>, plain: Option<&str>) -> Result { + if let Some(hex) = hash.filter(|h| !h.is_empty()) { + return decode_hex_32(hex).map(Self::from_hash); + } + match plain.filter(|p| !p.is_empty()) { + Some(plain) => Ok(Self::from_plain_key(plain)), + None => Ok(Self::disabled()), + } + } + /// Read configuration from env. `APR_API_KEY_HASH` (hex-encoded /// SHA-256) wins over `APR_API_KEY` (plaintext, hashed in-process). /// Neither set → disabled with a single stderr warning. + /// + /// # Errors + /// + /// A set, non-empty, malformed `APR_API_KEY_HASH` refuses to start. + pub fn try_from_env() -> Result { + let hash = std::env::var("APR_API_KEY_HASH").ok(); + let plain = std::env::var("APR_API_KEY").ok(); + let gate = Self::from_values(hash.as_deref(), plain.as_deref())?; + if !gate.is_enabled() { + eprintln!( + "[apr serve] WARNING: no APR_API_KEY or APR_API_KEY_HASH set; HTTP routes are unauthenticated", + ); + } + Ok(gate) + } + + /// [`Self::try_from_env`] for callers that cannot return an error: a + /// malformed `APR_API_KEY_HASH` yields [`Self::deny_all`], never an open gate. #[must_use] pub fn from_env() -> Self { - if let Ok(hex) = std::env::var("APR_API_KEY_HASH") { - match decode_hex_32(&hex) { - Ok(bytes) => return Self::from_hash(bytes), - Err(reason) => { - eprintln!( - "[apr serve] APR_API_KEY_HASH set but {reason}; ignoring (auth disabled)", - ); - return Self::disabled(); - } - } - } - if let Ok(plain) = std::env::var("APR_API_KEY") { - if !plain.is_empty() { - return Self::from_plain_key(&plain); - } - } - eprintln!( - "[apr serve] WARNING: no APR_API_KEY or APR_API_KEY_HASH set; HTTP routes are unauthenticated", - ); - Self::disabled() + Self::try_from_env().unwrap_or_else(|reason| { + eprintln!("[apr serve] {reason}; rejecting all requests (fail closed)"); + Self::deny_all() + }) } /// True iff the gate has a configured hash. @@ -251,4 +284,52 @@ mod tests { bad.replace_range(0..1, "Z"); assert!(decode_hex_32(&bad).is_err()); } + + // #5479: a bad APR_API_KEY_HASH must never yield an open gate. + #[test] + fn from_values_malformed_hash_is_an_error_not_a_disabled_gate() { + for bad in ["zz", "abc", &"g".repeat(64), &"a".repeat(63), " "] { + assert!( + AuthGate::from_values(Some(bad), None).is_err(), + "malformed hash {bad:?} must be refused" + ); + assert!( + AuthGate::from_values(Some(bad), Some("secret")).is_err(), + "a plaintext key must not paper over malformed hash {bad:?}" + ); + } + } + + #[test] + fn from_values_empty_hash_is_unset_so_the_plain_key_applies() { + let g = AuthGate::from_values(Some(""), Some("secret")).expect("empty hash is unset"); + assert!(g.is_enabled()); + assert!(g.check_bearer(Some("Bearer secret"))); + assert!(!g.check_bearer(None)); + } + + #[test] + fn from_values_valid_hash_wins_over_plain_key() { + let hex = "a".repeat(64); + let g = AuthGate::from_values(Some(&hex), Some("secret")).expect("valid hash"); + assert!(g.is_enabled()); + assert!(!g.check_bearer(Some("Bearer secret"))); + } + + #[test] + fn from_values_neither_set_is_disabled() { + assert!(!AuthGate::from_values(None, None).expect("ok").is_enabled()); + assert!(!AuthGate::from_values(Some(""), Some("")) + .expect("ok") + .is_enabled()); + } + + #[test] + fn deny_all_gate_rejects_every_request() { + let g = AuthGate::deny_all(); + assert!(g.is_enabled()); + for h in [None, Some("Bearer "), Some("Bearer x"), Some("garbage")] { + assert!(!g.check_bearer(h)); + } + } } diff --git a/crates/apr-cli/src/commands/serve/handlers.rs b/crates/apr-cli/src/commands/serve/handlers.rs index 83c17f4d23..0cbc8c8828 100644 --- a/crates/apr-cli/src/commands/serve/handlers.rs +++ b/crates/apr-cli/src/commands/serve/handlers.rs @@ -778,6 +778,7 @@ fn serve_wgpu_backend( ); // #4056: the WGSL RMSNorm takes the model's eps (it hardcoded 1e-6). fwd.set_rms_norm_eps(quantized.config().eps); + fwd.set_rope_theta(f64::from(quantized.config().rope_theta)); upload_wgpu_weights(&mut fwd, quantized, weights, num_layers); @@ -1514,9 +1515,11 @@ fn start_apr_server(model_path: &Path, config: &ServerConfig) -> Result<()> { .map_err(|e| CliError::InferenceFailed(format!("Failed to create runtime: {e}")))?; let bind_addr = config.bind_addr(); + let gate = super::auth::AuthGate::try_from_env() + .map_err(|reason| CliError::InferenceFailed(format!("{reason}; refusing to start")))?; runtime.block_on(async move { - let app = build_apr_cpu_router(state, super::auth::AuthGate::from_env()); + let app = build_apr_cpu_router(state, gate); let listener = tokio::net::TcpListener::bind(&bind_addr) .await diff --git a/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs b/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs index d4d4cf59ec..1b9f5bab2f 100644 --- a/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs +++ b/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs @@ -112,11 +112,39 @@ pub struct WgslForwardPass { /// [`DEFAULT_RMS_NORM_EPS`]; callers set the model's value with /// [`WgslForwardPass::set_rms_norm_eps`] (#4056: Llama-family models use 1e-5). rms_norm_eps: f32, + /// RoPE base for the CPU-side rotation; set with [`WgslForwardPass::set_rope_theta`] (#5503). + rope_theta: f64, } /// Default RMSNorm epsilon: the constant the shader hardcoded before #4056. pub const DEFAULT_RMS_NORM_EPS: f32 = 1e-6; +/// RoPE base used until the model's configured `rope_theta` is set (Qwen2: 1e6; Llama: 1e4). +pub const DEFAULT_ROPE_THETA: f64 = 1_000_000.0; + +/// Rotate `heads` consecutive `head_dim` rows of `data` (NeoX half-split) for `position`. +fn rope_rotate_heads( + data: &mut [f32], + heads: usize, + head_dim: usize, + position: usize, + rope_theta: f64, +) { + let half = head_dim / 2; + for h in 0..heads { + let offset = h * head_dim; + for i in 0..half { + let theta = rope_theta.powf(-((2 * i) as f64) / head_dim as f64); + let angle = position as f64 * theta; + let (sin_a, cos_a) = (angle.sin() as f32, angle.cos() as f32); + let x0 = data[offset + i]; + let x1 = data[offset + i + half]; + data[offset + i] = x0 * cos_a - x1 * sin_a; + data[offset + i + half] = x0 * sin_a + x1 * cos_a; + } + } +} + /// Uniform params for the RMSNorm shader: `(dim, eps as f32 bits, 0, 0)`. fn rmsnorm_params(dim: u32, eps: f32) -> [u32; 4] { [dim, eps.to_bits(), 0, 0] @@ -562,6 +590,7 @@ impl WgslForwardPass { head_dim: head_dim as u32, intermediate_dim: intermediate_dim as u32, rms_norm_eps: DEFAULT_RMS_NORM_EPS, + rope_theta: DEFAULT_ROPE_THETA, } } @@ -571,6 +600,12 @@ impl WgslForwardPass { self.rms_norm_eps = eps; } + /// Set the RoPE base the position>0 rotation uses — pass the model's configured + /// `rope_theta`. Default: [`DEFAULT_ROPE_THETA`]. + pub fn set_rope_theta(&mut self, theta: f64) { + self.rope_theta = theta; + } + /// The RMSNorm epsilon this forward pass uses. #[must_use] pub fn rms_norm_eps(&self) -> f32 { @@ -967,39 +1002,20 @@ impl WgslForwardPass { // PMAT-343: Apply RoPE (NeoX-style interleaved) to Q and K let head_dim = self.head_dim as usize; let position = _position; // Use the position parameter - let rope_theta = 1_000_000.0f64; // Qwen2 rope_theta - - // RoPE on Q (num_heads × head_dim) - for h in 0..(self.num_heads as usize) { - let offset = h * head_dim; - let half = head_dim / 2; - for i in 0..half { - let theta = rope_theta.powf(-((2 * i) as f64) / head_dim as f64); - let angle = position as f64 * theta; - let cos_a = angle.cos() as f32; - let sin_a = angle.sin() as f32; - let x0 = q_data[offset + i]; - let x1 = q_data[offset + i + half]; - q_data[offset + i] = x0 * cos_a - x1 * sin_a; - q_data[offset + i + half] = x0 * sin_a + x1 * cos_a; - } - } - - // RoPE on K (num_kv_heads × head_dim) - for h in 0..(self.num_kv_heads as usize) { - let offset = h * head_dim; - let half = head_dim / 2; - for i in 0..half { - let theta = rope_theta.powf(-((2 * i) as f64) / head_dim as f64); - let angle = position as f64 * theta; - let cos_a = angle.cos() as f32; - let sin_a = angle.sin() as f32; - let x0 = k_data[offset + i]; - let x1 = k_data[offset + i + half]; - k_data[offset + i] = x0 * cos_a - x1 * sin_a; - k_data[offset + i + half] = x0 * sin_a + x1 * cos_a; - } - } + rope_rotate_heads( + &mut q_data, + self.num_heads as usize, + head_dim, + position, + self.rope_theta, + ); + rope_rotate_heads( + &mut k_data, + self.num_kv_heads as usize, + head_dim, + position, + self.rope_theta, + ); // PMAT-344: Append K,V to cache and compute full attention let head_dim = self.head_dim as usize; @@ -2214,3 +2230,33 @@ mod rmsnorm_eps_tests { assert!(err.is_none(), "RMSNORM_SHADER failed validation: {err:?}"); } } + +#[cfg(test)] +mod rope_theta_tests { + use super::{rope_rotate_heads, DEFAULT_ROPE_THETA}; + + fn rotated(theta: f64, position: usize) -> Vec { + let mut v = vec![1.0, 0.5, 0.25, 2.0, -1.0, 0.75, 0.1, 0.9]; + rope_rotate_heads(&mut v, 1, 8, position, theta); + v + } + + /// #5503: the rotation follows the configured base — Llama 1e4 differs from Qwen 1e6 + /// at position > 0, and the two agree at position 0 (angle 0). + #[test] + fn rope_follows_the_configured_theta() { + assert_eq!(rotated(1e4, 0), rotated(1e6, 0)); + assert_ne!(rotated(1e4, 5), rotated(1e6, 5)); + assert_eq!(rotated(DEFAULT_ROPE_THETA, 5), rotated(1e6, 5)); + } + + /// Pair i rotates by position * theta^(-2i/d): checked against the closed form. + #[test] + fn rope_matches_the_closed_form() { + let mut v = vec![1.0, 0.0, 0.0, 0.0]; + rope_rotate_heads(&mut v, 1, 4, 3, 1e4); + let a = 3.0f64; // i = 0: theta^0 = 1 + assert!((f64::from(v[0]) - a.cos()).abs() < 1e-6); + assert!((f64::from(v[2]) - a.sin()).abs() < 1e-6); + } +} diff --git a/crates/aprender-contracts/src/ontology/extract/kernel_cells_tests.rs b/crates/aprender-contracts/src/ontology/extract/kernel_cells_tests.rs index 736aebee86..b265b6575b 100644 --- a/crates/aprender-contracts/src/ontology/extract/kernel_cells_tests.rs +++ b/crates/aprender-contracts/src/ontology/extract/kernel_cells_tests.rs @@ -573,15 +573,15 @@ mod rr2 { let header = " - id: release-readiness-v2.model-smoke\n targetClass: release:ModelKernelCell\n properties:\n"; let text = contract(); assert!(text.contains(header), "the model-smoke shape header moved"); - let (_, twice) = census(&parse(&text.replace(header, ""))); - assert_eq!( - twice.len(), - 1, - "the planted control did not fire: {twice:?}" - ); + // Two `node:` blocks in one shape used to write `/node` twice (#5501); each is now named by its path. + let (n, twice) = census(&parse(&text.replace(header, ""))); assert!( - twice[0].ends_with("shape/release-readiness-v2.model/node>"), - "{twice:?}" + twice.is_empty(), + "two nested nodes in one shape share an IRI: {twice:?}" + ); + assert_eq!( + n, 5, + "the planted control still merges the smoke into the model shape" ); } diff --git a/crates/aprender-contracts/src/ontology/shapes.rs b/crates/aprender-contracts/src/ontology/shapes.rs index 353b30db72..66688d9307 100644 --- a/crates/aprender-contracts/src/ontology/shapes.rs +++ b/crates/aprender-contracts/src/ontology/shapes.rs @@ -127,7 +127,7 @@ pub struct PropertyShape { /// A node shape: a target class, an open/closed switch, and its property shapes. #[derive(Debug, Clone)] pub struct NodeShape { - /// The contract that declares it (its stem); nested `node` shapes are `/node`. + /// The contract that declares it (its stem); nested `node` shapes are `/node/`. pub id: String, pub target_class: String, pub closed: bool, @@ -490,7 +490,7 @@ fn parse_property( // an IRI still matches an IRI value, because a `sh:in` over `nodeKind: IRI` is a list of IRIs. let r#in = parse_in(shape, pm)?; let pattern = parse_pattern(shape, pm)?; - let node = parse_nested_node(shape, pm, depth)?; + let node = parse_nested_node(shape, path, pm, depth)?; let severity = parse_severity( shape, pm.get("severity").and_then(serde_yaml::Value::as_str), @@ -606,9 +606,25 @@ fn parse_pattern( Ok(Some((p.to_string(), re))) } -/// `node`: one level of nesting is in the subset, deeper is not. +/// The part of a property path that names its nested shape: after the last `:`, `/` or `#`, kept to IRI-safe +/// characters (#5501: two `node:` blocks in one shape must not share an IRI). +fn path_local(path: &str) -> String { + let tail = path.rsplit([':', '/', '#']).next().unwrap_or(path); + tail.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +/// `node`: one level of nesting is in the subset, deeper is not. Named `/node/`. fn parse_nested_node( shape: &str, + path: &str, pm: &serde_yaml::Mapping, depth: usize, ) -> Result>, ShapeError> { @@ -623,7 +639,7 @@ fn parse_nested_node( .as_mapping() .ok_or_else(|| malformed_in(shape, "`node` is not a mapping".into()))?; Ok(Some(Box::new(parse_node_shape( - &format!("{shape}/node"), + &format!("{shape}/node/{}", path_local(path)), nm, None, depth + 1, @@ -1384,6 +1400,36 @@ mod tests { const BASE: &str = "entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:id, minCount: 1, maxCount: 1, pattern: '^[a-z0-9-]+$'}\n - {path: ont:kind, maxCount: 1, in: [kernel, pattern]}\n"; + /// #5501: two `node:` blocks in one shape each get their own IRI, so the Turtle export names every node + /// shape once (an RDF reader merges two sh:NodeShape of one name into a shape nothing satisfies). + #[test] + fn two_nested_nodes_in_one_shape_get_distinct_ids() { + let yaml = "entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:a, node: {properties: [{path: ont:id, minCount: 1}]}}\n - {path: ont:b, node: {properties: [{path: ont:kind, minCount: 1}]}}\n"; + let s = shape(yaml); + let ids: Vec<&str> = s + .properties + .iter() + .filter_map(|p| p.node.as_deref().map(|n| n.id.as_str())) + .collect(); + assert_eq!(ids.len(), 2, "{ids:?}"); + assert_ne!(ids[0], ids[1], "{ids:?}"); + let ttl = to_turtle(&[s]); + let subjects: Vec<&str> = ttl + .lines() + .filter(|l| l.ends_with(" a sh:NodeShape ;")) + .filter_map(|l| l.split(' ').next()) + .collect(); + let mut uniq = subjects.clone(); + uniq.sort_unstable(); + uniq.dedup(); + assert_eq!( + subjects.len(), + uniq.len(), + "a node shape is named twice: {subjects:?}" + ); + assert_eq!(subjects.len(), 3, "{subjects:?}"); + } + #[test] fn allow_empty_carries_its_reason_and_refuses_a_blank_or_nested_one() { let with = |v: &str| { diff --git a/crates/aprender-data/src/hf_hub/tests.rs b/crates/aprender-data/src/hf_hub/tests.rs index acd7ffab91..d46b06fa4b 100644 --- a/crates/aprender-data/src/hf_hub/tests.rs +++ b/crates/aprender-data/src/hf_hub/tests.rs @@ -1326,8 +1326,10 @@ task_categories: [ --- "; let errors = DatasetCardValidator::validate_readme(readme); - // Should not crash, just return empty errors (YAML parse fails gracefully) - assert!(errors.is_empty()); + // #5500: unparseable front matter is reported, not silently accepted. + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].field, "front matter"); + assert!(DatasetCardValidator::validate_readme_strict(readme).is_err()); } #[test] diff --git a/crates/aprender-data/src/hf_hub/validation.rs b/crates/aprender-data/src/hf_hub/validation.rs index 4f5055b8aa..546cd8c0c4 100644 --- a/crates/aprender-data/src/hf_hub/validation.rs +++ b/crates/aprender-data/src/hf_hub/validation.rs @@ -150,12 +150,24 @@ impl DatasetCardValidator { return errors; }; - // Parse YAML - let Ok(yaml) = serde_saphyr::from_str_with_options::( + if yaml_content.trim().is_empty() { + return errors; + } + + // Parse YAML; front matter that does not parse is an error, not a pass (#5500) + let yaml = match serde_saphyr::from_str_with_options::( &yaml_content, serde_saphyr::options! { strict_booleans: true, merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary, emit_comments: false, reject_non_finite_typeless_float: false }, - ) else { - return errors; + ) { + Ok(yaml) => yaml, + Err(e) => { + errors.push(ValidationError { + field: "front matter".to_string(), + value: e.to_string(), + suggestions: Vec::new(), + }); + return errors; + } }; Self::check_field(&yaml, "task_categories", VALID_TASK_CATEGORIES, &mut errors); diff --git a/crates/aprender-serve/src/cuda/executor/batched_gemv_dispatch.rs b/crates/aprender-serve/src/cuda/executor/batched_gemv_dispatch.rs index 2b6481682d..024b6b53d1 100644 --- a/crates/aprender-serve/src/cuda/executor/batched_gemv_dispatch.rs +++ b/crates/aprender-serve/src/cuda/executor/batched_gemv_dispatch.rs @@ -1,4 +1,11 @@ impl CudaExecutor { + /// Forget the cached Q8_1 activation quantization: a caller that rewrote an + /// input buffer from the host (a test, a probe) must call this before a GEMV on it. + #[cfg(test)] + pub(crate) fn invalidate_q8_activation(&mut self) { + self.q8_activation_valid = false; + } + /// #5303: [`Self::gemv_dispatch`] over `m` stacked activation rows: /// `output[r] = W · input[r]`, `input` `m × k` and `output` `m × n`. /// diff --git a/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch.rs b/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch.rs index 2906a87f69..f6a8ebe960 100644 --- a/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch.rs +++ b/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch.rs @@ -422,3 +422,7 @@ pub(crate) use graph::Qwen35BatchGraphs; #[cfg(test)] #[path = "forward_qwen35_cuda_batch_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "forward_qwen35_cuda_batch_gemv_tests.rs"] +mod gemv_tests; diff --git a/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch_gemv_tests.rs b/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch_gemv_tests.rs new file mode 100644 index 0000000000..d7e7c56289 --- /dev/null +++ b/crates/aprender-serve/src/gguf/cuda/forward_qwen35_cuda_batch_gemv_tests.rs @@ -0,0 +1,160 @@ +//! #5303: `batched_gemv_dispatch` against `gemv_dispatch` row by row, on the +//! projections of the real Qwen3.5-0.8B file. + +use super::super::{CudaLayer, CudaQuantWeight}; +use crate::cuda::types::WeightQuantType; +use crate::cuda::CudaExecutor; +use crate::gguf::cuda::Qwen35CudaModel; +use crate::gguf::forward_qwen35::Qwen35Model; +use trueno_gpu::driver::GpuBuffer; + +/// The real hybrid file, as in the other Qwen3.5 CUDA tests. +const MODEL_PATH: &str = "/home/noah/models/Qwen3.5-0.8B-Q4_K_M.gguf"; + +/// Row counts the batch scheduler forms (c = 1, 2, 4, 8, 16). +const ROWS: [usize; 5] = [1, 2, 4, 8, 16]; + +/// Row `r` of an `m × k` activation matrix: bounded, distinct per row and +/// element, with both signs, so no row can equal another by accident. +fn activation(r: usize, k: usize) -> Vec { + (0..k) + .map(|i| { + let x = (r * 7919 + i * 104_729 + 13) % 2003; + (x as f32 / 2003.0 - 0.5) * 4.0 + }) + .collect() +} + +/// Row `r` of `buf` as a view; forget it, it owns nothing. +fn view(buf: &GpuBuffer, r: usize, width: u32) -> GpuBuffer { + Qwen35CudaModel::view(buf, r as u32 * width, width) +} + +/// The first projection of each distinct (qtype, n, k) in the model: every +/// FFN gate/up/down and the `lm_head`. +fn distinct_weights( + gpu: &Qwen35CudaModel<'_>, +) -> Vec<(&'static str, WeightQuantType, u64, u32, u32)> { + let mut seen: Vec<(&'static str, WeightQuantType, u64, u32, u32)> = Vec::new(); + let mut push = |what: &'static str, w: &CudaQuantWeight| { + if !seen + .iter() + .any(|s| s.1 == w.qtype && s.3 == w.n && s.4 == w.k) + { + seen.push((what, w.qtype, w.ptr, w.n, w.k)); + } + }; + for layer in &gpu.layers { + match layer { + CudaLayer::DeltaNet(w) => { + push("attn_qkv", &w.attn_qkv); + push("ssm_alpha", &w.ssm_alpha); + push("ssm_beta", &w.ssm_beta); + push("attn_gate", &w.attn_gate); + push("ssm_out", &w.ssm_out); + }, + CudaLayer::Attention(w) => { + push("attn_q", &w.attn_q); + push("attn_k", &w.attn_k); + push("attn_v", &w.attn_v); + push("attn_output", &w.attn_output); + }, + } + let f = layer.ffn(); + push("ffn_gate", f.gate); + push("ffn_up", f.up); + push("ffn_down", f.down); + } + push("lm_head", &gpu.lm_head); + seen +} + +/// One weight, one m: the max bit difference over rows, `None` if equal. +fn first_difference( + ex: &mut CudaExecutor, + (qtype, ptr, n, k): (WeightQuantType, u64, u32, u32), + m: usize, +) -> Option { + let host: Vec = (0..m).flat_map(|r| activation(r, k as usize)).collect(); + let mut input = GpuBuffer::::new(ex.context(), m * k as usize).expect("input"); + input.copy_from_host_at(&host, 0).expect("upload"); + ex.invalidate_q8_activation(); + let batched = GpuBuffer::::new(ex.context(), m * n as usize).expect("batched out"); + let solo = GpuBuffer::::new(ex.context(), m * n as usize).expect("solo out"); + + ex.batched_gemv_dispatch(qtype, ptr, &input, &batched, m as u32, n, k) + .expect("batched"); + for r in 0..m { + let x = view(&input, r, k); + let y = view(&solo, r, n); + // The Q8 activation cache is keyed on a buffer's base pointer. + ex.invalidate_q8_activation(); + let run = ex.gemv_dispatch(qtype, ptr, &x, &y, n, k); + std::mem::forget(x); + std::mem::forget(y); + run.expect("single row"); + } + let mut a = vec![0f32; m * n as usize]; + let mut b = vec![0f32; m * n as usize]; + batched + .copy_to_host_at(&mut a, 0) + .expect("download batched"); + solo.copy_to_host_at(&mut b, 0).expect("download solo"); + let mut worst = 0f32; + let mut first = None; + for (i, (x, y)) in a.iter().zip(&b).enumerate() { + if x.to_bits() != y.to_bits() { + worst = worst.max((x - y).abs()); + first.get_or_insert(i); + } + } + first.map(|i| { + format!( + "row {} col {}: batched {} vs single {}, max |diff| {worst}", + i / n as usize, + i % n as usize, + a[i], + b[i] + ) + }) +} + +/// FALSIFY (#5303): for m in {1, 2, 4, 8, 16} and every projection shape the +/// Qwen3.5 forward uses, each row of `batched_gemv_dispatch` is bitwise the +/// row `gemv_dispatch` gives alone. A qtype that is not equal is named with +/// its measured difference. +#[test] +#[serial_test::serial] +fn batched_gemv_rows_equal_the_single_row_kernel() { + if !std::path::Path::new(MODEL_PATH).exists() { + eprintln!("SKIP: {MODEL_PATH} is absent"); + return; + } + let executor = crate::cuda_executor_or_skip!(0); + let mapped = crate::gguf::MappedGGUFModel::from_path(MODEL_PATH).expect("map the GGUF"); + let base = Qwen35Model::create_base_model(&mapped.model, mapped.data()).expect("base model"); + let qwen = + Qwen35Model::from_model_and_layers(&base, &mapped.model, mapped.data()).expect("qwen35"); + let mut gpu = Qwen35CudaModel::new(&qwen, executor).expect("build the CUDA model"); + + let weights = distinct_weights(&gpu); + assert!(!weights.is_empty(), "no projection found"); + let mut unequal = Vec::new(); + for (what, qtype, ptr, n, k) in weights { + for m in ROWS { + let diff = first_difference(&mut gpu.executor, (qtype, ptr, n, k), m); + eprintln!( + "[gemv] {what} {qtype:?} n={n} k={k} m={m}: {}", + diff.as_deref().unwrap_or("bitwise equal") + ); + if let Some(d) = diff { + unequal.push(format!("{what} {qtype:?} n={n} k={k} m={m}: {d}")); + } + } + } + assert!( + unequal.is_empty(), + "batched GEMV rows differ from the single-row kernel:\n{}", + unequal.join("\n") + ); +} diff --git a/crates/aprender-serve/src/infer/batch_wgpu.rs b/crates/aprender-serve/src/infer/batch_wgpu.rs index 5265969fcf..8e931c81ee 100644 --- a/crates/aprender-serve/src/infer/batch_wgpu.rs +++ b/crates/aprender-serve/src/infer/batch_wgpu.rs @@ -159,6 +159,7 @@ fn try_init_wgpu_batch( ); // #4056: the WGSL RMSNorm takes the model's eps (it hardcoded 1e-6). fwd.set_rms_norm_eps(cfg.eps); + fwd.set_rope_theta(f64::from(cfg.rope_theta)); // C-WGPU-Q4K-001: Upload raw Q4K bytes for projection weights (7x less VRAM). let raw_q4k = wgpu_adapter::raw_q4k_weights(model); diff --git a/crates/aprender-train/src/finetune/instruct_pipeline/wgpu.rs b/crates/aprender-train/src/finetune/instruct_pipeline/wgpu.rs index a26bc4f459..43c8ce85bf 100644 --- a/crates/aprender-train/src/finetune/instruct_pipeline/wgpu.rs +++ b/crates/aprender-train/src/finetune/instruct_pipeline/wgpu.rs @@ -176,6 +176,7 @@ impl InstructPipeline { ); // #4056: the WGSL RMSNorm takes the model's eps (it hardcoded 1e-6). fwd.set_rms_norm_eps(_model_config.rms_norm_eps); + fwd.set_rope_theta(f64::from(_model_config.rope_theta)); // KAIZEN: Only upload norm weights (tiny: 14 KB each, 28 layers = ~800 KB total). let mut uploaded = 0usize;