diff --git a/contracts/quant-dispatch-completeness-v1.yaml b/contracts/quant-dispatch-completeness-v1.yaml new file mode 100644 index 0000000000..6db11e048c --- /dev/null +++ b/contracts/quant-dispatch-completeness-v1.yaml @@ -0,0 +1,236 @@ +metadata: + version: "1.0.0" + created: "2026-09-17" + author: PAIML Engineering + registry: true + spec: docs/specifications/PP-QUANT-001-MASTER.md + ticket: "#3418" + epic: "#3421" + references: + - "llama.cpp ggml/include/ggml.h enum ggml_type @ 3173a5647 (2026-08-29)" + - "llama.cpp ggml/src/ggml.c ggml_type_traits[] @ 3173a5647" + - "llama.cpp ggml/src/ggml-common.h block_* static_asserts @ 3173a5647" + description: > + The single source of truth for every GGUF/ggml tensor type aprender-serve + must be able to name, size and (where ggml can) dequantize. Mirrors + ggml_type_traits[]: one row per type id. The Rust dispatch table + (PP-QUANT-001 §2.1) is complete iff it has an entry for every row in + `types` and rejects every id in `removed_ids` and every id not listed. + +name: quant-dispatch-completeness +version: "1.0.0" +scope: "crates/aprender-serve/src/" + +description: | + Five closed tickets (#1749, #1789, #2535, #3341, #3091) were the same defect: + one of ~30 files held its own incomplete quant-type list. This contract + enumerates the ggml type universe ONCE so a Rust test can assert the + dispatch table covers all of it, turning the honor system into a merge gate. + + Numbers here are transcribed from ggml-common.h's `static_assert(sizeof( + block_X) == …)` lines with QK_K = 256 and sizeof(ggml_half) = 2. A row that + disagrees with the reference is a bug in this file, not a design choice. + +# --------------------------------------------------------------------------- +# families — how a type turns bytes into f32. Each family maps to one Rust +# representation (PP-QUANT-001 §2.4): +# scalar no blocks; one element per `block_bytes` (plain conversion) +# affine_block 32-element blocks, one scale (+min) QuantBlockFormat, FAMILY=Simple +# affine_kquant 256-element super-blocks, sub-block scales QuantBlockFormat, FAMILY=KQuant +# codebook 256-element super-blocks indexing a fixed grid QuantCodebookFormat +# ternary 256-element super-blocks, {-1,0,1} packed QuantBlockFormat (ZERO_OFFSET=1) +# fp4 32/64-element blocks of E2M1 with shared exp QuantCodebookFormat (16-entry grid) +# activation ggml defines NO to_float: an activation-side +# layout only, never a weight on disk table entry with dequant = None +# --------------------------------------------------------------------------- +families: + - scalar + - affine_block + - affine_kquant + - codebook + - ternary + - fp4 + - activation + +# ids that ggml.h marks "support has been removed". A GGUF carrying one is +# corrupt or ancient; the table MUST reject them by name, not by "unknown id". +removed_ids: [4, 5, 31, 32, 33, 36, 37, 38] + +# GGML_TYPE_COUNT at the reference commit. ids >= this are unknown. +type_count: 43 + +# One row per live ggml type. `dequant: false` = ggml's own ggml_type_traits[] +# has to_float == NULL for this type (Q8_1, Q8_K), so aprender owes a size +# entry but no dequant function. `status` is the state of aprender-serve at +# origin/main @ 425e84888 (2026-09-17) and is what Phase 1 changes: +# named a GGUF_TYPE_* const or GgmlQuantType variant exists +# sized tensor_byte_size (gguf/transformer.rs) can size it +# dequant a CPU dequant/matvec path exists (QuantBlockFormat impl or fused_*) +# missing none of the above +types: + - {id: 0, name: F32, family: scalar, block: 1, bytes: 4, dequant: true, status: dequant} + - {id: 1, name: F16, family: scalar, block: 1, bytes: 2, dequant: true, status: dequant} + - {id: 2, name: Q4_0, family: affine_block, block: 32, bytes: 18, dequant: true, status: dequant} + - {id: 3, name: Q4_1, family: affine_block, block: 32, bytes: 20, dequant: true, status: sized} + - {id: 6, name: Q5_0, family: affine_block, block: 32, bytes: 22, dequant: true, status: sized} + - {id: 7, name: Q5_1, family: affine_block, block: 32, bytes: 24, dequant: true, status: named} + - {id: 8, name: Q8_0, family: affine_block, block: 32, bytes: 34, dequant: true, status: dequant} + - {id: 9, name: Q8_1, family: activation, block: 32, bytes: 36, dequant: false, status: named} + - {id: 10, name: Q2_K, family: affine_kquant, block: 256, bytes: 84, dequant: true, status: sized} + - {id: 11, name: Q3_K, family: affine_kquant, block: 256, bytes: 110, dequant: true, status: named} + - {id: 12, name: Q4_K, family: affine_kquant, block: 256, bytes: 144, dequant: true, status: dequant} + - {id: 13, name: Q5_K, family: affine_kquant, block: 256, bytes: 176, dequant: true, status: dequant} + - {id: 14, name: Q6_K, family: affine_kquant, block: 256, bytes: 210, dequant: true, status: dequant} + - {id: 15, name: Q8_K, family: activation, block: 256, bytes: 292, dequant: false, status: missing} + - {id: 16, name: IQ2_XXS, family: codebook, block: 256, bytes: 66, dequant: true, status: named, grid: iq2xxs_grid} + - {id: 17, name: IQ2_XS, family: codebook, block: 256, bytes: 74, dequant: true, status: named, grid: iq2xs_grid} + - {id: 18, name: IQ3_XXS, family: codebook, block: 256, bytes: 98, dequant: true, status: missing, grid: iq3xxs_grid} + - {id: 19, name: IQ1_S, family: codebook, block: 256, bytes: 50, dequant: true, status: missing, grid: iq1s_grid} + - {id: 20, name: IQ4_NL, family: codebook, block: 32, bytes: 18, dequant: true, status: missing, grid: kvalues_iq4nl} + - {id: 21, name: IQ3_S, family: codebook, block: 256, bytes: 110, dequant: true, status: missing, grid: iq3s_grid} + - {id: 22, name: IQ2_S, family: codebook, block: 256, bytes: 82, dequant: true, status: missing, grid: iq2s_grid} + - {id: 23, name: IQ4_XS, family: codebook, block: 256, bytes: 136, dequant: true, status: missing, grid: kvalues_iq4nl} + - {id: 24, name: I8, family: scalar, block: 1, bytes: 1, dequant: false, status: missing} + - {id: 25, name: I16, family: scalar, block: 1, bytes: 2, dequant: false, status: missing} + - {id: 26, name: I32, family: scalar, block: 1, bytes: 4, dequant: false, status: missing} + - {id: 27, name: I64, family: scalar, block: 1, bytes: 8, dequant: false, status: missing} + - {id: 28, name: F64, family: scalar, block: 1, bytes: 8, dequant: true, status: missing} + - {id: 29, name: IQ1_M, family: codebook, block: 256, bytes: 56, dequant: true, status: missing, grid: iq1s_grid} + - {id: 30, name: BF16, family: scalar, block: 1, bytes: 2, dequant: true, status: dequant} + - {id: 34, name: TQ1_0, family: ternary, block: 256, bytes: 54, dequant: true, status: missing} + - {id: 35, name: TQ2_0, family: ternary, block: 256, bytes: 66, dequant: true, status: missing} + - {id: 39, name: MXFP4, family: fp4, block: 32, bytes: 17, dequant: true, status: missing, grid: kvalues_fp4} + - {id: 40, name: NVFP4, family: fp4, block: 64, bytes: 36, dequant: true, status: missing, grid: kvalues_fp4} + - {id: 41, name: Q1_0, family: affine_block, block: 128, bytes: 18, dequant: true, status: missing} + - {id: 42, name: Q2_0, family: affine_block, block: 64, bytes: 18, dequant: true, status: missing} + +# Codebook grids the QuantCodebookFormat impls port verbatim from ggml-common.h +# (GGML_TABLE_BEGIN lines). `entries` × sizeof(elem) = bytes each grid costs in +# .rodata; all seven together are 25.6 KiB. Ported as `const` arrays generated +# by a script from the reference header, never typed in by hand. +grids: + - {name: kmask_iq2xs, elem: u8, entries: 8} + - {name: ksigns_iq2xs, elem: u8, entries: 128} + - {name: iq2xxs_grid, elem: u64, entries: 256} + - {name: iq2xs_grid, elem: u64, entries: 512} + - {name: iq2s_grid, elem: u64, entries: 1024} + - {name: iq3xxs_grid, elem: u32, entries: 256} + - {name: iq3s_grid, elem: u32, entries: 512} + - {name: iq1s_grid, elem: u64, entries: 2048} + - {name: kvalues_iq4nl, elem: i8, entries: 16} + - {name: kvalues_fp4, elem: i8, entries: 16} + +# The sites that match on a quant type today (Phase 0 inventory, PP-QUANT-001 +# §3). Oracle, run from the repo root: +# grep -rln "GGUF_TYPE_Q4_K\|GGUF_TYPE_Q4_0\|match qtype\|match.*\.qtype" \ +# crates/aprender-serve/src/ --include="*.rs" | grep -v test | sort +# `qdc_dispatch_sites_match_oracle` (quantize/dispatch_contract_tests.rs) re-runs +# this oracle and requires oracle(tree) == {file : migrated == false}: a new site +# fails CI until listed, and a row may only flip to `migrated: true` once the +# file really stopped matching on a raw qtype. +# Phase 2 exit: every row without `keep` is `migrated: true`; the `keep` rows +# (the table's key enum and the CUDA kernel selector) stay open by design. +dispatch_sites: + - {file: crates/aprender-serve/src/api/model_source.rs, migrated: false} + - {file: crates/aprender-serve/src/apr/cache.rs, migrated: false} + - {file: crates/aprender-serve/src/convert/q4k_conversion_stats.rs, migrated: false} + - {file: crates/aprender-serve/src/convert/q4k_converter_helpers.rs, migrated: false} + - {file: crates/aprender-serve/src/cuda/executor/layers/cublas_prefill/gemm.rs, migrated: false} + - {file: crates/aprender-serve/src/cuda/executor/layers/gemv_dispatch.rs, migrated: false} + - {file: crates/aprender-serve/src/cuda/types.rs, migrated: false, keep: "WeightQuantType registry; Phase 1 derives it from the table"} + - {file: crates/aprender-serve/src/gguf/cuda/cuda.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/cuda/expert_swiglu_cuda.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/cuda/matmul.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/embedding.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/acceleration.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/batch.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/ffn_block.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/results.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/single.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/forward/traced.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/fused_matmul_into.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/inference/matmul_fused.rs, migrated: false, note: "#1749/#1789 site"} + - {file: crates/aprender-serve/src/gguf/inference/matmul.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/loader.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/metadata.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/model_gguf_transformer.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/model_owned_quantized.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/quantized.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/qwen3_moe_load.rs, migrated: false, note: "#3341 site"} + - {file: crates/aprender-serve/src/gguf/transformer_quantized_layer_field.rs, migrated: false} + - {file: crates/aprender-serve/src/gguf/transformer.rs, migrated: false, note: "#3091 site: tensor_byte_size — first to migrate"} + - {file: crates/aprender-serve/src/gguf/types.rs, migrated: false, keep: "GgmlQuantType — the table's key enum"} + - {file: crates/aprender-serve/src/gpu/adapters/wgpu_adapter.rs, migrated: false} + +equations: + completeness: + description: "∀ id ∈ types: table[id] is Some — every live ggml type has an entry" + formula: "∀ t ∈ types: quant_type_traits(t.id) = Some(e) ∧ e.name = t.name" + invariants: + - "|{t ∈ types}| = 35 = type_count − |removed_ids| (every id in [0, type_count) is a row or a removed id; none is silently skipped)" + rejection: + description: "∀ id ∈ removed_ids ∪ [type_count, ∞): table[id] is None — removed and unknown ids are refused, never defaulted" + formula: "∀ id ∈ removed_ids: quant_type_traits(id) = None ∧ ∀ id ≥ type_count: quant_type_traits(id) = None" + byte_size: + description: "∀ t: table[t.id].bytes_for(n) = ceil(n / t.block) × t.bytes — the only place a tensor is sized" + formula: "bytes_for(n) = ⌈n / block⌉ · bytes" + postconditions: + - "bytes_for(block) = bytes" + - "bytes_for(0) = 0" + dequant_presence: + description: "∀ t: (table[t.id].dequant is Some) ⇔ t.dequant — the table has a dequant fn exactly for the types ggml itself can dequantize" + formula: "is_some(e.dequant_to_f32) ⇔ t.dequant" + single_source: + description: "The set of files matching on a raw qtype outside the table shrinks monotonically to the `keep` set" + formula: "oracle(tree) ⊆ {s.file : s ∈ dispatch_sites}" + +proof_obligations: + - id: PO-QDC-001 + type: invariant + property: "quant_type_traits() covers every row of `types` (equation completeness)" + formal: "rust test: iterate the contract's types, assert Some for each id and name equality" + - id: PO-QDC-002 + type: invariant + property: "quant_type_traits() returns None for every removed_ids entry and for id = type_count (equation rejection)" + formal: "rust test: iterate removed_ids ∪ {type_count}, assert None" + - id: PO-QDC-003 + type: invariant + property: "Every QuantBlockFormat / QuantCodebookFormat impl's SUPERBLOCK_BYTES and ELEMENTS_PER_SUPERBLOCK equal the `bytes`/`block` of its row (equation byte_size)" + formal: "rust test: for each impl, compare associated consts against the contract row" + - id: PO-QDC-004 + type: invariant + property: "Every grid in `grids` is present in the Rust port with exactly `entries` elements, and its first and last element equal the reference header's" + formal: "rust test over the generated grid module; generator script asserts the same at generation time" + - id: PO-QDC-005 + type: invariant + property: "Re-running the dispatch_sites oracle yields no file absent from dispatch_sites (equation single_source)" + formal: "rust test: run the oracle regex over crates/aprender-serve/src, diff against the list" + +falsification_tests: + - id: FALSIFY-QDC-001 + rule: "Removing any single row's table entry turns PO-QDC-001 RED" + prediction: "Delete the Q3_K entry from QUANT_TYPE_TRAITS: the completeness test fails naming id 11 / Q3_K. OWED (Phase 1, needs the table): test fn qdc_table_covers_every_row in quantize/dispatch_contract_tests.rs" + if_fails: "The gate is theater: the table can lose a type without CI noticing — #3091's failure mode is still open" + - id: FALSIFY-QDC-002 + rule: "A table entry for a removed id turns PO-QDC-002 RED" + prediction: "Add an entry at id 4 (Q4_2): the rejection test fails" + test: "cargo test -p aprender-serve --lib qdc_removed_ids_rejected" + if_fails: "Removed/corrupt ids can be defaulted to a real kernel — the ALG-006 vacuous-catch-all class" + - id: FALSIFY-QDC-003 + rule: "A wrong byte size in any impl turns PO-QDC-003 RED" + prediction: "Set Q4K::SUPERBLOCK_BYTES = 145: the byte-size test fails naming Q4_K" + test: "cargo test -p aprender-serve --lib qdc_block_format_consts_match_rows" + if_fails: "Tensor sizing can drift from the on-disk layout — the #2535 'truncated/corrupt' misdiagnosis class" + - id: FALSIFY-QDC-004 + rule: "A truncated or edited grid turns PO-QDC-004 RED" + prediction: "Drop the last element of iq2xs_grid: the grid test fails naming iq2xs_grid, expected 512. OWED (Phase 1, needs iq_grids.rs): test fn qdc_grids_match_reference in quantize/dispatch_contract_tests.rs" + if_fails: "Codebook dequant can silently index past or into a wrong lattice point" + - id: FALSIFY-QDC-005 + rule: "A new file that matches on a raw qtype outside dispatch_sites turns PO-QDC-005 RED" + prediction: "Add `match qtype { GGUF_TYPE_Q4_K => … }` to a new non-test file: the single-source test fails naming that file" + test: "cargo test -p aprender-serve --lib qdc_dispatch_sites_match_oracle" + if_fails: "A 31st dispatch site can appear — the exact mechanism behind all five prior tickets" + - id: FALSIFY-QDC-006 + rule: "The ggml reference and this file agree on every row" + prediction: "Regenerate `types` from ggml.h + ggml-common.h at the pinned commit: zero diff. OWED (Phase 1): scripts/gen_quant_dispatch_rows.sh --check" + if_fails: "This contract has drifted from the format it claims to enumerate and is no longer a source of truth" diff --git a/crates/aprender-serve/Cargo.toml b/crates/aprender-serve/Cargo.toml index 3b142207b9..2f98ed6fc5 100644 --- a/crates/aprender-serve/Cargo.toml +++ b/crates/aprender-serve/Cargo.toml @@ -168,6 +168,7 @@ serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" [dev-dependencies] +serde_yaml_ng = "0.10" # Contract trait enforcement (Section 23) provable-contracts = { path = "../aprender-contracts", package = "aprender-contracts" } diff --git a/crates/aprender-serve/src/quantize/dispatch_contract_tests.rs b/crates/aprender-serve/src/quantize/dispatch_contract_tests.rs new file mode 100644 index 0000000000..4beea2ea81 --- /dev/null +++ b/crates/aprender-serve/src/quantize/dispatch_contract_tests.rs @@ -0,0 +1,225 @@ +//! PP-QUANT-001 Phase 0 gate — binds `contracts/quant-dispatch-completeness-v1.yaml` +//! to the tree (docs/specifications/PP-QUANT-001-MASTER.md §2.2, §3). +//! +//! PO-QDC-002 (removed ids rejected), PO-QDC-003 (block-format consts equal their +//! rows) and PO-QDC-005 (the dispatch-site oracle agrees with the inventory) are +//! checkable before the table exists and are enforced here. PO-QDC-001 and +//! PO-QDC-004 need the Phase 1 table and grids; the contract lists them as owed. + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::path::{Path, PathBuf}; + + use serde::Deserialize; + + use crate::gguf::GgmlQuantType; + use crate::quantize::format_trait::{Q4_0Fmt, Q8_0Fmt, QuantBlockFormat, Q4K, Q5K, Q6K}; + + const CONTRACT_REL: &str = "contracts/quant-dispatch-completeness-v1.yaml"; + const CRATE_SRC_REL: &str = "crates/aprender-serve/src"; + + #[derive(Deserialize)] + struct Contract { + type_count: u32, + removed_ids: Vec, + types: Vec, + dispatch_sites: Vec, + } + + #[derive(Deserialize)] + struct TypeRow { + id: u32, + name: String, + block: usize, + bytes: usize, + } + + #[derive(Deserialize)] + struct SiteRow { + file: String, + #[serde(default)] + migrated: bool, + } + + fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root resolves") + } + + fn load_contract() -> Contract { + let path = repo_root().join(CONTRACT_REL); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + serde_yaml_ng::from_str(&text) + .unwrap_or_else(|e| panic!("{CONTRACT_REL} does not parse: {e}")) + } + + #[test] + fn qdc_enumeration_is_gapless() { + let c = load_contract(); + let live: BTreeSet = c.types.iter().map(|t| t.id).collect(); + let removed: BTreeSet = c.removed_ids.iter().copied().collect(); + assert_eq!(live.len(), c.types.len(), "duplicate ids in `types`"); + assert!(live.is_disjoint(&removed), "an id is both live and removed"); + let all: BTreeSet = (0..c.type_count).collect(); + let covered: BTreeSet = live.union(&removed).copied().collect(); + assert_eq!( + covered, all, + "every id in [0, type_count) must be a `types` row or a `removed_ids` entry" + ); + let names: BTreeSet<&str> = c.types.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names.len(), c.types.len(), "duplicate names in `types`"); + } + + /// PO-QDC-002 — today's `GgmlQuantType::from_id` must already refuse the removed ids. + #[test] + fn qdc_removed_ids_rejected() { + let c = load_contract(); + for id in c + .removed_ids + .iter() + .copied() + .chain(std::iter::once(c.type_count)) + { + assert!( + GgmlQuantType::from_id(id).is_none(), + "GgmlQuantType::from_id({id}) must be None (removed or out of range)" + ); + } + } + + /// Every id `GgmlQuantType` already knows must spell its name the way the contract does. + #[test] + fn qdc_named_rows_agree_with_ggml_quant_type() { + let c = load_contract(); + for row in &c.types { + if let Some(t) = GgmlQuantType::from_id(row.id) { + assert_eq!( + t.as_str(), + row.name, + "GgmlQuantType names id {} `{}`, the contract names it `{}`", + row.id, + t.as_str(), + row.name + ); + } + } + } + + fn assert_format_matches_row(c: &Contract) { + let row = c + .types + .iter() + .find(|t| t.name == F::FORMAT_ID) + .unwrap_or_else(|| panic!("no contract row named {}", F::FORMAT_ID)); + assert_eq!( + F::SUPERBLOCK_BYTES, + row.bytes, + "{}: SUPERBLOCK_BYTES {} != contract bytes {}", + F::FORMAT_ID, + F::SUPERBLOCK_BYTES, + row.bytes + ); + assert_eq!( + F::ELEMENTS_PER_SUPERBLOCK, + row.block, + "{}: ELEMENTS_PER_SUPERBLOCK {} != contract block {}", + F::FORMAT_ID, + F::ELEMENTS_PER_SUPERBLOCK, + row.block + ); + } + + /// PO-QDC-003 — every `QuantBlockFormat` impl's geometry equals its contract row. + #[test] + fn qdc_block_format_consts_match_rows() { + let c = load_contract(); + assert_format_matches_row::(&c); + assert_format_matches_row::(&c); + assert_format_matches_row::(&c); + assert_format_matches_row::(&c); + assert_format_matches_row::(&c); + } + + /// The oracle written beside `dispatch_sites` in the contract, line by line: + /// `GGUF_TYPE_Q4_K|GGUF_TYPE_Q4_0|match qtype|match.*\.qtype`. + fn oracle_hits_line(line: &str) -> bool { + line.contains("GGUF_TYPE_Q4_K") + || line.contains("GGUF_TYPE_Q4_0") + || line.contains("match qtype") + || line + .find("match") + .is_some_and(|i| line[i..].contains(".qtype")) + } + + fn collect_rs_files(dir: &Path, out: &mut Vec) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); + for entry in entries { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().is_some_and(|x| x == "rs") { + out.push(path); + } + } + } + + fn oracle_sites(root: &Path) -> BTreeSet { + let mut files = Vec::new(); + collect_rs_files(&root.join(CRATE_SRC_REL), &mut files); + files + .into_iter() + .filter_map(|p| { + let rel = p + .strip_prefix(root) + .expect("under repo root") + .to_string_lossy() + .into_owned(); + // `grep -v test` in the oracle drops any path containing "test". + if rel.contains("test") { + return None; + } + let src = std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {rel}: {e}")); + src.lines().any(oracle_hits_line).then_some(rel) + }) + .collect() + } + + /// PO-QDC-005 — the tree's raw-qtype dispatch sites are exactly the contract's + /// `migrated: false` rows. A new site fails until it is listed; a row flipped to + /// `migrated: true` fails until the site really stopped matching on a raw qtype. + #[test] + fn qdc_dispatch_sites_match_oracle() { + let root = repo_root(); + let c = load_contract(); + for s in &c.dispatch_sites { + assert!( + root.join(&s.file).is_file(), + "dispatch_sites lists a missing file: {}", + s.file + ); + } + let listed_open: BTreeSet = c + .dispatch_sites + .iter() + .filter(|s| !s.migrated) + .map(|s| s.file.clone()) + .collect(); + let found = oracle_sites(&root); + + let unlisted: Vec<&String> = found.difference(&listed_open).collect(); + let stale: Vec<&String> = listed_open.difference(&found).collect(); + assert!( + unlisted.is_empty() && stale.is_empty(), + "PO-QDC-005 FAILED — {CONTRACT_REL} `dispatch_sites` and the tree disagree.\n\ + Matching on a raw qtype but NOT listed (a new dispatch site — migrate it to \ + quant_type_traits() or add a row): {unlisted:?}\n\ + Listed as open (migrated: false) but no longer matching (flip the row to \ + migrated: true): {stale:?}" + ); + } +} diff --git a/crates/aprender-serve/src/quantize/mod.rs b/crates/aprender-serve/src/quantize/mod.rs index e71cd418e6..7d1ef98641 100644 --- a/crates/aprender-serve/src/quantize/mod.rs +++ b/crates/aprender-serve/src/quantize/mod.rs @@ -66,6 +66,8 @@ pub mod activation; pub mod bsum_precompute; pub mod contract_tests; pub mod dequant; +#[cfg(test)] +mod dispatch_contract_tests; pub mod encode; pub mod format_trait; pub mod fused_gate_up; diff --git a/docs/specifications/PP-QUANT-001-MASTER.md b/docs/specifications/PP-QUANT-001-MASTER.md new file mode 100644 index 0000000000..4570cb60f8 --- /dev/null +++ b/docs/specifications/PP-QUANT-001-MASTER.md @@ -0,0 +1,372 @@ +# PP-QUANT-001 v1.0 — MASTER — Quant-type dispatch consolidation + +**Status:** DRAFT · ticket #3418 (umbrella: "new architectures cost too many lines" — +see the 2026-09-17 comment on #3418 for how this and PP-ARCH-001 relate) · **Phase +0/1 on train 0.69.0**, Phase 2/3 on the dedicated milestone (per operator decision +2026-09-17; see §6.1) · authored 2026-09-17 against `origin/main` @ `425e84888` +**Companions:** `contracts/quant-dispatch-completeness-v1.yaml` (the type universe, the +Phase 0 inventory, and the gate's obligations; `pv validate` green) · +`crates/aprender-serve/src/quantize/format_trait.rs` (existing partial trait, extended +not replaced) · epic #3421 (Phase 0/1, 0.69.0) · epic #3423 (Phase 2/3, dedicated +milestone) · `docs/specifications/06x-release-schedule.md` (the train Phase 0/1 rides; +not amended by this document — see §6.2) +**Downstream:** `docs/specifications/PP-ARCH-001-MASTER.md` (#3422) — the shared +attention/FFN forward-path consolidation this document is a prerequisite for, tracked +jointly with this document's Phase 2/3 by epic #3423 on milestone `Inference dispatch +& architecture consolidation`. Closing PP-QUANT-001 alone does not deliver "new +architectures cost hundreds of lines, not thousands": of #3418's own 1,678-line Qwen3-MoE +count, only ~85 lines are quant dispatch; the rest is forward-pass logic PP-ARCH-001 +governs. PP-ARCH-001 needs this document's dispatch table to make its shared +`attn_block`/`moe_ffn_block` layer quant-agnostic. +**Governs:** how `crates/aprender-serve/` maps a GGUF/APR `qtype` to `{byte_size, +dequant_to_f32, family}`, and where that mapping is looked up from. +**Owner:** unassigned (§7) + +--- + +## §0 Conventions + +Same marks as `PP-LLAMA-001-MASTER.md` §0.3: `[V]` verified by a cited command in this +session · `[C]` calculated from `[V]` inputs · `[A]` asserted, re-verify at implementing +HEAD · `[U]` unmeasured, row names who measures it. + +This document states rules and phases. `contracts/quant-dispatch-completeness-v1.yaml` +declares the actual enumerated type list and is what a CI gate reads — a claim that a +type is "covered" is true only if that contract's obligation for it is `ARMED` (§0.4 of +PP-LLAMA-001's convention, reused here). This document cites the contract; it does not +duplicate its content. + +--- + +## §1 Problem, as evidenced in #3418 + +Quant-type dispatch (`qtype → {byte_size, dequant_to_f32, family}`) is reimplemented at +**30 call sites** across `crates/aprender-serve/src/` instead of living in one table +(llama.cpp's `ggml_type_traits[]` equivalent). `crates/aprender-serve/src/quantize/format_trait.rs` +already has `QuantBlockFormat` — a real, working trait — but: + +- Only ~5 of the 30 call sites route through it `[A]` (#3418 investigation). +- It models **affine** quantization only (`x = d*s*q - dmin*m`): Q4_0/Q8_0/Q4_K/Q5_K/Q6_K. + It has no representation for **codebook/lattice** types (IQ2_XXS/XS, IQ3_XXS/S, + IQ4_NL/XS) — those need a second trait shape, not an extension of the first, because + each block indexes a fixed grid table rather than carrying a per-block scale. +- The crate names 13 `GGUF_TYPE_*` tensor-type constants (§4 — #3418's "16" included + three metadata value types); ggml defines 35 live tensor types. Every type absent + from a given call site's own match arms is a live + `RealizarError::UnsupportedOperation` waiting on the next real download. +- Five closed tickets (#1749, #1789, #2535, #3341, #3091) are the same defect — + "this file's qtype list was incomplete" — recurring in five different files. Each fix + was scoped to the one file that crashed; none closed the class. + +Full evidence, grep commands, and the file list are in #3418 — not reproduced here to +avoid this document drifting from the ticket as the tree changes; §0 rule: this +document does not duplicate content that ages, it cites where the aging content lives. + +--- + +## §2 Target architecture + +### §2.1 Two traits, one dispatch entry point + +1. **`QuantBlockFormat`** (exists, `format_trait.rs`) — affine block formats. Extend its + impls to the full affine subset of the ggml universe (Q1_0, Q2_0, Q4_0/1, Q5_0/1, + Q8_0, Q2_K through Q6_K, TQ1_0/TQ2_0) rather than the current 5. Q8_1 and Q8_K get + a *size* entry only — ggml itself has no `to_float` for them (they are activation + layouts), so the table records `dequant_row: None` rather than pretending. +2. **`QuantCodebookFormat`** (new) — lattice/codebook formats (IQ1–IQ4 family). Grid + tables are ported data from llama.cpp's `ggml-quants.c` (`iq2xs_grid`, `iq3xs_grid`, + etc.) — reference data, not something to re-derive. +3. **One dispatch table**, `static QUANT_TYPE_TRAITS: [Option; N]` + plus a lookup fn `quant_type_traits(qtype: u32) -> Option<&'static QuantTypeTraits>` + (names TBD at implementation — analogous to `ggml_type_traits[]`). `QuantTypeTraits` + is a **plain data struct** — `{byte_size, block_size, family, dequant_to_f32: fn(&[u8], + &mut [f32]) -> Result<()>}` — not an enum over trait objects (§2.1a). This is the + single new public surface every call site is migrated to use. + +### §2.1a Why a data table, not `dyn QuantBlockFormat` (correction, 2026-09-17) + +An earlier draft of this section described the table's entries as +`Affine(dyn QuantBlockFormat) | Codebook(dyn QuantCodebookFormat)`. That is not +buildable: `QuantBlockFormat` carries associated `const`s (`FORMAT_ID`, +`SUPERBLOCK_BYTES`, …) and its methods take no `self` receiver (`read_d(superblock: +&[u8])`, not `read_d(&self, ...)`) — both independently make a trait non-object-safe, +so `dyn QuantBlockFormat` fails to compile. This was the point of `format_trait.rs`'s +existing design: monomorphized generics, zero vtable overhead — the opposite of what a +trait object gives you. + +`ggml_type_traits[]` itself was never trait-object-shaped either — it is a C struct of +plain fields and **function pointers**, stored in a static array indexed by the type +enum. The direct Rust port of that is a struct whose `dequant_to_f32` field is an +ordinary `fn` pointer, not a `dyn Trait`. Each table entry's function pointer is a +concrete, non-generic wrapper (e.g. `dequant_q4k_to_f32`) that calls the existing +monomorphized generic (`dequant_generic::(...)`) internally; the generic parameter +is fully resolved before the function is placed in the array, so no object-safety rule +is ever in play. `QuantBlockFormat`/`QuantCodebookFormat` are unchanged by this +correction — they remain the const-based, compile-time-specialized traits the hot-path +kernels use directly; the dispatch table is an additional, thin runtime layer over them, +not a replacement. + +### §2.2 The completeness gate (the mechanical backstop, not the honor system) + +`contracts/quant-dispatch-completeness-v1.yaml` (exists; `pv validate` green `[V]`) +is the enumeration. It carries, per live ggml type id: `name`, `family`, `block`, +`bytes`, whether ggml itself can dequantize it, and aprender's status at `425e84888`; +plus `removed_ids` (the eight ids ggml.h marks removed), `type_count` (43), the ten +codebook `grids` with their element type and length, and the Phase 0 `dispatch_sites` +inventory. Every number is transcribed from `ggml-common.h`'s `static_assert(sizeof( +block_X) == …)` lines at llama.cpp `3173a5647` and self-checked: 35 rows + 8 removed +ids = 43 = `type_count`, no gaps, no duplicates `[V]`. + +The gate is a Rust test (owed, Phase 1) that reads the contract and asserts the table +in §2.4 has `Some` for every row, `None` for every removed id and for `type_count`, and +that every `QuantBlockFormat`/`QuantCodebookFormat` impl's byte constants equal its row. +A missing type is then a CI failure at merge time — what #1749/#1789/#2535/#3341/#3091 +needed and did not have; each was an honor-system list, five times over. The contract's +`FALSIFY-QDC-001..006` name the mutation that must turn each assertion RED. + +### §2.3 Per-backend fan-out is additive, not multiplicative + +CPU SIMD, CUDA, and wgpu each implement the trait once per type. The ~30-call-site +migration happens once, regardless of backend count, because call sites stop matching +on `qtype` themselves and instead look up the one dispatch table and call the returned +`QuantTypeTraits` struct's function-pointer fields. + +### §2.4 Rust design (Phase 1 blueprint) + +**§2.4.1 There are already three registries; the table is built on one of them, not +beside them.** At `425e84888` `[V]`: + +| Registry | Where | Covers | Carries | +|---|---|---|---| +| `GgmlQuantType` (`#[repr(u32)]`, `from_id`, `as_str`) | `crates/aprender-serve/src/gguf/types.rs` | 16 ids | name ↔ id only | +| `WeightQuantType` (PMAT-232: exhaustive matches, no `Default`) | `crates/aprender-serve/src/cuda/types.rs` | 8 | `bytes_per_superblock`, `bytes_per_block`, CUDA kernel choice | +| `QuantBlockFormat` impls (`Q4K`, `Q5K`, `Q6K`, `Q4_0`, `Q8_0`) | `crates/aprender-serve/src/quantize/format_trait.rs` | 5 | full block algebra, compile-time | +| `tensor_byte_size` (#3091's site) | `crates/aprender-serve/src/gguf/transformer.rs:435` | 11 | bytes only, its own arms | + +A fourth enum would be the two-lists defect again. `GgmlQuantType` is the key: it is +already `#[repr(u32)]` on the ggml id, already has `from_id`, and is what the loader +holds. Phase 1 extends it to all 35 live types and makes `from_id` reject the eight +removed ids by name. `WeightQuantType` stays as the *CUDA kernel selector* (its +`match`es choose kernels, which is legitimately backend-specific) but loses its byte +methods: they become `quant_type_traits(self.into()).bytes` so there is one number. + +**§2.4.2 The table entry.** + +```rust +// crates/aprender-serve/src/quantize/dispatch.rs (owed) +pub type DequantRowFn = fn(src: &[u8], dst: &mut [f32]) -> Result<()>; + +pub struct QuantTypeTraits { + pub id: u32, // ggml id; == index in the table + pub name: &'static str, // "Q4_K" — the GGUF spelling + pub family: QuantFamilyKind, // Scalar | AffineBlock | AffineKQuant | Codebook | Ternary | Fp4 | Activation + pub block: usize, // elements per (super-)block; 1 for scalars + pub bytes: usize, // bytes per (super-)block + pub dequant_row: Option, // None ⇔ ggml has no to_float (Q8_1, Q8_K, I*) +} + +impl QuantTypeTraits { + pub const fn bytes_for(&self, n_elements: usize) -> usize { n_elements.div_ceil(self.block) * self.bytes } +} + +static QUANT_TYPE_TRAITS: [Option; GGML_TYPE_COUNT] = [ /* index == id */ ]; + +pub fn quant_type_traits(id: u32) -> Option<&'static QuantTypeTraits> { + QUANT_TYPE_TRAITS.get(id as usize).and_then(Option::as_ref) +} +``` + +`dequant_row` is an `fn` pointer (§2.1a), filled with a non-generic wrapper per type: + +```rust +fn dequant_row_q4k(src: &[u8], dst: &mut [f32]) -> Result<()> { dequant_row::(src, dst) } +``` + +where `dequant_row` is generic and monomorphized. The hot kernels +(`fused_q4k_parallel_matvec`, `generic_fused_gate_up_matvec_into`, the CUDA GEMV +paths) are **not** routed through the fn pointer — they keep calling the generics +directly with a concrete `F`. The table serves the cold paths that today hold the +duplicated arms: sizing, validation, loading, conversion, metadata, "can this backend +take this tensor" decisions. That is where all five prior tickets crashed; none crashed +inside a kernel inner loop. + +`GGML_TYPE_COUNT` (43) is a `const` in `gguf/types.rs`; the array is indexed by id so a +removed id is a literal `None` slot, and an id ≥ 43 falls off the end — both are the +contract's `rejection` equation with no `_ =>` anywhere. + +**§2.4.3 `QuantCodebookFormat`** (`crates/aprender-serve/src/quantize/codebook_trait.rs`, +owed) mirrors `QuantBlockFormat`'s const-based shape so the same kind of generic +kernel can be written over it, but the algebra differs: a block holds a scale, a run +of grid *indices*, sign bits, and (for the `_S`/`_XS` variants) sub-block scales. + +```rust +pub trait QuantCodebookFormat: Send + Sync + 'static { + const FORMAT_ID: &'static str; + const ELEMENTS_PER_SUPERBLOCK: usize; // 256, or 32 for IQ4_NL + const SUPERBLOCK_BYTES: usize; + const GRID_POINT_LEN: usize; // elements one grid entry expands to (8 for IQ2/IQ1, 4 for IQ3, 1 for IQ4) + type GridElem: Copy + 'static; // u64 / u32 / i8 + const GRID: &'static [Self::GridElem]; // the ported table, by reference + fn read_d(superblock: &[u8]) -> f32; + fn grid_index(superblock: &[u8], point: usize) -> usize; + fn signs(superblock: &[u8], point: usize) -> u8; + fn subblock_scale(superblock: &[u8], idx: usize) -> f32; + fn dequant_point(superblock: &[u8], point: usize, out: &mut [f32]); +} +``` + +MXFP4/NVFP4 are `QuantCodebookFormat` too: their "grid" is `kvalues_fp4` (16 E2M1 +values) and the scale is a shared exponent; forcing them into the affine trait would +need a fake `dmin`. TQ1_0/TQ2_0 *are* affine (`{-1,0,1}·d`) and go on +`QuantBlockFormat` with `ZERO_OFFSET = 1`, `QUANT_BITS = 2`. + +**§2.4.4 Grid tables are generated, never typed.** `scripts/gen_iq_grids.sh` (owed) +reads `ggml-common.h` at the pinned commit and emits +`crates/aprender-serve/src/quantize/iq_grids.rs`: ten `pub const` arrays, 25.6 KiB of +`.rodata` in total (the contract's `grids` section lists each with its length). The +generated file carries the source commit in its header, and PO-QDC-004's test compares +each array's length, first and last element against the contract. A hand edit to a +grid is therefore a RED test, not a silent lattice corruption. + +**§2.4.5 Migration shape (Phase 2, one site per PR).** `tensor_byte_size` is the +template — it is #3091's exact crash site and the simplest: + +```rust +// before: eleven arms and a `_ => Err(UnsupportedOperation)` +// after: +fn tensor_byte_size(qtype: u32, num_elements: usize, dims: &[u64]) -> Result { + let t = quant_type_traits(qtype).ok_or_else(|| unsupported_qtype("tensor_byte_size", qtype))?; + Ok(match (t.family, dims) { + (QuantFamilyKind::AffineKQuant | QuantFamilyKind::Codebook | QuantFamilyKind::Ternary, [rows, cols]) => + (*rows as usize) * t.bytes_for(*cols as usize), // row-padded, LAYOUT-001 + _ => t.bytes_for(num_elements), + }) +} +``` + +The row-padding rule (K-quant rows pad to super-block boundaries) is the one piece of +logic that stays at the site because it is about tensor *shape*, not type. Every other +site follows the same pattern: look up, branch on `family` if the site genuinely +differs per family, never on the id. + +**§2.4.6 Naming.** `quant_type_traits` / `QuantTypeTraits` / `QUANT_TYPE_TRAITS` — +deliberately the ggml name so a reader coming from llama.cpp finds it by grep; +`QuantFamilyKind` rather than reusing `QuantFamily` (which is the trait's two-valued +`KQuant | Simple` and stays as is). Module: `crate::quantize::dispatch`, re-exported +from `crate::quantize`. + +--- + +## §3 Phases + +Phase boundaries exist so the completeness gate (Phase 1) can go green and be checked +in *before* the 30-file migration (Phase 2) starts touching the CPU/CUDA/wgpu backends +simultaneously — the ordering #3418 asks for, independent of which train it rides on. + +| Phase | Deliverable | Exit criterion | +|---|---|---| +| **0 — Inventory** — **DONE in this PR** | The 30 call sites (`dispatch_sites`) and the 35-type universe (`types`, `removed_ids`, `type_count`) live in `contracts/quant-dispatch-completeness-v1.yaml`, not in a script: the contract is the one list, and the `pv`-dogfooding rule forbids a bash re-implementation. The oracle that produced `dispatch_sites` is written into the contract beside it | `pv validate contracts/quant-dispatch-completeness-v1.yaml` exit 0 `[V]`; 35 + 8 = 43 = `type_count` `[V]`; 30 sites = #3418's count `[V]` | +| **1 — Trait + gate** | Per §2.4: `GgmlQuantType` → 35 variants; `QuantCodebookFormat` + generated `iq_grids.rs`; `QuantBlockFormat` impls for the remaining affine/ternary types; `dispatch.rs` with `QUANT_TYPE_TRAITS` and `quant_type_traits()`; the five PO-QDC tests reading the contract. Additive — no call site migrated | All five `quant_dispatch_*` tests green, each shown RED first by its FALSIFY-QDC mutation; `WeightQuantType::bytes_per_*` deleted in favour of the table (the first consumer, proves the table is load-bearing) | +| **2 — Migration** | Every `dispatch_sites` row flipped to `migrated: true`, one site (or tightly related group) per PR, `transformer.rs::tensor_byte_size` first (§2.4.5) | PO-QDC-005's oracle finds only the `keep` rows; the contract's `dispatch_sites` list and the tree agree | +| **3 — Parity re-proof** | Full parity re-run of the existing Qwen2.5-Coder GPU-beat measurement (README/BEATS.md's demonstrated model) against pre-migration baseline, per quant type touched, per backend | cosine ≥ 0.98 vs. pre-migration baseline (the bar #3091's own history already holds this codebase to); no regression on `contracts/beat-ollama-decode-throughput-speed-v1.yaml` | + +Phase 2 is the ~30-file, multi-backend blast radius #3418 warns about. Phases 0/1 are +additive and low-risk; they can land first inside 0.69 without touching a live call +site, so Qwen2.5-Coder's proven path is untouched until Phase 3 is ready to re-verify it. + +--- + +## §4 The universe, measured (2026-09-17, `425e84888` vs llama.cpp `3173a5647`) + +| Fact | Value | Measured by | +|---|---|---| +| Live ggml tensor types | **35** (ids 0–42 minus 8 removed; `GGML_TYPE_COUNT` = 43) | `awk '/enum ggml_type \{/,/\};/' ggml/include/ggml.h` in the llama.cpp checkout `[V]` | +| `GGUF_TYPE_*` tensor-type consts in aprender-serve | **13** (#3418's "16" counted `STRING`/`UINT32`/`ARRAY`/`INT32`, which are metadata value types, not tensor types) | `grep -cE 'pub const GGUF_TYPE_' crates/aprender-serve/src/gguf/types.rs` `[V]` | +| `GgmlQuantType` variants | 16 | `gguf/types.rs` `[V]` | +| `WeightQuantType` variants | 8 | `cuda/types.rs` `[V]` | +| `QuantBlockFormat` impls | 5 | `format_trait.rs` `[V]` | +| Status by contract row | `dequant` 8 · `sized` 3 · `named` 5 · **`missing` 19** | `contracts/quant-dispatch-completeness-v1.yaml` `types[].status` `[C]` | + +Of the 19 `missing`: 9 are codebook (IQ1_S, IQ1_M, IQ2_S, IQ3_XXS, IQ3_S, IQ4_NL, +IQ4_XS, MXFP4, NVFP4 — #3091's IQ2_XS/IQ4_XS crash class), 2 ternary (TQ1_0, TQ2_0), +2 new affine (Q1_0, Q2_0 — added to ggml after #3418 was written), 5 scalar (I8–I64, +F64), and Q8_K (activation-only; needs a size entry, no dequant). Two (Q8_1, Q8_K) +legitimately have **no** dequant in ggml either; the table encodes that as +`dequant_row: None` rather than as an "unsupported" error, so a caller can tell +"cannot" from "not yet". + +--- + +## §5 Owed artifacts (do not exist at `425e84888`, written without backticks per the +`PP-LLAMA-001` convention so the drift gate is never asked to check a file this document +is asking someone to create) + +Delivered by this document's PR: + +- `contracts/quant-dispatch-completeness-v1.yaml` — Phase 0 inventory + Phase 1 gate + definition, `pv validate` green +- `crates/aprender-serve/src/quantize/dispatch_contract_tests.rs` — the five `qdc_*` + tests that bind the contract to the tree today (PO-QDC-002/003/005 plus the + contract's own gaplessness and name agreement); each shown RED by its FALSIFY-QDC + mutation before landing (receipt in the PR). Registered as a tree reader in + `scripts/tree_reader_tests.txt` so the quick CI tier always runs it +- `serde_yaml_ng` as an aprender-serve dev-dependency (it was build-only) + +Still owed (Phase 1 unless marked): + +- crates/aprender-serve/src/quantize/dispatch.rs — `QuantTypeTraits`, `QuantFamilyKind`, + `QUANT_TYPE_TRAITS`, `quant_type_traits()`, the per-type `dequant_row_*` wrappers (§2.4.2) +- crates/aprender-serve/src/quantize/codebook_trait.rs — `QuantCodebookFormat` + impls (§2.4.3) +- crates/aprender-serve/src/quantize/iq_grids.rs — GENERATED, ten `const` grids (§2.4.4) +- scripts/gen_iq_grids.sh — the generator; pins the llama.cpp commit; `--check` diffs +- scripts/gen_quant_dispatch_rows.sh — regenerates the contract's `types` rows from + ggml.h + ggml-common.h; `--check` is FALSIFY-QDC-006 +- `qdc_table_covers_every_row` (PO-QDC-001) and `qdc_grids_match_reference` + (PO-QDC-004), added to the existing `dispatch_contract_tests.rs` once the table and + grids exist; the contract's FALSIFY-QDC-001/004 then gain their `test:` citation +- a `ci/explicit-test-commands.d/NNN-quant-dispatch.cmd` fragment if the tests land as + an integration target rather than `--lib` (a `tests/*.rs` file is dark until named) +- docs/audits/impl-PP-QUANT-001-phaseN-receipt.md — one per phase, RED→GREEN proof (each phase) + +--- + +## §6 Risks accepted by the 0.69 placement decision + +**§6.1** #3418 explicitly requested this ride its own dedicated release cycle, arguing +the ~30-file, 3-backend (CPU/CUDA/wgpu) blast radius of Phase 2 competes for the same +verification attention as the model-specific tickets riding the normal cadence, and +risks regressing Qwen2.5-Coder — the one model line with a real GPU-parity receipt +(`docs/BEATS.md`). The operator decided (2026-09-17) to place Phase 0/1 on **0.69** +instead. This document's phase split (§3) is the mitigation: **only Phase 0/1 are +committed to the 0.69 window** (done — epic #3421). Phase 2 (the actual multi-file +migration) and Phase 3 (parity re-proof) are **not** bound to 0.69's +`2026-09-18T06:00Z` train-leaves-by instant — as of 2026-09-17 they have a fixed home +of their own: milestone **`Inference dispatch & architecture consolidation`**, +tracked by epic #3423 alongside PP-ARCH-001's phases (§0's Downstream note). This +replaces the original "may slip forward under the train rule" language: Phase 2/3 +were never actually going to be scheduled as a 06x-train slip — they're on the +dedicated, clockless cycle #3418 asked for from the start, just tracked jointly with +PP-ARCH-001 rather than as a `PP-QUANT-001`-only milestone. + +**§6.2** `docs/specifications/06x-release-schedule.md` is not amended by this document. +0.69's stated theme ("declarative fine-tune lands; batching bands turn VALID") and its +114 open milestone issues are unaffected; this work is additive to that train, not a +replacement of its scope. If Phase 2/3 need dedicated verification attention that the +schedule doc's priorities A–G don't already carry, that is a separate amendment to +`06x-release-schedule.md`, proposed once Phase 1 lands and Phase 2's real size is known +from the Phase 0 inventory — not asserted here in advance. + +--- + +## §7 Next steps (tracking, not yet done by this document) + +1. ~~Open a GitHub epic under milestone `0.69.0`, linking #3418~~ — done: epic #3421 + (rescoped 2026-09-17 to Phase 0/1 only). +2. Assign an owner (currently unassigned). +3. ~~Phase 0 inventory as the first PR~~ — done: `contracts/quant-dispatch-completeness-v1.yaml` + plus the `qdc_*` tests binding it to the tree. +4. Phase 1: the table, `QuantCodebookFormat`, generated grids, and the two remaining + tests (§2.4, §5). Still tracked by #3421. +5. Phase 2/3: tracked by epic #3423 on milestone `Inference dispatch & architecture + consolidation`, jointly with PP-ARCH-001-MASTER.md — the downstream consolidation + this document is a prerequisite for. diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index 2b21cd83b5..7135e54252 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -121,6 +121,7 @@ aprender-rag --lib loader::text aprender-rag-cli --lib aprender-serve --lib cli::cli_tests aprender-serve --lib quantize::contract_tests +aprender-serve --lib quantize::dispatch_contract_tests aprender-shell --lib model aprender-simulate --lib demos::tsp_grasp aprender-simulate --lib demos::tsp_instance