Skip to main content

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}