From 94b401ceaafd93f9b853d391faaa282f6f8e6d59 Mon Sep 17 00:00:00 2001 From: Frank McSherry Date: Wed, 26 Aug 2026 16:35:05 -0400 Subject: [PATCH 1/2] corgi: decline Proj on list shapes; take the row-wise fallback Term::Proj lowered to Op::Field unconditionally, but Proj on a List means indexing and Field is product-elimination-only (panicked in value.rs). compile_projection now guards with a total place-shape resolver. Co-Authored-By: Claude Fable 5 --- interactive/src/corgi/logic.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/interactive/src/corgi/logic.rs b/interactive/src/corgi/logic.rs index 9aa86e9a3..e7a4be284 100644 --- a/interactive/src/corgi/logic.rs +++ b/interactive/src/corgi/logic.rs @@ -218,6 +218,22 @@ pub fn shape_of_place(t: &Term, env_shapes: &[Shape]) -> Shape { } } +/// TOTAL best-effort shape of a `Proj` operand: resolve the `Var`/`Bound`/`Proj` spine +/// through KNOWN shapes only; `None` means "can't tell" (never a panic), and the caller +/// keeps the old lowering. The non-panicking cousin of [`shape_of_place`]. +fn place_shape_opt(t: &Term, env_shapes: &[Shape]) -> Option { + match t { + Term::Var(i) => env_shapes.get(*i).cloned(), + Term::Bound(k) => env_shapes.get(env_shapes.len().checked_sub(1 + *k)?).cloned(), + Term::Proj(inner, i) => match place_shape_opt(inner, env_shapes)? { + Shape::Prod(fs) => fs.get(*i).cloned(), + Shape::List(e) => Some(*e), + _ => None, + }, + _ => None, + } +} + /// Best-effort structural `Shape` of a (non-place) compiled `Term`, used to decide cross-shape /// `Eq`/`Ne`: DDIR compares `Value`s structurally, so e.g. `Tuple != Int` is *always* true (the /// variants differ) — but corgi's `CmpOp::Rel` requires matching shapes. When the operand shapes @@ -392,6 +408,14 @@ pub fn compile(term: &Term, b: &mut Builder, env: &[usize], env_shapes: & } } Term::Proj(t, i) => { + // Shape-directed: `Op::Field` is PRODUCT elimination only. On a `List` operand, + // DDIR's Proj means indexing (`ir::eval` ir.rs:112), which has no columnar + // lowering here yet (corgi's Get tier) — decline, and rows handle it. The check + // is the TOTAL place resolver (never panics); an unresolved operand compiles as + // before (`Field`, with corgi's runtime shape check behind it). + if matches!(place_shape_opt(t, env_shapes), Some(Shape::List(_))) { + return None; + } let id = compile(t, b, env, env_shapes, anchor)?; Some(b.add(Op::Field(*i), vec![id])) } From f379b2483b4c232b4efa379611c39688fe32bf0f Mon Sep 17 00:00:00 2001 From: Frank McSherry Date: Wed, 26 Aug 2026 16:35:05 -0400 Subject: [PATCH 2/2] corgi: reject heterogeneous columns loudly at ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns carry exactly one shape; infer_shape_cols/transcode assumed row 0's arity for every row, truncating or panicking depending on row order — worst case a silent wrong answer (distinct over truncated rows invents a row). assert_uniform enforces the contract with a message naming the offending row and the remedy (pad to one arity, or use --backend=vec). Co-Authored-By: Claude Fable 5 --- interactive/src/corgi/logic.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/interactive/src/corgi/logic.rs b/interactive/src/corgi/logic.rs index e7a4be284..fee0ff47c 100644 --- a/interactive/src/corgi/logic.rs +++ b/interactive/src/corgi/logic.rs @@ -21,6 +21,7 @@ use corgi::{ArithOp, BinOp as CBinOp, Builder, CmpOp, Graph, Kind, NumOp, Op, Pr /// single sample can't reveal (it shows only one tag), so the scan is over every row, recursing /// column-wise to cover nested variants too. pub fn infer_shape_cols(rows: &[DValue]) -> Shape { + assert_uniform(rows); let Some(first) = rows.first() else { return Shape::Unit }; match first { DValue::Int(_) => Shape::Prim(64), @@ -80,6 +81,33 @@ pub fn infer_shape_cols(rows: &[DValue]) -> Shape { } } +/// The uniformity contract, enforced LOUDLY at the ingest boundary: corgi columns are SoA +/// and carry ONE shape, while DDIR rows are dynamically typed. Without this check a mixed +/// column either truncated silently (rows wider than row 0 lost their extra fields — silent +/// wrong answers) or panicked with a bare `index out of bounds` — both worse than saying why. +/// Nested levels are covered by `infer_shape_cols`' recursion (each recursive call re-checks). +fn assert_uniform(rows: &[DValue]) { + let Some(first) = rows.first() else { return }; + fn kind(r: &DValue) -> (&'static str, usize) { + match r { + DValue::Int(_) => ("Int", 0), + DValue::Tuple(xs) => ("Tuple", xs.len()), + DValue::List(_) => ("List", 0), + DValue::Variant(..) => ("Variant", 0), + } + } + let k0 = kind(first); + for (i, r) in rows.iter().enumerate().skip(1) { + let ki = kind(r); + assert!( + ki == k0, + "corgi transcode: heterogeneous column — row 0 is {}/{} but row {} is {}/{}: \ + the corgi backend requires shape-uniform columns (pad rows to one arity, or use --backend=vec)", + k0.0, k0.1, i, ki.0, ki.1 + ); + } +} + /// AoS rows -> SoA corgi columns, directed by `shape`. pub fn transcode(rows: &[DValue], shape: &Shape) -> CValue { match shape {