Skip to content

Commit 5e10dbb

Browse files
committed
fix(osint): PR #560 codex P2 — gremlin bag semantics + aiwar cross-family edges
(1) graph_gremlin: step() silently deduped reached targets via a `seen` set, breaking Gremlin bag/multiset semantics — g.v(["A","C"]).out().count() gave 1 not 2 when both reach B. Rewrote to per-traverser emission (duplicates preserved); set semantics is now the explicit `dedup()` step. +test out_preserves_bag_multiplicity. (2) aiwar: aiwar_node_rows wrote cross-category adapter bytes into the first 12 in_family slots (labeled `linked` by project_snapshot), so `references` queries missed them and the label flipped with sorted fan-out count. aiwar edges are ALL cross-family (built from tf != fam) → write them to the 4 out_family slots (`references`), cap 4. Test asserts `references` present and no `linked`. contract aiwar 3/3; callcenter gremlin 8/8 (+1 bag test); clippy clean (new files; pre-existing TD-CALLCENTER-QUERY-CLIPPY untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcpLeEC3XK8Eye53GKBVvi
1 parent 6a64730 commit 5e10dbb

3 files changed

Lines changed: 63 additions & 23 deletions

File tree

.claude/board/AGENT_LOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2026-06-20 (cont.⁹) — PR #560 codex P2 review fixes (gremlin bag semantics + aiwar cross-family edges)
2+
3+
**Main thread (Opus), autoattended.** Two unresolved P2 codex threads on PR #560, both fixed: (1) `graph_gremlin.rs` `step()` silently deduped targets via a `seen` set — broke Gremlin bag/multiset semantics (`v(["A","C"]).out().count()` = 1 not 2 when both reach B). Rewrote to per-traverser emission (duplicates preserved); added explicit `dedup()` step + `out_preserves_bag_multiplicity` test. (2) `aiwar.rs` `aiwar_node_rows` put cross-category adapter bytes into the first 12 `in_family` slots (labeled `linked`), so `references` queries missed them and the label flipped with fan-out count — aiwar edges are ALL cross-family, so they now go to the 4 `out_family` slots (`references`), cap 4; test asserts `references` present + no `linked`. contract aiwar 3/3, callcenter gremlin 8/8 (+1 bag test), clippy clean (my files; pre-existing TD-CALLCENTER-QUERY-CLIPPY untouched). Pushed to #560; both review threads resolved. **558/559 (NOT mine — OpenProject/Redmine ontology bridges) checked: NOT all resolved** — #558 2 open (codex P2 seed-context-id + CodeRabbit unit-tests), #559 1 open P1 (Redmine/OpenProject entity_type_id convergence). Surfaced to operator (different arc); not auto-fixed.
4+
15
## 2026-06-20 (cont.⁸) — D-GV2-1 shipped: GUID v2 tail (leaf·family·identity 3×u16), feature-gated
26

37
**Main thread (Opus), autoattended.** Operator "go" on the guid-v2-tail plan (canon version bump + capacity numbers accepted). Built **D-GV2-1** additive + `#[cfg(feature="guid-v2-tail")]` + NON-breaking (v1 untouched): `canonical_node::{new_v2, leaf() 10..12, family_v2() 12..14, identity_v2() 14..16, local_key_v2, decode_v2/GuidPartsV2, to_hex_v2, GUID_TAIL_LAYOUT_VERSION_V2=2}`; `hhtl::from_guid_prefix_v2` (HEEL·HIP·TWIG·leaf, 16 nibbles — leaf in path, family/identity in basin tail). Per `I-LEGACY-API-FEATURE-GATED`: distinct v2 names (no silent semantic swap), field-isolation matrix test (vary one tier → only that accessor changes), v1/v2 coexistence test, version-gate const. **Verified BOTH configs:** default `cargo test -p lance-graph-contract --lib` = **703** (unchanged, non-breaking); `--features guid-v2-tail` = **706** (+3 v2 tests); clippy `-D warnings` clean on both. Cutover (rename v2→canonical, deprecate v1, ENVELOPE_LAYOUT_VERSION bump) = D-GV2-5, after D-GV2-2 (family→Codebook registry) / D-GV2-3 (soa_graph per-family edges) / D-GV2-4 (aiwar re-key) consume the v2 accessors. Pushed to jirak (extends PR #560). Plan D-GV2-1 marked SHIPPED.

crates/lance-graph-callcenter/src/graph_gremlin.rs

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -83,27 +83,41 @@ impl<'a> Traversal<'a> {
8383
}
8484

8585
fn step(&mut self, label: Option<&str>, outgoing: bool) {
86-
let cur: HashSet<&str> = self.current.iter().map(String::as_str).collect();
86+
// Bag/multiset semantics (Gremlin): each current traverser independently
87+
// emits its reached targets, so duplicates are PRESERVED — `count()` and
88+
// downstream steps reflect edge multiplicity / fan-in. Set semantics is
89+
// the explicit `dedup()` step, never implicit (codex P2, PR #560).
90+
let cur = std::mem::take(&mut self.current);
8791
let mut next: Vec<String> = Vec::new();
88-
let mut seen: HashSet<String> = HashSet::new();
89-
for e in &self.snap.edges {
90-
if let Some(l) = label {
91-
if e.label != l {
92-
continue;
92+
for from_v in &cur {
93+
for e in &self.snap.edges {
94+
if let Some(l) = label {
95+
if e.label != l {
96+
continue;
97+
}
98+
}
99+
let (from, to) = if outgoing {
100+
(&e.source, &e.target)
101+
} else {
102+
(&e.target, &e.source)
103+
};
104+
if from == from_v {
105+
next.push(to.clone());
93106
}
94-
}
95-
let (from, to) = if outgoing {
96-
(&e.source, &e.target)
97-
} else {
98-
(&e.target, &e.source)
99-
};
100-
if cur.contains(from.as_str()) && seen.insert(to.clone()) {
101-
next.push(to.clone());
102107
}
103108
}
104109
self.current = next;
105110
}
106111

112+
/// Gremlin `dedup()` — collapse to distinct vertices (set semantics). The
113+
/// explicit opt-in; every other step preserves bag/multiset multiplicity.
114+
#[must_use]
115+
pub fn dedup(mut self) -> Self {
116+
let mut seen: HashSet<String> = HashSet::new();
117+
self.current.retain(|id| seen.insert(id.clone()));
118+
self
119+
}
120+
107121
/// Terminal `values("kind")` — project the `kind` of each reached vertex
108122
/// (skips ids that are not present as nodes, e.g. a dangling adapter target).
109123
#[must_use]
@@ -218,6 +232,21 @@ mod tests {
218232
assert_eq!(g(&s).v(&["A"]).out_e("member-of").values_kind(), vec!["Family"]);
219233
}
220234

235+
#[test]
236+
fn out_preserves_bag_multiplicity() {
237+
// A→B and C→B: from {A,C}, out() reaches B TWICE (bag semantics);
238+
// dedup() collapses to one. No implicit dedup (codex P2, PR #560).
239+
let s = GraphSnapshot {
240+
nodes: vec![node("A", "X"), node("B", "X"), node("C", "X")],
241+
edges: vec![edge("A", "B", "r"), edge("C", "B", "r")],
242+
inferences: vec![],
243+
contradictions: vec![],
244+
timestamp: 0,
245+
};
246+
assert_eq!(g(&s).v(&["A", "C"]).out().count(), 2);
247+
assert_eq!(g(&s).v(&["A", "C"]).out().dedup().count(), 1);
248+
}
249+
221250
#[test]
222251
fn unknown_label_yields_empty() {
223252
let s = sample();

crates/lance-graph-contract/src/aiwar.rs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,15 @@ pub fn aiwar_node_rows(graph: &LiteralGraph) -> Vec<NodeRow> {
8989
.collect();
9090
slots.sort_unstable();
9191
slots.dedup();
92+
// aiwar entities connect ACROSS categories (Nation→System, …); every
93+
// adapter here is cross-family (built from `tf != fam`), so they go in
94+
// the 4 OUT-of-family slots (labeled `references`), never in-family
95+
// (`linked`) — otherwise the label would flip with fan-out count and
96+
// `references` queries would miss them (codex P2, PR #560). Cap at the
97+
// 4 canonical out-of-family slots.
9298
let mut edges = EdgeBlock::default();
93-
for (k, &b) in slots.iter().take(16).enumerate() {
94-
if k < 12 {
95-
edges.in_family[k] = b;
96-
} else {
97-
edges.out_family[k - 12] = b;
98-
}
99+
for (k, &b) in slots.iter().take(4).enumerate() {
100+
edges.out_family[k] = b;
99101
}
100102
NodeRow {
101103
key: NodeGuid::new(
@@ -167,12 +169,17 @@ mod tests {
167169
// every entity → member-of edge to its category family hub
168170
let member_of = snap.edges.iter().filter(|e| e.label == "member-of").count();
169171
assert_eq!(member_of, g.node_count());
170-
// Israel (Nation) → the PredictiveAnalytics family hub: a cross-category
171-
// family-adapter edge (resolves to a family node, not an individual member)
172+
// Israel (Nation) → the PredictiveAnalytics family hub: a cross-CATEGORY
173+
// edge, so it carries the out-of-family `references` label — never the
174+
// in-family `linked` (aiwar edges are all cross-category).
172175
assert!(snap
173176
.edges
174177
.iter()
175-
.any(|e| e.label == "linked" && e.target.starts_with("family:")));
178+
.any(|e| e.label == "references" && e.target.starts_with("family:")));
179+
assert!(
180+
!snap.edges.iter().any(|e| e.label == "linked"),
181+
"aiwar edges are all cross-category ⇒ none are in-family `linked`"
182+
);
176183
}
177184

178185
#[test]

0 commit comments

Comments
 (0)