From a9a0a4169f768d6d5da29fa3b7c30202f3edf658 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:32:38 +0000 Subject: [PATCH 1/2] glove-parity: refuse two populations of one relation target concept A call names one population per target concept, so a method whose two relations reach the same concept in different universes cannot say which one each relation reads. Preparation now refuses it (ConflictingUniverse) instead of keeping the first. Codex review on #1514. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011BMJnDBAfzcuQh5HeT39Rh --- .../plans/gremlin-composable-algebra-v1.md | 6 +- crates/lance-graph-glove-parity/src/method.rs | 12 +++- .../tests/relation.rs | 63 +++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/.claude/plans/gremlin-composable-algebra-v1.md b/.claude/plans/gremlin-composable-algebra-v1.md index 517031423..8b99e8b1d 100644 --- a/.claude/plans/gremlin-composable-algebra-v1.md +++ b/.claude/plans/gremlin-composable-algebra-v1.md @@ -258,13 +258,15 @@ adapter, minus the relations that fan out. and binder, and names errors `relation.field`. The key joins `required`, so an unproven key refuses the method like any field it reads. - A call names each target concept's population; another population, or - none, is `CallError::ForeignUniverse`. + none, is `CallError::ForeignUniverse`. Two relations that reach one target concept in + different populations are refused at preparation (`ConflictingUniverse`), + since one population per concept is all a call can name. - **R2IL cannot express it.** The fold band has `VIA` but no `GATHER`: it can read a target field through a key, but not test that the key names a live row. A method with a relation condition returns `CallError::FrontendGap { R2il, "GATHER" }`; methods without one still run on both frontends. -Evidence: `tests/relation.rs` (7 tests). FIXTURE: a synthetic `line -billed_to-> +Evidence: `tests/relation.rs` (8 tests). FIXTURE: a synthetic `line -billed_to-> partner` pair (37 partners, 4 dangling key values, every 7th partner dead, every 13th line dead); every answer is checked against a row loop over the field text at n ∈ {1, 64, 65, 4097}. Six disable runs, each red on the test diff --git a/crates/lance-graph-glove-parity/src/method.rs b/crates/lance-graph-glove-parity/src/method.rs index f5e0dc7b6..4456d2ffa 100644 --- a/crates/lance-graph-glove-parity/src/method.rs +++ b/crates/lance-graph-glove-parity/src/method.rs @@ -365,6 +365,9 @@ pub enum PrepareError { /// The relation supplies no foreign lane for this target field /// (`relation.field`). NoForeignLane(String), + /// Two relations the method reads reach this target concept in + /// different populations. A call names one population per concept. + ConflictingUniverse(u16), } /// Why a call did not run. @@ -1136,8 +1139,13 @@ impl Glove<'_> { for c in filter { if let Cond::Reaches { rel } | Cond::Via { rel, .. } = *c { let r = self.relation(rel)?; - if !reaches.iter().any(|(c, _)| *c == r.target.concept) { - reaches.push((r.target.concept, r.target.universe)); + let (concept, universe) = (r.target.concept, r.target.universe); + match reaches.iter().find(|(c, _)| *c == concept) { + None => reaches.push((concept, universe)), + Some(&(_, u)) if u != universe => { + return Err(PrepareError::ConflictingUniverse(concept)); + } + Some(_) => {} } } } diff --git a/crates/lance-graph-glove-parity/tests/relation.rs b/crates/lance-graph-glove-parity/tests/relation.rs index c7143b48a..b9bdf6c3f 100644 --- a/crates/lance-graph-glove-parity/tests/relation.rs +++ b/crates/lance-graph-glove-parity/tests/relation.rs @@ -654,3 +654,66 @@ fn out_composes_and_keeps_the_anchor() { let get = |k: u8| table.get(MethodKey([k; 16])).unwrap().as_ref().unwrap(); assert_eq!(get(1).programs(), get(2).programs()); } + +/// FAILS IF: two relations to one target concept from different populations +/// prepare. A call names one population per concept, so the method could not +/// say which one the second relation's planes come from. +#[test] +fn two_populations_of_one_target_concept_are_refused() { + let fx = Fx::new(); + let glove = |universe| Glove { + concept: PARTNER, + basis: &fx.partner_basis, + claims: &fx.partner_claims, + binder: &Erp, + table: "partner", + reading: READING, + universe, + relations: &[], + }; + let (now, then) = (glove(PARTNER_U), glove(Universe(9))); + let lanes: &[(&str, u16)] = &[("country", 0)]; + let rel = |name, target, plane| Relation { + name, + fk: "partner", + target, + plane, + lanes, + }; + let prepare = |second| { + let rels = [rel("billed_to", &now, 0), rel("shipped_to", second, 1)]; + let line = Glove { + concept: LINE, + basis: &fx.line_basis, + claims: &fx.line_claims, + binder: &Erp, + table: "line", + reading: READING, + universe: LINE_U, + relations: &rels, + }; + let specs = [( + "m", + fx.spec( + 1, + query( + &[ + Cond::Reaches { rel: "billed_to" }, + Cond::Reaches { rel: "shipped_to" }, + ], + &[Fold::Count], + ), + ), + )]; + MethodTable::prepare(&line, &specs) + .get(MethodKey([1; 16])) + .unwrap() + .clone() + }; + assert_eq!( + prepare(&then).unwrap_err(), + PrepareError::ConflictingUniverse(PARTNER) + ); + // Silence twin: the same population through both relations prepares. + assert!(prepare(&now).is_ok()); +} From d746905a602590fbbbcf3ba8d8fd61036e990646 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:33:27 +0000 Subject: [PATCH 2/2] board: D-CMH-G1-4a shipped (#1514) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011BMJnDBAfzcuQh5HeT39Rh --- .claude/board/STATUS_BOARD.md | 2 +- .claude/plans/gremlin-composable-algebra-v1.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 864039ffe..194857f91 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -22,7 +22,7 @@ Plan: `.claude/plans/gremlin-composable-algebra-v1.md`. | D-CMH-G1-1 | predicate methods + method references | Shipped (#1508) | composed `programs()` == hand conjunction; cycles, contract, cross-class refused | | D-CMH-G1-2 | typed Gremlin steps → method body | Shipped (#1508) | == direct Quack program; both frontends == row oracle | | D-CMH-G1-3 | Gremlin-shaped runtime declarations | Shipped (#1508) | loads after the build; effects refused at load | -| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` | In PR | row loop over the text at n ∈ {1,64,65,4097}; 6 disable runs red; R2IL refuses (no `GATHER`) | +| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` | Shipped (#1514; ConflictingUniverse fix follows) | row loop over the text at n ∈ {1,64,65,4097}; 6 disable runs red; R2IL refuses (no `GATHER`) | | D-CMH-G1-4 | rest of the relation carrier (fan-out moves the anchor; `E()`, `outE/inV`) | Queued | #1311 bulk oracle + DuckDB through the method layer | #1311 bulk oracle + DuckDB through the method layer | | D-CMH-G1-5 | FirstOfRun | Queued | insufficiency proof first; Quack + R2IL vs independent oracle | diff --git a/.claude/plans/gremlin-composable-algebra-v1.md b/.claude/plans/gremlin-composable-algebra-v1.md index 8b99e8b1d..db1f18d23 100644 --- a/.claude/plans/gremlin-composable-algebra-v1.md +++ b/.claude/plans/gremlin-composable-algebra-v1.md @@ -282,6 +282,6 @@ emitted per condition. | D-CMH-G1-1 | predicate methods + method references | Shipped (#1508) | | D-CMH-G1-2 | typed Gremlin steps → method body | Shipped (#1508) | | D-CMH-G1-3 | Gremlin-shaped runtime declarations | Shipped (#1508) | -| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` after it (§8) | In PR | +| D-CMH-G1-4a | functional relation carrier: `out(rel)`, `out(rel).has`, `count()` after it (§8) | Shipped (#1514) | | D-CMH-G1-4 | the rest of the carrier: `in`/M:N (anchor moves to child or edge rows), `E()`, `outE/inV`, `dedup` after a fan-out | Queued | | D-CMH-G1-5 | FirstOfRun (insufficiency proof first; separate PR #3) | Queued |