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 517031423..db1f18d23 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 @@ -280,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 | 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()); +}