lunaris_retrieve/operators/graph.rs
1//! `Graph::anchored(entity_ids, hops)` — graph-anchored retrieval (RETRIEVE-03).
2//!
3//! Per blueprint §8 + ROADMAP Phase 3 success criterion #3, the operator starts
4//! from a list of pre-resolved [`EntityId`]s (extracted from the query text by
5//! the planner per D-13), traverses the graph via Cypher BFS for `hops` steps,
6//! and returns reachable entity ids as `RawHit { source_op: SourceOp::Graph }`.
7//!
8//! ## Capability-driven Cypher dialect dispatch (Wave 4 amendment)
9//!
10//! A parallel-agent probe found that Moon does not accept the full Wave-4
11//! template. The operator picks the Cypher template at `retrieve()`-time
12//! from [`StorageCapabilities::cypher_dialect`](lunaris_core::CypherDialect):
13//!
14//! | Tier | Backends | Template shape |
15//! |------|----------|----------------|
16//! | [`CypherDialect::Legacy`] | Moon | `MATCH (n)-[*1..N]-(m) RETURN id/name/type` |
17//! | [`CypherDialect::Full`] | (forward-compat) | `MATCH p = ...` + `length(p)` + `source_entity_id` + `reduce(...) AS edge_weight_product` |
18//!
19//! Primary-source rejection that motivated the split: **Moon** rejects
20//! `MATCH p = (n)-[*1..N]-(m)` outside `shortestPath()`
21//! (`vendor/moon/src/graph/cypher/parser/pattern.rs:172-216`) and its
22//! function table omits `length()`, `reduce()`, `relationships()`
23//! (`executor/eval.rs:116-227`).
24//!
25//! 0.7.0 dropped the intermediate `PathMetrics` tier along with the
26//! Postgres backend (Apache AGE 1.5) that was its only producer.
27//!
28//! W-7 fix: the property name MUST be `id_hex` because Plan 03-03's ingest
29//! fan-out writes `WriteOp::GraphNode { props: { "id_hex": format!("{}",
30//! entity_id), ... } }`. Earlier drafts used `id`, which silently returned
31//! zero rows. Both templates use `id_hex`.
32//!
33//! Score formula:
34//! `score = (edge_weight_product / (1.0 + path_length)) * anchor_confidence`
35//!
36//! `anchor_confidence` is NEVER a Cypher column — the operator synthesizes
37//! it post-Cypher by joining the (optional) `source_entity_id` column
38//! against the planner's per-seed confidence map (built from
39//! `Graph::anchored`'s `Vec<(EntityId, f32)>` argument).
40//!
41//! Back-compat: when a column the dialect promises is absent from the
42//! response (e.g., a degraded backend that answers with the legacy
43//! three-column shape), the header-keyed parser falls
44//! back to defaults (`path_length = i`, `edge_weight_product = 1.0`,
45//! `anchor_confidence = 1.0`) — the score reduces to the Wave-3 legacy
46//! `1.0 / (1.0 + i)`. The `graph_score_back_compat_when_no_new_headers`
47//! test plus the `dispatch_*_preserves_back_compat_score*` tests pin this
48//! property across every tier.
49//!
50//! `<hops>` is spliced into the cypher string at construction time — the
51//! openCypher spec requires variable-length pattern bounds (`[*lo..hi]`) to
52//! be literals; both Moon GRAPH.QUERY and Apache AGE refuse `[*1..$hops]`.
53//! `$ids` and `$k` are typed parameters so EntityIds NEVER reach the cypher
54//! string text — defends T-03-02-01 (Cypher injection) by mirroring the
55//! Plan 02-02 plainto_tsquery parameter-binding pattern.
56//!
57//! ## Defense layers (T-03-02-02 DoS mitigation)
58//!
59//! - `MAX_GRAPH_HOPS = 5` — hard cap on hops per D-14 (caller-supplied `hops
60//! greater than 5` clamps to 5; `hops == 0` clamps up to 1 so the BFS
61//! always runs at least one edge step).
62//! - [`super::clamp_k`] caps the result count at `MAX_K = 1000` (Plan 02-02
63//! convention).
64//! - Phase 4 OPS-05 will wire `tower::timeout` around the Retriever for
65//! callers that need a wall-clock cap — bounded fan-out per query.
66
67use std::any::Any;
68use std::collections::HashMap;
69use std::sync::Arc;
70
71use async_trait::async_trait;
72use lunaris_core::storage::types::GraphDecay;
73use lunaris_core::{CypherDialect, CypherQuery, LunarisError};
74use lunaris_extract::EntityId;
75
76use super::{QueryContext, Retriever, clamp_k};
77use crate::types::{RawHit, SourceOp};
78
79/// Default hops per ROADMAP Phase 3 success criterion #3.
80pub const DEFAULT_GRAPH_HOPS: usize = 2;
81/// Hard cap on hops to prevent runaway BFS (D-14). Caller-supplied
82/// `hops greater than 5` clamps to 5; `hops == 0` clamps up to 1 so the BFS
83/// always runs at least one edge step (zero-hop traversal would only return
84/// the anchor entities themselves, which the caller already has).
85pub const MAX_GRAPH_HOPS: usize = 5;
86/// Default candidate count for graph traversal (matches Vector/Keyword default of 30).
87pub const DEFAULT_GRAPH_K: usize = 30;
88/// Graph name Lunaris traverses — the `<graph>` argument of Moon's
89/// `GRAPH.QUERY <graph> "..."`. Held as a constant (rather than inlined at
90/// each call site) so a future backend can be pointed at the same graph
91/// identifier and keep the Cypher template portable; `with_graph()`
92/// overrides it per tenant.
93pub const LUNARIS_GRAPH_NAME: &str = "lunaris_graph";
94
95/// Graph-anchored retrieval operator.
96///
97/// Construction is fluent — wire at `recall()`-build time, the network call
98/// only happens at `.retrieve()` time. The `entity_ids` come from the
99/// RETRIEVE-13 planner stub (extracted from query text) per D-13. Empty
100/// `entity_ids` short-circuits to an empty result set without touching
101/// storage (the planner returns no entities when the query has no entity
102/// mentions; treat that as "graph branch contributes nothing" rather than an
103/// error).
104#[derive(Clone, Debug)]
105#[must_use = "Graph is a query node — pass it to RetrievalBuilder::with_root() or chain via .and/.or/.fuse_rrf, otherwise it never executes"]
106pub struct Graph {
107 /// Anchor seeds with per-seed confidence in `[0.0, 1.0]`. The operator
108 /// snapshots a `HashMap<EntityId, f32>` from this vector at construction
109 /// for O(1) confidence lookup after Cypher returns.
110 pub seeds: Vec<(EntityId, f32)>,
111 pub hops: usize,
112 pub k: usize,
113 pub graph: String,
114 /// Per-seed confidence keyed by `EntityId` for O(1) post-Cypher lookup
115 /// against the `source_entity_id` column. Built from `seeds` at
116 /// construction time. On duplicate seed keys the MAX confidence wins
117 /// (safest default for caller mistakes — a low-confidence duplicate
118 /// must never demote a high-confidence anchor). Values are clamped to
119 /// `[0.0, 1.0]` to prevent a caller-passed out-of-range value from
120 /// poisoning the score downstream.
121 pub(crate) confidence_by_seed: HashMap<EntityId, f32>,
122 /// Optional recency decay (ADD task `ft-navigate-recall`): threads a λ
123 /// through `StoragePort::graph_traverse_decayed` so edge cost becomes
124 /// `|weight| + λ·w·age_seconds` on backends with native decay (Moon).
125 /// `None` keeps the exact pre-decay traversal (delegation guarantee of
126 /// the graph-decay-recency contract).
127 pub(crate) decay: Option<GraphDecay>,
128}
129
130impl Graph {
131 /// `Graph::anchored(seeds, hops)` — primary constructor per D-13.
132 ///
133 /// `seeds` carries `(EntityId, confidence)` pairs where `confidence` is
134 /// the planner's belief that the entity is actually relevant to the
135 /// query. Values are clamped to `[0.0, 1.0]`. Duplicates collapse to
136 /// MAX (safe default — never demote a high-confidence anchor by passing
137 /// a low-confidence duplicate).
138 ///
139 /// `hops` is clamped to `[1, MAX_GRAPH_HOPS]` (D-14):
140 /// - `hops` greater than 5 — clamped to 5 (DoS defense; bounded BFS fan-out)
141 /// - `hops == 0` — clamped up to 1 (zero-hop traversal would only return
142 /// the anchor entities, which the caller already has)
143 ///
144 /// `k` defaults to [`DEFAULT_GRAPH_K`] (matches Vector/Keyword default of
145 /// 30) and is clamped via [`super::clamp_k`].
146 pub fn anchored(seeds: Vec<(EntityId, f32)>, hops: usize) -> Self {
147 let mut confidence_by_seed: HashMap<EntityId, f32> = HashMap::with_capacity(seeds.len());
148 for (id, conf) in &seeds {
149 let clamped = conf.clamp(0.0, 1.0);
150 confidence_by_seed
151 .entry(*id)
152 .and_modify(|prev| {
153 if clamped > *prev {
154 *prev = clamped;
155 }
156 })
157 .or_insert(clamped);
158 }
159 Self {
160 seeds,
161 hops: hops.clamp(1, MAX_GRAPH_HOPS),
162 k: clamp_k(DEFAULT_GRAPH_K),
163 graph: LUNARIS_GRAPH_NAME.into(),
164 confidence_by_seed,
165 decay: None,
166 }
167 }
168
169 /// Apply recency decay to the traversal (ADD task `ft-navigate-recall`).
170 /// The λ rides `StoragePort::graph_traverse_decayed`; backends without
171 /// `capabilities().graph_decay_native` surface `NotSupported` — gate on
172 /// the capability before composing decay into a recall.
173 pub fn with_decay(mut self, decay: GraphDecay) -> Self {
174 self.decay = Some(decay);
175 self
176 }
177
178 /// Convenience: list of seed [`EntityId`]s (without confidence). Mirrors
179 /// the pre-Wave-4 `entity_ids` field for callers that only need the keys.
180 pub fn entity_ids(&self) -> Vec<EntityId> {
181 self.seeds.iter().map(|(id, _)| *id).collect()
182 }
183
184 /// Override the candidate count. Clamped at [`super::MAX_K`].
185 pub fn with_k(mut self, k: usize) -> Self {
186 self.k = clamp_k(k);
187 self
188 }
189
190 /// Override the graph name (defaults to [`LUNARIS_GRAPH_NAME`]). Useful
191 /// for tenant-isolated deployments that scope each tenant to its own
192 /// AGE / Moon graph.
193 pub fn with_graph(mut self, graph: impl Into<String>) -> Self {
194 self.graph = graph.into();
195 self
196 }
197
198 // Mirror vector.rs lines 37-67 exactly — same chain method shape so the
199 // canonical compose example
200 // `Vector::new(...).and(Graph::anchored(...)).fuse_rrf(60).top(5)` works
201 // regardless of operator order.
202
203 /// Concurrent fan-out — runs THIS graph branch alongside `other` and
204 /// concatenates results (per-source ranking preserved via
205 /// [`SourceOp`]). Downstream `fuse_rrf` groups by `source_op` to fold
206 /// per-branch rankings.
207 pub fn and<R: Retriever + 'static>(self, other: R) -> super::combinators::AndRetriever {
208 super::combinators::AndRetriever::new(Box::new(self), Box::new(other))
209 }
210 /// Concurrent fan-out — runs THIS graph branch alongside `other` and
211 /// unions results by id (max score wins on duplicate id).
212 pub fn or<R: Retriever + 'static>(self, other: R) -> super::combinators::OrRetriever {
213 super::combinators::OrRetriever::new(Box::new(self), Box::new(other))
214 }
215 /// Sequential narrow — runs THIS graph branch first, then passes its hit
216 /// ids as a `Filter::Or(Filter::Eq{id, …})` to `other`.
217 pub fn then<R: Retriever + 'static>(self, other: R) -> super::combinators::ThenRetriever {
218 super::combinators::ThenRetriever::new(Box::new(self), Box::new(other))
219 }
220 /// Cap the final result set after the graph traversal resolves.
221 pub fn top(self, n: usize) -> super::modifiers::TopRetriever {
222 super::modifiers::TopRetriever::new(Box::new(self), n)
223 }
224
225 /// Wrap with a cross-encoder rerank pass (Plan 02-03).
226 pub fn rerank(
227 self,
228 reranker: Arc<dyn lunaris_rerank::Reranker>,
229 ) -> super::rerank::RerankRetriever {
230 super::rerank::RerankRetriever::new(Box::new(self), reranker)
231 }
232
233 /// Wrap with a fallback retriever — if THIS graph path errors (e.g., a
234 /// backend without graph support returns `NotSupported`, or Moon
235 /// GRAPH.QUERY hits a transient disconnect), switch to `fallback` and
236 /// tag returned hits with `degraded: true` (Plan 02-03).
237 pub fn degraded_fallback<R: Retriever + 'static>(
238 self,
239 fallback: R,
240 ) -> super::degraded::DegradedFallbackRetriever {
241 super::degraded::DegradedFallbackRetriever::new(Box::new(self), Box::new(fallback))
242 }
243
244 /// Build the parameterized [`CypherQuery`] for this operator using the
245 /// requested [`CypherDialect`] tier.
246 ///
247 /// `hops` is spliced into the cypher string literal because openCypher
248 /// requires variable-length pattern bounds to be literals (both Moon and
249 /// AGE refuse `[*1..$hops]`). The literal is bounded by `MAX_GRAPH_HOPS`
250 /// at construction time so a caller cannot inject `[*1..1000000]`.
251 ///
252 /// `$ids` carries the EntityIds as hex strings — they are NEVER spliced
253 /// into the cypher text (T-03-02-01 mitigation). `$k` carries the row
254 /// limit. This pattern mirrors the Plan 02-02 `plainto_tsquery($1)`
255 /// parameter-binding mitigation for tsquery DSL injection.
256 ///
257 /// Wave 4 amendment — dialect tiers:
258 /// - [`CypherDialect::Legacy`] — Moon-compatible. No path binding, no
259 /// `length()`, no `reduce()`, no `source_entity_id`. The header-keyed
260 /// parser falls back to the Wave-3 `1/(1+i)` score formula.
261 /// - [`CypherDialect::Full`] — forward-compat. `MATCH p = ...` +
262 /// `length(p) AS path_length` + `n.id_hex AS source_entity_id` +
263 /// `reduce(w=1.0, r in relationships(p) | ...) AS
264 /// edge_weight_product`. No current backend accepts this.
265 ///
266 /// W-7 fix: MATCH and RETURN MUST use `id_hex` to align with Plan 03-03's
267 /// GraphNode props which writes `id_hex`. Using `id` would silently
268 /// return zero rows because the node property is `id_hex` not `id`.
269 fn build_cypher(&self, dialect: CypherDialect) -> CypherQuery {
270 let cypher = match dialect {
271 CypherDialect::Legacy => format!(
272 "UNWIND $ids AS sid \
273 MATCH (n)-[*1..{hops}]-(m) WHERE n.id_hex = sid \
274 RETURN \
275 m.id_hex AS id, \
276 m.name AS name, \
277 m.type AS type \
278 LIMIT $k",
279 hops = self.hops,
280 ),
281 CypherDialect::Full => format!(
282 "UNWIND $ids AS sid \
283 MATCH p = (n)-[*1..{hops}]-(m) WHERE n.id_hex = sid \
284 RETURN \
285 m.id_hex AS id, \
286 m.name AS name, \
287 m.type AS type, \
288 length(p) AS path_length, \
289 reduce(w=1.0, r in relationships(p) | w * coalesce(r.weight, 1.0)) AS edge_weight_product, \
290 n.id_hex AS source_entity_id \
291 LIMIT $k",
292 hops = self.hops,
293 ),
294 };
295 let id_strs: Vec<serde_json::Value> = self
296 .seeds
297 .iter()
298 // EntityId Display = hex (16 bytes → 32 hex chars). Stable across
299 // calls — see lunaris_extract::types::EntityId::Display impl.
300 .map(|(id, _)| serde_json::Value::String(format!("{}", id)))
301 .collect();
302 let mut params = serde_json::Map::new();
303 params.insert("ids".into(), serde_json::Value::Array(id_strs));
304 params.insert("k".into(), serde_json::Value::Number(self.k.into()));
305 CypherQuery { graph: self.graph.clone(), cypher, params }
306 }
307}
308
309#[async_trait]
310impl Retriever for Graph {
311 async fn retrieve(&self, ctx: &QueryContext) -> Result<Vec<RawHit>, LunarisError> {
312 if self.seeds.is_empty() {
313 // No anchor entities → empty result. The planner returns no
314 // entity_ids when the query has no entity mentions; treat that
315 // as "graph branch contributes nothing" rather than an error.
316 // Importantly, we do NOT call ctx.storage.graph_traverse() — the
317 // empty case must NOT trigger a backend round trip.
318 return Ok(Vec::new());
319 }
320 // Wave 4 amendment: read the backend's declared dialect tier and
321 // build the matching Cypher template. Moon stays at Legacy (its
322 // parser rejects `MATCH p = ...` outside shortestPath()); `Full`
323 // is reserved for a future backend that accepts path binding +
324 // reduce(). The capability is read ONCE here, not threaded through
325 // every operator method, so the dispatch point is single and
326 // testable. See `dispatch_*` integration tests in graph_anchored.rs.
327 let dialect = ctx.storage.capabilities().cypher_dialect;
328 let q = self.build_cypher(dialect);
329 // Wave 2.5C: use ctx.scope — plumbed from RetrievalBuilder::with_scope
330 // (set by ScopedLunaris::recall/dsl) so graph_traverse is scope-isolated
331 // at the storage layer. Bare Lunaris::recall() uses Scope::dev().
332 // ft-navigate-recall: route through graph_traverse_decayed — decay
333 // None delegates byte-for-byte to graph_traverse (graph-decay-recency
334 // contract), so the no-decay path is behaviorally unchanged.
335 let result = ctx
336 .storage
337 .graph_traverse_decayed(&ctx.scope, &q, ctx.query.as_of, self.decay.as_ref())
338 .await?;
339
340 // P0 #4 Wave 3 + Wave 4 — Real graph scoring.
341 //
342 // Score formula:
343 // score = (edge_weight_product / (1.0 + path_length)) * anchor_confidence
344 //
345 // - `path_length` and `edge_weight_product` are read by HEADER NAME
346 // from GraphResult (additive: backends that haven't been upgraded
347 // to Wave 4 omit the headers; the operator falls back to
348 // `i / 1.0` so the score reduces to the pre-Wave-3 legacy
349 // `1.0 / (1.0 + i)`).
350 // - `source_entity_id` (Wave 4) is also header-keyed. When present,
351 // the operator decodes it back to an [`EntityId`] and looks up
352 // `confidence_by_seed` for the seed-supplied confidence (Wave 4
353 // piece A — `EntityId` confidence plumbing). When absent OR when
354 // the decoded id is not in the seed map, anchor_confidence
355 // defaults to `1.0` — preserving the back-compat property.
356 //
357 // The first three columns (`id`/`name`/`type`) stay positional —
358 // 30+ existing test fixtures and the `canned_graph_with` helper build
359 // rows positionally; switching them to header lookup would break the
360 // whole fixture surface.
361 //
362 // Defensive parsing (T-03-02-04 mitigation against malformed
363 // GraphResult): row.first().and_then(...).unwrap_or("") + hex::decode
364 // .unwrap_or_default() — a malformed row produces an empty-id RawHit
365 // rather than panicking. Hit count never exceeds result.rows.len().
366 let path_len_idx = result.headers.iter().position(|h| h == "path_length");
367 let edge_w_idx = result.headers.iter().position(|h| h == "edge_weight_product");
368 let source_id_idx = result.headers.iter().position(|h| h == "source_entity_id");
369
370 let hits: Vec<RawHit> = result
371 .rows
372 .into_iter()
373 .enumerate()
374 .map(|(i, row)| {
375 let id_hex = row.first().and_then(|v| v.as_str()).unwrap_or("").to_string();
376 let id_bytes = hex::decode(&id_hex).unwrap_or_default();
377 let name = row.get(1).cloned().unwrap_or(serde_json::Value::Null);
378 let typ = row.get(2).cloned().unwrap_or(serde_json::Value::Null);
379
380 // Header-keyed optional columns. f64 inputs are clamped at the
381 // f32 conversion boundary; non-numeric / out-of-range cells
382 // fall back to defaults rather than poisoning the score.
383 let path_length = path_len_idx
384 .and_then(|idx| row.get(idx))
385 .and_then(|v| v.as_f64())
386 .map(|x| x as f32)
387 .unwrap_or(i as f32);
388 let edge_weight_product = edge_w_idx
389 .and_then(|idx| row.get(idx))
390 .and_then(|v| v.as_f64())
391 .map(|x| x as f32)
392 .unwrap_or(1.0);
393
394 // Wave 4: synthesize anchor_confidence by joining
395 // `source_entity_id` against the per-seed confidence map.
396 // Missing column → default 1.0 (back-compat). Decode failure
397 // OR seed-not-in-map → default 1.0 (defensive; a row whose
398 // anchor we can't identify is treated as full-confidence
399 // rather than zero-confidence, which would silently zero out
400 // a legitimate hit).
401 let anchor_confidence = source_id_idx
402 .and_then(|idx| row.get(idx))
403 .and_then(|v| v.as_str())
404 .and_then(EntityId::from_hex)
405 .and_then(|seed| self.confidence_by_seed.get(&seed).copied())
406 .unwrap_or(1.0);
407
408 let score = (edge_weight_product / (1.0 + path_length)) * anchor_confidence;
409
410 RawHit {
411 id: id_bytes,
412 score,
413 rerank_applied: false,
414 degraded: false,
415 metadata: serde_json::json!({"name": name, "type": typ}),
416 source_op: SourceOp::Graph,
417 }
418 })
419 .collect();
420
421 Ok(hits)
422 }
423
424 fn as_any(&self) -> &dyn Any {
425 self
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 /// Shorthand for the empty-seeds case in tests. Avoids `Vec::<(EntityId, f32)>::new()`
434 /// boilerplate when the test only cares about non-seed constructor behavior
435 /// (hops clamp, k default, graph default).
436 fn no_seeds() -> Vec<(EntityId, f32)> {
437 Vec::new()
438 }
439
440 #[test]
441 fn anchored_clamps_hops_at_5() {
442 // D-14: hops > MAX_GRAPH_HOPS (5) clamps down to 5.
443 let g = Graph::anchored(no_seeds(), 100);
444 assert_eq!(g.hops, MAX_GRAPH_HOPS);
445 let g = Graph::anchored(no_seeds(), 6);
446 assert_eq!(g.hops, MAX_GRAPH_HOPS);
447 let g = Graph::anchored(no_seeds(), 5);
448 assert_eq!(g.hops, MAX_GRAPH_HOPS);
449 }
450
451 #[test]
452 fn anchored_clamps_hops_at_least_1() {
453 // hops == 0 clamps UP to 1 — zero-hop traversal would only return the
454 // anchor entities themselves (which the caller already has).
455 let g = Graph::anchored(no_seeds(), 0);
456 assert_eq!(g.hops, 1);
457 }
458
459 #[test]
460 fn anchored_default_k_is_30() {
461 // Matches Vector / Keyword default of 30.
462 let g = Graph::anchored(no_seeds(), 2);
463 assert_eq!(g.k, DEFAULT_GRAPH_K);
464 }
465
466 #[test]
467 fn with_k_clamps_at_max() {
468 // T-02-02-03 — clamp_k caps at super::MAX_K to defend against
469 // k = 1_000_000 DoS.
470 let g = Graph::anchored(no_seeds(), 2).with_k(usize::MAX);
471 assert_eq!(g.k, super::super::MAX_K);
472 }
473
474 #[test]
475 fn with_graph_overrides_default() {
476 let g = Graph::anchored(no_seeds(), 2).with_graph("tenant_42_graph");
477 assert_eq!(g.graph, "tenant_42_graph");
478 }
479
480 #[test]
481 fn anchored_default_graph_is_lunaris_graph() {
482 let g = Graph::anchored(no_seeds(), 2);
483 assert_eq!(g.graph, LUNARIS_GRAPH_NAME);
484 assert_eq!(g.graph, "lunaris_graph");
485 }
486
487 #[test]
488 fn anchored_clamps_confidence_into_unit_interval() {
489 // Out-of-range confidence values must NOT poison the score. Clamp to
490 // [0.0, 1.0] at the constructor boundary.
491 let id_high = EntityId::from_name_and_type("High", "Person");
492 let id_neg = EntityId::from_name_and_type("Neg", "Person");
493 let g = Graph::anchored(vec![(id_high, 100.0), (id_neg, -5.0)], 2);
494 assert_eq!(g.confidence_by_seed.get(&id_high), Some(&1.0));
495 assert_eq!(g.confidence_by_seed.get(&id_neg), Some(&0.0));
496 }
497
498 #[test]
499 fn anchored_duplicate_seeds_collapse_to_max_confidence() {
500 // Duplicate seeds in the input vector must collapse to the MAX
501 // confidence — a caller-supplied low-confidence duplicate MUST NOT
502 // demote a high-confidence anchor.
503 let id = EntityId::from_name_and_type("Alice", "Person");
504 let g = Graph::anchored(vec![(id, 0.2), (id, 0.9), (id, 0.5)], 2);
505 assert_eq!(g.confidence_by_seed.get(&id), Some(&0.9));
506 }
507
508 #[test]
509 fn build_cypher_splices_hops_literal_and_parameterizes_ids_and_k() {
510 // Property holds across ALL dialect tiers: hops is a literal,
511 // EntityIds are parameterized, k is a parameter.
512 let id1 = EntityId([1u8; 16]);
513 let id2 = EntityId([2u8; 16]);
514 let g = Graph::anchored(vec![(id1, 1.0), (id2, 1.0)], 3);
515 let id1_hex = format!("{}", id1);
516 let id2_hex = format!("{}", id2);
517 for dialect in [CypherDialect::Legacy, CypherDialect::Full] {
518 let q = g.build_cypher(dialect);
519 assert!(
520 q.cypher.contains("[*1..3]"),
521 "[{dialect:?}] hops literal must be spliced: {}",
522 q.cypher
523 );
524 assert!(
525 !q.cypher.contains(&id1_hex),
526 "[{dialect:?}] entity id 1 must NOT be in cypher text: {}",
527 q.cypher
528 );
529 assert!(
530 !q.cypher.contains(&id2_hex),
531 "[{dialect:?}] entity id 2 must NOT be in cypher text: {}",
532 q.cypher
533 );
534 let ids = q.params.get("ids").expect("ids param present").as_array().unwrap();
535 assert_eq!(ids.len(), 2);
536 assert_eq!(ids[0], serde_json::Value::String(id1_hex.clone()));
537 assert_eq!(ids[1], serde_json::Value::String(id2_hex.clone()));
538 assert!(q.params.contains_key("k"));
539 assert_eq!(q.params.get("k").unwrap().as_u64(), Some(DEFAULT_GRAPH_K as u64));
540 assert!(!q.params.contains_key("hops"), "hops MUST NOT be a parameter");
541 }
542 }
543
544 #[test]
545 fn build_cypher_uses_id_hex_property_name_not_id() {
546 // W-7 fix: MATCH and RETURN MUST use the `id_hex` property (Plan
547 // 03-03's GraphNode writes `id_hex`; using `id` would silently return
548 // zero rows).
549 //
550 // Inspector-UAT fix (2026-06-16): the anchor filter MUST be a `WHERE`
551 // clause, NOT the inline-property form `(n {id_hex: sid})`. Moon's
552 // Cypher executor SILENTLY IGNORES inline-property filters on a plain
553 // MATCH (live-confirmed) — it matches every node as an anchor, so the
554 // traversal returns the whole connected component and neither `root`
555 // nor `hops` constrains. See `feedback_moon_cypher_inline_filter`.
556 let id = EntityId::from_name_and_type("Alice", "Person");
557 let g = Graph::anchored(vec![(id, 1.0)], 2);
558 for dialect in [CypherDialect::Legacy, CypherDialect::Full] {
559 let q = g.build_cypher(dialect);
560 assert!(
561 q.cypher.contains("WHERE n.id_hex = sid"),
562 "[{dialect:?}] anchor MUST filter via `WHERE n.id_hex = sid` (Moon ignores \
563 inline-property filters): {}",
564 q.cypher
565 );
566 assert!(
567 !q.cypher.contains("{id_hex: sid}"),
568 "[{dialect:?}] inline-property filter `(n {{id_hex: sid}})` is silently ignored \
569 by Moon — must not be used: {}",
570 q.cypher
571 );
572 assert!(
573 q.cypher.contains("m.id_hex AS id"),
574 "[{dialect:?}] RETURN must select m.id_hex AS id: {}",
575 q.cypher
576 );
577 }
578 }
579
580 #[test]
581 fn build_cypher_legacy_dialect_omits_path_metrics_and_reduce() {
582 // Legacy tier (Moon's ceiling): id/name/type only. No
583 // path binding, no length(), no reduce(), no source_entity_id.
584 // This is the universally-supported template.
585 let id = EntityId::from_name_and_type("Alice", "Person");
586 let g = Graph::anchored(vec![(id, 1.0)], 2);
587 let q = g.build_cypher(CypherDialect::Legacy);
588 assert!(
589 !q.cypher.contains("MATCH p ="),
590 "Legacy MUST NOT bind a path variable (Moon rejects it): {}",
591 q.cypher
592 );
593 assert!(
594 !q.cypher.contains("length("),
595 "Legacy MUST NOT call length() (Moon function table omits it): {}",
596 q.cypher
597 );
598 assert!(
599 !q.cypher.contains("reduce("),
600 "Legacy MUST NOT call reduce() (Moon function table omits it): {}",
601 q.cypher
602 );
603 assert!(
604 !q.cypher.contains("source_entity_id"),
605 "Legacy MUST NOT emit source_entity_id alias: {}",
606 q.cypher
607 );
608 }
609
610 #[test]
611 fn build_cypher_full_dialect_emits_full_wave4_columns() {
612 // Full tier (forward-compat — no backend supports this yet): path
613 // binding + length(p) + source_entity_id + reduce(...) AS
614 // edge_weight_product.
615 let id = EntityId::from_name_and_type("Alice", "Person");
616 let g = Graph::anchored(vec![(id, 1.0)], 2);
617 let q = g.build_cypher(CypherDialect::Full);
618 assert!(q.cypher.contains("MATCH p ="), "Full MUST bind path: {}", q.cypher);
619 assert!(
620 q.cypher.contains("length(p) AS path_length"),
621 "Full MUST emit length(p) AS path_length: {}",
622 q.cypher
623 );
624 assert!(q.cypher.contains("reduce("), "Full MUST emit reduce(...): {}", q.cypher);
625 assert!(
626 q.cypher.contains("edge_weight_product"),
627 "Full MUST emit edge_weight_product alias: {}",
628 q.cypher
629 );
630 assert!(
631 q.cypher.contains("n.id_hex AS source_entity_id"),
632 "Full MUST emit source_entity_id alias: {}",
633 q.cypher
634 );
635 }
636
637 #[test]
638 fn build_cypher_targets_lunaris_graph_by_default() {
639 let id = EntityId::from_name_and_type("Alice", "Person");
640 let g = Graph::anchored(vec![(id, 1.0)], 2);
641 for dialect in [CypherDialect::Legacy, CypherDialect::Full] {
642 let q = g.build_cypher(dialect);
643 assert_eq!(q.graph, "lunaris_graph", "[{dialect:?}] default graph name");
644 }
645 }
646
647 #[test]
648 fn build_cypher_with_custom_graph_name() {
649 // with_graph() lets tenant-isolated deployments scope each tenant to
650 // its own AGE / Moon graph (property holds per-dialect).
651 let id = EntityId::from_name_and_type("Alice", "Person");
652 let g = Graph::anchored(vec![(id, 1.0)], 2).with_graph("tenant_42_graph");
653 for dialect in [CypherDialect::Legacy, CypherDialect::Full] {
654 let q = g.build_cypher(dialect);
655 assert_eq!(q.graph, "tenant_42_graph", "[{dialect:?}] custom graph name");
656 }
657 }
658
659 #[test]
660 fn graph_is_dyn_compatible() {
661 // Compile-time proof Graph constructs as Box<dyn Retriever>; required
662 // for the AndRetriever / FuseRrfRetriever composition path.
663 let _: Box<dyn Retriever> = Box::new(Graph::anchored(no_seeds(), 2));
664 }
665}