diff --git a/.claude/plans/foundry-parity-v1.md b/.claude/plans/foundry-parity-v1.md index 0ebdcffa0..f9ba08321 100644 --- a/.claude/plans/foundry-parity-v1.md +++ b/.claude/plans/foundry-parity-v1.md @@ -30,7 +30,8 @@ instance; Foundry columns cite its public documentation only, as evidence of - [x] Named cases 01–09, refusals, negative controls (§5). - [x] Disable runs: five guards broken one at a time, suite red each time, tree restored. - [x] Bench in ns/µs (§6). -- [ ] P1: element-wise cross-lane arithmetic in an admitted operator (per-store max, limiting resource, per-store cost) — see §7 G1. +- [x] P1a (2026-11-10): per-store cost through the INT8 packed GEMM (ndarray#418), arm C — oracle-exact, staged A counted (§5a, §6). +- [ ] P1b: element-wise cross-lane arithmetic in an admitted operator (per-store max without the sweep, limiting resource) — see §7 G1. - [ ] P3: scenario overlay frame (§7 G3); governed action / writeback gate (§7 G4). - [ ] Production IAM in the read path instead of the lab stand-in (§7 G2). @@ -93,7 +94,7 @@ compared against Foundry. | 11 | History / replay | `FrameStore` revisions; Lance versions | lab stale-frame refusal + rebind | LAB (invalidation) / UNKNOWN (replay at a past version through DT64) | Bind at a historical frame | Answer at frame N after the source moved to N+1 | PARTIAL | | 12 | Derived state + invalidation | `LaneBinding::check` on every open | lab `a_stale_frame_is_refused_and_a_rebind_sees_the_restock` | LAB | Incremental recompute of only the changed subset (lab recomputes the whole question; it is 64 rows) | — | FUNCTIONALLY-RELATED | | 13 | What-if scenarios (beta in Foundry docs) | `contract::scenario::ScenarioBranch` (facade) | `lance-graph-contract/src/scenario.rs` | PARTIAL; DR path BLOCKED | Overlay frame witness for DT64 bindings (§7 G3) | lab `the_scenario_is_oracle_only_and_lowers_capacity` (oracle only) | BLOCKED | -| 14 | Aggregations / analytics | mask-RISC `Count`, `MaskedSumI32`, group terminals | lab `network_capacity`, `network_cost` | LAB | Per-row cross-lane arithmetic (§7 G1) | Per-store cost through an admitted terminal | PARTIAL | +| 14 | Aggregations / analytics | mask-RISC `Count`, `MaskedSumI32`, group terminals; ndarray INT8 packed GEMM | lab `network_capacity`, `network_cost`; arm C `per_store` (per-store cost) | LAB | Per-row min / argmin across lanes (§7 G1) | Limiting resource through an admitted operator | PARTIAL | | 15 | Operational applications | a2ui-rs / consumer CustomObject | not exercised here | UNKNOWN | — | — | NOT-COMPARABLE here | | 16 | Audit / decision provenance | Witnesses (route, frame, basis) per answer | `LaneBinding` fields | PARTIAL | Decision record: who asked, which witnesses, which answer | — | MISSING | | 17 | Data connections / egress | Source adapters (OGAR, Lance) | outside this lab | UNKNOWN | — | — | NOT-COMPARABLE here | @@ -126,6 +127,31 @@ lanes); quantity outside `1..=128`. Disable runs (each red, then restored): staff predicate threshold → 0; denied mask → empty; ingredient threshold off by one q; unknown mask → empty; fulfil deadline → `i32::MAX`. +### 5a. Arm C — per-store cost as one INT8 GEMM (P1a) + +`C[store][item] = Σ_k cost[store][k] · demand[k][item]` through +`ndarray::simd::int8_gemm_packed_strided` (ndarray#418, AMX `TDPBUSD` on the +lab host). Only the cost lanes of categories some item demands are bound (plus +`storeId`). **A is staged**: the source owns one `i32` lane per category (A's +transpose) and the kernel reads `u8` rows, so the bound lanes are copied once +per binding into a 64 × 64 `u8` buffer (4 096 B, counted). A cost that does not +fit `u8` is refused, never truncated. B (the menu's demand matrix) is packed +once and reused only under the same menu revision — the identity-and-revision +rule #1992's `ReuseKey` applies to its operands, never a digest. + +Tests (7): oracle parity for 64 stores × 8 items × q ∈ {1, 3, 128} and against +the materialized null model; the sum over arm B's `CanCook` mask equals arm B's +`network_cost` (two independent arms); demanded lanes only, one borrow each, A +staged once; `u8` overflow refused and 255 admitted exactly; a stale binding +refused after a price change; another menu revision and out-of-range q +refused; reversed row order answers by identity. Disable runs (each red, then +restored): truncate instead of refuse; ignore the denied mask; demand in the +wrong column; admit any menu; stage only the first cost lane. + +Not exercised: the scalar tier of the kernel (the host has AMX); ndarray#418 +tests both tiers against each other. The limiting resource (argmin) remains +BLOCKED — it is not a matrix product. + Not exercised: the `NotMonotone` and `Ceiling` guards of the layer-cake sweep are defensive; no admitted fixture can reach them (feasibility predicates are all `≥ threshold(q)` with thresholds non-decreasing in q). They are labelled, @@ -150,6 +176,11 @@ three runs, ranges across runs: | arm A: build 64 objects (51 968 B second copy) | 20 818–23 216 | | arm A: `CanCook(q=1)` over 64 objects | 1 792–1 844 | | arm A: `NetworkCapacity` | 1 811–1 922 | +| arm C cold: bind 23 cost lanes + `storeId` | 5 567–5 953 | +| arm C cold: open (`borrow_all` + stage A, 4 096 B) | 1 139–1 562 | +| arm C cold: prepare demand band (once per menu revision) | 452–629 | +| arm C warm: per-store cost, 64 stores × 8 items, AMX `TDPBUSD` | 654–1 170 | +| arm A: per-store cost, 64 × 8 (recipes read once) | 1 188–1 661 | Reading, not spin: a single WHERE-shaped question is ~4× cheaper in arm B and copies nothing; but the per-store maximum and the network capacity are ~8× @@ -159,11 +190,18 @@ A's build cost (21 µs) amortises after roughly two capacity questions. At 64 rows every mask is one word, so mask-RISC's fixed per-program cost (program construction + validation, not separated here) dominates; scaling is OPEN. +Arm C (separate run of the same bench, three runs): per-store cost for all 8 +items is ~1.5–1.8× cheaper than arm A's walk once arm A reads its recipes +once. A first comparator called `fx::recipe` per (store, item) and measured +7.7–15 µs; that was 512 allocations, not the objects, and is not the +comparison. At 64 × 64 × 16 the GEMM is tiny; the per-call fixed cost +(tile configuration, output mapping) likely dominates, and scaling is OPEN. + ## 7. Missing-contract register | Id | Missing operation | Why the current contract cannot express it | Lawful alternative today | Smallest test | Needs | |---|---|---|---|---|---| -| G1 | Element-wise cross-lane arithmetic: `min_k ⌊stock_k / d_k⌋` per row, argmin (limiting resource), per-row weighted sum (per-store cost) | mask-RISC terminals reduce over rows; predicates compare a lane to a constant | Layer-cake over monotone masks (max only, ~30× programs); no alternative for argmin or per-row cost | Per-store max and cost in one program, equal to oracle | Decision + mask-RISC/Quack PR (H2) | +| G1 | Element-wise cross-lane arithmetic: `min_k ⌊stock_k / d_k⌋` per row, argmin (limiting resource). (Per-row weighted sum — per-store cost — is now LAB via arm C, §5a.) | mask-RISC terminals reduce over rows; predicates compare a lane to a constant; min/argmin is not a matrix product | Layer-cake over monotone masks (max only, ~30× programs); none for argmin | Per-store max and limiting resource in one program, equal to oracle | Decision + mask-RISC/Quack PR (H2) | | G2 | Row-level authorization from IAM in the read path | DR `Scope` is per lane and consistency-only; RBAC grants are class/op, not wired to DT64 | Lab stand-in predicate on the identity lane | Same lab tests with the predicate supplied by `lance-graph-rbac` | Decision on where IAM evaluates (H3) | | G3 | Scenario overlay over a binding | No frame witness for a proposed, non-canonical plane | Oracle only | Store 12 closed: overlay answer = oracle, canonical frame unchanged | Overlay frame contract (H3) | | G4 | Governed action / writeback | No command identity, preconditions, idempotency, atomic commit or audit on this path; `MethodBinding::effects` is descriptive | none | Preview vs commit of "reassign order 12→17" with a stale-precondition refusal | Separate gate; not in a read-only lab | @@ -176,8 +214,9 @@ construction + validation, not separated here) dominates; scaling is OPEN. WHERE-shaped feasibility, fulfilment, capacity and network cost are exact through existing contracts with no copy. Falsified for anything needing a per-row value combining several lanes. -- **H2 (missing algebra):** *supported by G1, G5.* The limiting resource and - per-store cost cannot be expressed; the maximum only by a costly sweep. +- **H2 (missing algebra):** *supported by G1, G5, narrowed by arm C.* The + per-store cost IS expressible, as an INT8 GEMM over staged A (§5a). The + limiting resource still cannot be expressed; the maximum only by a sweep. - **H3 (infrastructure):** *supported by G2, G3, G4, G6* but untested in depth here: authorization, scenarios, actions and method execution all lack a contract on this path. @@ -187,8 +226,10 @@ None is chosen over the others; all three hold in part. ## 9. Roadmap - **P0 (this PR):** the lab and this matrix. -- **P1:** G1 — decide whether element-wise cross-lane arithmetic enters - mask-RISC/Quack, then re-run the per-store questions against the oracle. +- **P1a (done, 2026-11-10):** per-store cost via the INT8 packed GEMM (arm C). +- **P1b:** G1 remainder — decide whether element-wise cross-lane min/argmin + enters mask-RISC/Quack, then re-run per-store max and limiting resource + against the oracle. - **P2:** G6 + G5 — `CanCook` as a registered method lowered from its `MethodRef`; recipe demand as bound relation lanes. - **P3:** G3 scenario overlay frame; G4 action gate (preview vs commit) as a diff --git a/crates/foundry-parity-lab/Cargo.toml b/crates/foundry-parity-lab/Cargo.toml index 15014f8dc..29b7a72c5 100644 --- a/crates/foundry-parity-lab/Cargo.toml +++ b/crates/foundry-parity-lab/Cargo.toml @@ -22,6 +22,9 @@ description = "D-FOUNDRY-PARITY-1 lab: can source-owned planes, a DT64 lane bind lance-graph-dt64 = { path = "../lance-graph-dt64" } lance-graph-mask-risc = { path = "../lance-graph-mask-risc" } lance-graph-contract = { path = "../lance-graph-contract" } +# Arm C: the INT8 packed GEMM (ndarray#418), from the same coordinate +# mask-risc uses, so the graph keeps one ndarray package. +ndarray = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master", default-features = false, features = ["std"] } [[test]] name = "integration" diff --git a/crates/foundry-parity-lab/src/arm_a.rs b/crates/foundry-parity-lab/src/arm_a.rs index be4cc925e..1ae28bca6 100644 --- a/crates/foundry-parity-lab/src/arm_a.rs +++ b/crates/foundry-parity-lab/src/arm_a.rs @@ -100,6 +100,41 @@ impl Objects { } } + /// Ingredient cost of `q` of `m` at one object; `None` if denied. + pub fn store_cost(&self, s: usize, m: usize, q: i32) -> Option { + let o = &self.stores[s]; + if o.id == DENIED_STORE { + return None; + } + Some( + fx::recipe(m) + .demand + .iter() + .map(|&(k, d)| i64::from(d * q) * i64::from(o.cost[k])) + .sum(), + ) + } + + /// Every store's cost for every item at `q`, recipes read once. + pub fn store_costs(&self, q: i32) -> [[Option; ITEMS]; STORES] { + let recipes: Vec<_> = (0..ITEMS).map(fx::recipe).collect(); + let mut out = [[None; ITEMS]; STORES]; + for (s, o) in self.stores.iter().enumerate() { + if o.id == DENIED_STORE { + continue; + } + for (m, r) in recipes.iter().enumerate() { + out[s][m] = Some( + r.demand + .iter() + .map(|&(k, d)| i64::from(d * q) * i64::from(o.cost[k])) + .sum(), + ); + } + } + out + } + /// `NetworkCapacity` on the objects. pub fn network_capacity(&self, m: usize) -> i64 { (0..STORES) diff --git a/crates/foundry-parity-lab/src/arm_c.rs b/crates/foundry-parity-lab/src/arm_c.rs new file mode 100644 index 000000000..808799a81 --- /dev/null +++ b/crates/foundry-parity-lab/src/arm_c.rs @@ -0,0 +1,254 @@ +//! ARM C: per-store ingredient cost as one INT8 GEMM (gap G1, partial). +//! +//! `C[store][item] = Σ_k cost[store][k] · demand[k][item]`. A is the cost +//! matrix (stores × categories, `u8`), B the menu's demand matrix (categories +//! × items, `i8`), and `u8 × i8 → i32` is exact here: costs are 50..=249 and +//! demands 1..=3. The product runs through `ndarray::simd::int8_gemm_packed_strided` +//! (AMX `TDPBUSD` where the host has it, the scalar tier otherwise; both exact). +//! +//! What this arm costs, stated rather than hidden: +//! +//! * **A is staged.** The source owns one `i32` lane per category over stores, +//! which is A's transpose, and the kernel reads `u8` rows. So the bound cost +//! lanes are copied into a 64 × 64 `u8` buffer once per binding (4 096 bytes, +//! reported by [`CostQuery::staged_bytes`]). A cost that does not fit `u8` is +//! refused, never truncated. Categories no item demands are not bound; their +//! columns stay zero. +//! * **B is prepared once** per menu revision and reused under the menu's +//! identity and revision only, never a digest (the rule +//! `dr-isa-stream-lab`'s `ReuseKey` applies to its operands, #1992). +//! +//! What it still cannot answer: the limiting resource (an argmin across lanes) +//! is not a matrix product and stays BLOCKED (G1). + +use std::cell::RefCell; + +use lance_graph_contract::class_view::ClassView; +use lance_graph_dt64::dr::FrameStore; +use lance_graph_dt64::{BindStore, LaneBinding, LaneRefusal}; +use lance_graph_mask_risc::{ + execute, tile_words_for, ExecError, LaneRef, MaskOp, Operand, Planes, Pred, Program, Scratch, + Terminal, Value, +}; +use ndarray::simd::{int8_gemm_packed_strided, int8_tile_gemm_tier, PackedI8B}; + +use crate::bind::{COST0, STORE_ID}; +use crate::fixture::{ + self as fx, CATEGORIES, DENIED_STORE, ITEMS, MENU_REV, STORES, STORE_ID_BASE, +}; +use crate::source::{Network, Plane}; + +/// Output columns of the one B band: 8 items, padded to the kernel's 16. +const BAND: usize = 16; + +/// The categories any menu item demands, ascending. Only these are bound. +pub fn demanded_categories() -> Vec { + let mut ks: Vec = (0..ITEMS) + .flat_map(|m| fx::recipe(m).demand.into_iter().map(|(k, _)| k)) + .collect(); + ks.sort_unstable(); + ks.dedup(); + ks +} + +/// The fields arm C binds: the demanded categories' cost lanes and `storeId`. +pub fn cost_fields() -> Vec { + let mut f: Vec = demanded_categories() + .into_iter() + .map(|k| (COST0 + k) as u8) + .collect(); + f.push(STORE_ID as u8); + f +} + +/// Why arm C could not answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CostRefusal { + Lane(LaneRefusal), + Exec(ExecError), + /// A cost does not fit the kernel's unsigned 8-bit A operand. + NotU8 { + field: u8, + row: usize, + value: i32, + }, + /// A bound plane had another kind than its field requires. + PlaneKind { + field: u8, + }, + /// `storeId` was not bound. + NoIdentity, + /// A demanded cost lane was not in the binding; its column would read as zero. + NotBound { + field: u8, + }, + /// The prepared demand band was made for another menu revision. + MenuRevision { + prepared: u32, + asked: u32, + }, + /// `q` outside `1..=128`. + Quantity { + q: i32, + }, +} + +impl From for CostRefusal { + fn from(r: LaneRefusal) -> Self { + CostRefusal::Lane(r) + } +} + +impl From for CostRefusal { + fn from(e: ExecError) -> Self { + CostRefusal::Exec(e) + } +} + +/// The menu's demand matrix, VNNI-packed once. +pub struct PreparedDemand { + menu: u32, + band: [PackedI8B; 1], +} + +impl PreparedDemand { + /// Pack the demand of menu revision `menu` (K = 64 categories × 16 cols). + pub fn prepare(menu: u32) -> PreparedDemand { + let mut b = vec![0i8; CATEGORIES * BAND]; + for m in 0..ITEMS { + for (k, d) in fx::recipe(m).demand { + b[k * BAND + m] = i8::try_from(d).expect("demand fits i8"); + } + } + PreparedDemand { + menu, + band: [PackedI8B::pack(&b, CATEGORIES)], + } + } + + /// Reuse rule: the menu revision must match. Nothing else is consulted. + pub fn admit(&self, menu: u32) -> Result<(), CostRefusal> { + if self.menu == menu { + Ok(()) + } else { + Err(CostRefusal::MenuRevision { + prepared: self.menu, + asked: menu, + }) + } + } +} + +/// Per-store cost over one binding: staged A, the identity lane, the denied mask. +pub struct CostQuery<'s> { + a: Box<[u8]>, + ids: &'s [u32], + denied: u64, + out: RefCell>, +} + +impl<'s> CostQuery<'s> { + /// Check the binding once, borrow every bound lane, stage A, and compute + /// the IAM stand-in's denied mask with one mask-RISC program. + pub fn open( + binding: &LaneBinding, + store: &BindStore, + view: &V, + frames: &FrameStore, + source: &'s Network, + ) -> Result, CostRefusal> { + let mut a = vec![0u8; STORES * CATEGORIES].into_boxed_slice(); + let mut ids = None; + let mut bound = [false; CATEGORIES]; + for lane in binding.borrow_all(store, view, frames, source)? { + let (field, plane) = lane?; + match (usize::from(field), plane) { + (STORE_ID, Plane::U32(v)) => ids = Some(v), + (f, Plane::I32(v)) if (COST0..COST0 + CATEGORIES).contains(&f) => { + let k = f - COST0; + bound[k] = true; + for (row, &value) in v.iter().enumerate() { + a[row * CATEGORIES + k] = u8::try_from(value) + .map_err(|_| CostRefusal::NotU8 { field, row, value })?; + } + } + _ => return Err(CostRefusal::PlaneKind { field }), + } + } + let ids = ids.ok_or(CostRefusal::NoIdentity)?; + // `borrow_all` checks only the fields the binding carries; an omitted + // demanded lane would leave its column of A zero and understate cost. + if let Some(k) = demanded_categories().into_iter().find(|&k| !bound[k]) { + return Err(CostRefusal::NotBound { + field: (COST0 + k) as u8, + }); + } + let lanes = [LaneRef::U32(ids)]; + let planes = Planes { + n_rows: STORES, + masks: &[], + lanes: &lanes, + }; + let program = Program::new( + vec![MaskOp::Pred { + pred: Pred::NeU32 { + lane: 0, + v: DENIED_STORE, + }, + under: None, + dst: 0, + }], + Terminal::Keep { + mask: Operand::Scratch(0), + }, + ); + let mut scratch = Scratch::new(tile_words_for(STORES), 1); + let visible = match execute(&program, &planes, &mut scratch, None)? { + Value::Mask(_) => scratch.slot(0).map_or(0, |s| s[0]), + _ => unreachable!("Keep yields a mask"), + }; + Ok(CostQuery { + a, + ids, + denied: !visible, + out: RefCell::new(vec![0i32; STORES * BAND].into_boxed_slice()), + }) + } + + /// Bytes copied from the source to stage A. + pub fn staged_bytes(&self) -> usize { + self.a.len() + } + + /// Which INT8 tier runs (`"AMX TDPBUSD"` or the scalar reference). + pub fn tier() -> &'static str { + int8_tile_gemm_tier() + } + + /// Ingredient cost of making `q` of every item at every store, indexed + /// `[store row][item]` (store row = id − base). `None` for a store the + /// reader is denied. One GEMM per call. + pub fn per_store( + &self, + demand: &PreparedDemand, + q: i32, + ) -> Result<[[Option; ITEMS]; STORES], CostRefusal> { + if !(1..=crate::arm_b::Q_MAX).contains(&q) { + return Err(CostRefusal::Quantity { q }); + } + demand.admit(MENU_REV)?; + let mut c = self.out.borrow_mut(); + int8_gemm_packed_strided(&self.a, CATEGORIES, &demand.band, &mut c, STORES); + let mut out = [[None; ITEMS]; STORES]; + for r in 0..STORES { + if self.denied >> r & 1 == 1 { + continue; + } + let s = (self.ids[r] - STORE_ID_BASE) as usize; + for m in 0..ITEMS { + out[s][m] = Some(i64::from(q) * i64::from(c[r * BAND + m])); + } + } + Ok(out) + } +} diff --git a/crates/foundry-parity-lab/src/bin/bench.rs b/crates/foundry-parity-lab/src/bin/bench.rs index 71ddbecf7..bc9b06891 100644 --- a/crates/foundry-parity-lab/src/bin/bench.rs +++ b/crates/foundry-parity-lab/src/bin/bench.rs @@ -7,8 +7,9 @@ use std::time::Instant; use foundry_parity_lab::arm_a::Objects; use foundry_parity_lab::arm_b::{demanded_fields, Guards, ItemQuery}; +use foundry_parity_lab::arm_c::{cost_fields, CostQuery, PreparedDemand}; use foundry_parity_lab::bind::{World, READER, SPACE_A}; -use foundry_parity_lab::fixture::STORES; +use foundry_parity_lab::fixture::{MENU_REV, STORES}; use foundry_parity_lab::source::{Network, Order}; use lance_graph_dt64::LaneSource; @@ -105,6 +106,37 @@ fn main() { }), ); println!(); + println!("arm C tier: {}", CostQuery::tier()); + let cf = cost_fields(); + report( + "arm C cold: bind cost lanes", + median(|| { + black_box(world.bind(&net, &cf, SPACE_A, READER).unwrap()); + }), + ); + let cb = world.bind(&net, &cf, SPACE_A, READER).unwrap(); + report( + "arm C cold: open (borrow_all + stage A)", + median(|| { + black_box(CostQuery::open(&cb, &world.store, &world.view, net.frames(), &net).unwrap()); + }), + ); + report( + "arm C cold: prepare demand band", + median(|| { + black_box(PreparedDemand::prepare(MENU_REV)); + }), + ); + let cq = CostQuery::open(&cb, &world.store, &world.view, net.frames(), &net).unwrap(); + let demand = PreparedDemand::prepare(MENU_REV); + println!("arm C staged bytes {}", cq.staged_bytes()); + report( + "arm C warm: per-store cost, 64 x 8 items", + median(|| { + black_box(cq.per_store(&demand, black_box(3)).unwrap()); + }), + ); + println!(); report( "arm A: build 64 objects", median(|| { @@ -126,4 +158,10 @@ fn main() { black_box(objects.network_capacity(0)); }), ); + report( + "arm A: per-store cost, 64 x 8 items", + median(|| { + black_box(objects.store_costs(black_box(3))); + }), + ); } diff --git a/crates/foundry-parity-lab/src/fixture.rs b/crates/foundry-parity-lab/src/fixture.rs index 1dda0131b..a0a01ca3e 100644 --- a/crates/foundry-parity-lab/src/fixture.rs +++ b/crates/foundry-parity-lab/src/fixture.rs @@ -18,6 +18,9 @@ pub const EQUIPMENT: usize = 4; pub const SKILLS: usize = 4; /// Menu items. pub const ITEMS: usize = 8; +/// Revision of the menu (recipes). A prepared demand operand is valid for +/// exactly one revision. +pub const MENU_REV: u32 = 1; /// Global identity of row 0. pub const STORE_ID_BASE: u32 = 1000; diff --git a/crates/foundry-parity-lab/src/lib.rs b/crates/foundry-parity-lab/src/lib.rs index ccd8ee005..d71b6a6f1 100644 --- a/crates/foundry-parity-lab/src/lib.rs +++ b/crates/foundry-parity-lab/src/lib.rs @@ -11,6 +11,7 @@ //! * [`source`] owns the planes and implements DT64's `LaneSource`. //! * [`bind`] is the class: its 211-field basis, route and demanded binding. //! * [`arm_b`] answers through `borrow_all` + mask-RISC programs. +//! * [`arm_c`] answers per-store cost as one INT8 GEMM over staged A. //! * [`arm_a`] is the materialized-object null model. //! * [`oracle`] answers from the fixture alone, and also evaluates the one //! scenario (Level S) the DR path cannot: no overlay contract exists. @@ -20,6 +21,7 @@ pub mod arm_a; pub mod arm_b; +pub mod arm_c; pub mod bind; pub mod fixture; pub mod oracle; diff --git a/crates/foundry-parity-lab/src/oracle.rs b/crates/foundry-parity-lab/src/oracle.rs index 9935502ea..9e68fa6a0 100644 --- a/crates/foundry-parity-lab/src/oracle.rs +++ b/crates/foundry-parity-lab/src/oracle.rs @@ -99,6 +99,21 @@ impl Oracle { .sum() } + /// Ingredient cost of making `q` of `m` at store row `s`, whether or not + /// it can make them; `None` when the reader may not see the store. + pub fn store_cost(&self, s: usize, m: usize, q: i32) -> Option { + if fx::store_id(s) == DENIED_STORE { + return None; + } + Some( + fx::recipe(m) + .demand + .iter() + .map(|&(k, d)| i64::from(d * q) * i64::from(fx::unit_cost(s, k))) + .sum(), + ) + } + /// Ingredient cost of making `q` of `m` at every store that can. pub fn network_cost(&self, m: usize, q: i32) -> i64 { let r = fx::recipe(m); diff --git a/crates/foundry-parity-lab/src/source.rs b/crates/foundry-parity-lab/src/source.rs index 83106431b..58e691a4f 100644 --- a/crates/foundry-parity-lab/src/source.rs +++ b/crates/foundry-parity-lab/src/source.rs @@ -148,6 +148,23 @@ impl Network { }); } + /// A source-side price change at store row `s`, category `k`. The plane + /// changes in place and the frame advances. + pub fn set_unit_cost(&mut self, s: usize, k: usize, value: i32) { + let r = (0..STORES) + .find(|&r| self.order.row(r) == s) + .expect("the store is in the population"); + if let Owned::I32(lane) = &mut self.planes[crate::bind::COST0 + k] { + lane[r] = value; + } + let next = self.frames.revisions().source + 1; + self.frames.advance(FrameRevisions { + source: next, + relation_map: 1, + coordinates: 1, + }); + } + pub fn order(&self) -> Order { self.order } diff --git a/crates/foundry-parity-lab/tests/integration/cost.rs b/crates/foundry-parity-lab/tests/integration/cost.rs new file mode 100644 index 000000000..26c691936 --- /dev/null +++ b/crates/foundry-parity-lab/tests/integration/cost.rs @@ -0,0 +1,221 @@ +//! Arm C: per-store ingredient cost as one INT8 GEMM, against the oracle. + +use foundry_parity_lab::arm_b::Guards; +use foundry_parity_lab::arm_c::{ + cost_fields, demanded_categories, CostQuery, CostRefusal, PreparedDemand, +}; +use foundry_parity_lab::bind::{World, READER}; +use foundry_parity_lab::fixture::{case, ITEMS, MENU_REV, STORES}; +use foundry_parity_lab::oracle::Oracle; +use foundry_parity_lab::source::{Network, Order}; +use lance_graph_dt64::LaneSource; + +use crate::common::open; + +fn cost_query<'s>(world: &World, net: &'s Network) -> Result, CostRefusal> { + let binding = world.bind(net, &cost_fields(), net.order().space(), READER)?; + CostQuery::open(&binding, &world.store, &world.view, net.frames(), net) +} + +#[test] +fn per_store_cost_matches_the_oracle_for_every_store_item_and_quantity() { + let world = World::new(); + let net = Network::ingest(Order::Identity); + let demand = PreparedDemand::prepare(MENU_REV); + let query = cost_query(&world, &net).expect("binds and stages"); + let o = Oracle::BASELINE; + for q in [1, 3, 128] { + let c = query.per_store(&demand, q).expect("answers"); + for s in 0..STORES { + for m in 0..ITEMS { + assert_eq!(c[s][m], o.store_cost(s, m, q), "store {s} item {m} q {q}"); + } + } + } + // Anti-vacuity: costs genuinely vary by store, so agreement is informative. + let c = query.per_store(&demand, 1).unwrap(); + let mut distinct: Vec = (0..STORES).filter_map(|s| c[s][0]).collect(); + distinct.sort_unstable(); + distinct.dedup(); + assert!(distinct.len() > 20, "{} distinct costs", distinct.len()); + assert_eq!( + c[case::HIDDEN], + [None; ITEMS], + "the denied store has no cost" + ); + assert_eq!( + (0..STORES).filter(|&s| c[s][0].is_some()).count(), + STORES - 1 + ); + // The materialized null model agrees too. + let objects = foundry_parity_lab::arm_a::Objects::build(false); + assert_eq!(objects.store_costs(1), c); +} + +#[test] +fn per_store_cost_sums_to_arm_bs_network_cost_over_feasible_stores() { + // Two independent arms: arm B's MaskedSum over CanCook, arm C's GEMM + // restricted to the same feasibility mask. + let world = World::new(); + let net = Network::ingest(Order::Identity); + let demand = PreparedDemand::prepare(MENU_REV); + let costs = cost_query(&world, &net).unwrap(); + for m in 0..ITEMS { + let cook = open(&world, &net, m, true, false).unwrap(); + for q in [1, 3] { + let mask = cook.cook_mask(q, Guards::ALL).unwrap(); + let c = costs.per_store(&demand, q).unwrap(); + let via_gemm: i64 = (0..STORES) + .filter(|&r| mask >> r & 1 == 1) + .map(|r| c[(cook.store_at(r) - 1000) as usize][m].unwrap()) + .sum(); + assert_eq!(via_gemm, cook.network_cost(q).unwrap(), "item {m} q {q}"); + } + } +} + +#[test] +fn only_demanded_cost_lanes_are_bound_and_a_is_staged_once() { + let world = World::new(); + let net = Network::ingest(Order::Identity); + let fields = cost_fields(); + let query = cost_query(&world, &net).unwrap(); + assert_eq!(fields.len(), demanded_categories().len() + 1); + // 23 of the 64 categories are demanded by some item, plus `storeId`. + assert_eq!(fields.len(), 24); + assert_eq!( + net.borrows.get(), + fields.len() as u64, + "one borrow per bound lane" + ); + assert_eq!(query.staged_bytes(), 64 * 64); + let demand = PreparedDemand::prepare(MENU_REV); + for q in 1..=5 { + query.per_store(&demand, q).unwrap(); + } + assert_eq!( + net.borrows.get(), + fields.len() as u64, + "answers reuse the staged A" + ); +} + +#[test] +fn a_cost_that_does_not_fit_u8_is_refused_not_truncated() { + let world = World::new(); + let k = demanded_categories()[0]; + let mut net = Network::ingest(Order::Identity); + net.set_unit_cost(10, k, 300); + match cost_query(&world, &net) { + Err(CostRefusal::NotU8 { row, value, .. }) => assert_eq!((row, value), (10, 300)), + other => panic!("expected NotU8, got {:?}", other.err()), + } + // Silent half: the largest u8 is admitted and answered exactly. + let mut net = Network::ingest(Order::Identity); + net.set_unit_cost(10, k, 255); + let c = cost_query(&world, &net) + .expect("255 fits u8") + .per_store(&PreparedDemand::prepare(MENU_REV), 1) + .unwrap(); + let expected: i64 = (0..ITEMS) + .map(|m| { + foundry_parity_lab::fixture::recipe(m) + .demand + .iter() + .map(|&(kk, d)| { + let price = if kk == k { + 255 + } else { + foundry_parity_lab::fixture::unit_cost(10, kk) + }; + i64::from(d) * i64::from(price) + }) + .sum::() + }) + .sum(); + assert_eq!((0..ITEMS).map(|m| c[10][m].unwrap()).sum::(), expected); +} + +#[test] +fn a_binding_missing_a_demanded_cost_lane_is_refused() { + let world = World::new(); + let net = Network::ingest(Order::Identity); + let all = cost_fields(); + let dropped = all[0]; + let partial: Vec = all.iter().copied().filter(|&f| f != dropped).collect(); + let binding = world + .bind(&net, &partial, net.order().space(), READER) + .expect("a partial binding is itself valid"); + match CostQuery::open(&binding, &world.store, &world.view, net.frames(), &net) { + Err(CostRefusal::NotBound { field }) => assert_eq!(field, dropped), + other => panic!("expected NotBound, got {:?}", other.err()), + } + // Identity-only binding: refused, never answered as all-zero costs. + let ids_only = [*all.last().unwrap()]; + let binding = world + .bind(&net, &ids_only, net.order().space(), READER) + .unwrap(); + assert!(matches!( + CostQuery::open(&binding, &world.store, &world.view, net.frames(), &net), + Err(CostRefusal::NotBound { .. }) + )); + // Silent half: the full binding still opens. + assert!(cost_query(&world, &net).is_ok()); +} + +#[test] +fn a_stale_binding_is_refused_after_a_price_change() { + let world = World::new(); + let mut net = Network::ingest(Order::Identity); + let binding = world + .bind(&net, &cost_fields(), net.order().space(), READER) + .unwrap(); + net.set_unit_cost(3, demanded_categories()[0], 99); + let r = CostQuery::open(&binding, &world.store, &world.view, net.frames(), &net); + assert!( + matches!( + r, + Err(CostRefusal::Lane(lance_graph_dt64::LaneRefusal::Frame(_))) + ), + "{:?}", + r.err() + ); +} + +#[test] +fn a_demand_band_prepared_for_another_menu_is_refused() { + let world = World::new(); + let net = Network::ingest(Order::Identity); + let query = cost_query(&world, &net).unwrap(); + let other = PreparedDemand::prepare(MENU_REV + 1); + assert_eq!( + query.per_store(&other, 1).err(), + Some(CostRefusal::MenuRevision { + prepared: MENU_REV + 1, + asked: MENU_REV + }) + ); + let demand = PreparedDemand::prepare(MENU_REV); + for q in [0, 129] { + assert_eq!( + query.per_store(&demand, q).err(), + Some(CostRefusal::Quantity { q }) + ); + } +} + +#[test] +fn another_row_order_costs_identically_by_identity() { + let world = World::new(); + let net = Network::ingest(Order::Reversed); + let c = cost_query(&world, &net) + .expect("binds in its own space") + .per_store(&PreparedDemand::prepare(MENU_REV), 2) + .unwrap(); + let o = Oracle::BASELINE; + for s in 0..STORES { + for m in 0..ITEMS { + assert_eq!(c[s][m], o.store_cost(s, m, 2), "store {s} item {m}"); + } + } +} diff --git a/crates/foundry-parity-lab/tests/integration/main.rs b/crates/foundry-parity-lab/tests/integration/main.rs index 6b09db93f..5c54dd5b6 100644 --- a/crates/foundry-parity-lab/tests/integration/main.rs +++ b/crates/foundry-parity-lab/tests/integration/main.rs @@ -3,5 +3,6 @@ #![allow(clippy::needless_range_loop)] pub mod common; pub mod controls; +pub mod cost; pub mod parity; pub mod refusals;