Skip to main content

core_rules/
def.rs

1use core_storage::{list_tokens, Value, ValueKey};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub struct RuleDef {
7    pub name: String,
8    pub src_label: String,
9    pub dst_label: String,
10    pub predicate: Predicate,
11    pub edge_type: String,
12    pub weight_prop: Option<String>,
13    /// Per-rule provenance cap. `None` uses the engine default (`1_000_000`).
14    ///
15    /// APPENDED field. bincode is positional, so this breaks decode of
16    /// `CreateRule` WAL records and snapshot `rule_defs` written before this
17    /// field existed. Pre-alpha no-migration ruling: accepted; no decoder
18    /// compat. `#[serde(default)]` cannot help — bincode does not skip
19    /// missing positional fields.
20    pub max_edges: Option<u64>,
21    /// Opt-in IVF-Flat approximate candidate selection.
22    ///
23    /// `false` (default) → exact `ScanAll` path; semantics and derived edges
24    /// are byte-identical to pre-T4 behaviour for all existing rules.
25    ///
26    /// `true` → `VectorClusters` candidate path: k-means partitions the dst
27    /// (and src) side at backfill/rebuild time; only members of the P nearest
28    /// clusters are evaluated. Recall ≥ 0.90 quiesced, ≥ 0.85 on any
29    /// crash-recovery state — not exact. Only valid when the predicate is
30    /// `VectorSimilar`-rooted (`VectorSimilar` itself, or `All` whose first
31    /// element is `VectorSimilar`); `validate()` rejects other combinations.
32    ///
33    /// APPENDED field — same pre-alpha no-migration ruling as `max_edges`:
34    /// WAL/snapshot records written before this field break positional bincode
35    /// decode. Accepted for pre-1.0 builds; no decoder compat.
36    #[serde(default)]
37    pub approximate: bool,
38    /// Optional intermediate hop. When set, src matches `via_label` via
39    /// `via_edge`, then the existing `predicate` is evaluated between
40    /// **via node** and **dst** (not src and dst). Derived edge is still
41    /// src → dst with `edge_type`.
42    ///
43    /// `via_label` and `via_edge` must both be `Some` or both `None`;
44    /// `validate()` rejects a half-set combination. `via_dir` defaults to
45    /// `Out` when the via fields are set (src → via); supply `Some(In)` to
46    /// reverse the hop (src ← via). Semantics: for each src, expand
47    /// `via_edge` one hop in `via_dir` to via-nodes carrying `via_label`;
48    /// run `predicate` between via and dst; fire src → dst if **any** via
49    /// satisfies; score = max over via; top-k still per src.
50    ///
51    /// APPENDED field — same pre-alpha no-migration ruling as `max_edges`
52    /// and `approximate`: WAL/snapshot records written before this field
53    /// break positional bincode decode. Accepted for pre-1.0 builds; no
54    /// decoder compat. `#[serde(default)]` covers JSON only.
55    #[serde(default)]
56    pub via_label: Option<String>,
57    /// See `via_label`.
58    ///
59    /// APPENDED field — same pre-alpha no-migration ruling as `via_label`.
60    #[serde(default)]
61    pub via_edge: Option<String>,
62    /// Direction to traverse `via_edge` from src. `None` treated as `Out`
63    /// (src → via) at evaluation time. See `via_label`.
64    ///
65    /// APPENDED field — same pre-alpha no-migration ruling as `via_label`.
66    #[serde(default)]
67    pub via_dir: Option<core_storage::Direction>,
68}
69
70/// Score-combination conventions for composed predicates:
71///
72/// - `All(parts)` — **minimum** of the individual branch scores.  Every
73///   branch must match; the weakest link controls the edge weight.
74///   Verified by test `all_takes_min_score_and_requires_every_part`.
75///
76/// - `Any(parts)` — **maximum** of the satisfied branches' scores.  At
77///   least one branch must match; the strongest match controls the edge
78///   weight.  Verified by test `any_score_is_max_when_both_branches_match`.
79///
80/// These conventions are opposites: `All` is pessimistic (min), `Any` is
81/// optimistic (max).  Nesting is allowed up to depth
82/// `MAX_PREDICATE_NESTING_DEPTH`; `validate()` returns a named error beyond
83/// that.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub enum Predicate {
86    KeyMatch {
87        field: String,
88    },
89    FieldEqual {
90        field: String,
91    },
92    Overlap {
93        field: String,
94        min: f64,
95    },
96    All(Vec<Predicate>),
97    // APPENDED (Plan 7) — positional bincode: never reorder.
98    NumericWithin {
99        field: String,
100        tolerance: f64,
101    },
102    GeoRadius {
103        field: String,
104        km: f64,
105    },
106    VectorSimilar {
107        field: String,
108        min: f64,
109    },
110    // APPENDED (Plan 13 T2) — positional bincode: never reorder.
111    /// OR composition: matches when at least one branch matches.
112    /// Score = max over satisfied branches (see doc comment on `Predicate`).
113    Any(Vec<Predicate>),
114}
115
116pub struct NodeView<'a> {
117    pub key: &'a str,
118    pub props: &'a dyn Fn(&str) -> Option<Value>,
119}
120
121impl RuleDef {
122    pub fn validate(&self) -> Result<(), String> {
123        for (what, s) in [
124            ("name", &self.name),
125            ("src_label", &self.src_label),
126            ("dst_label", &self.dst_label),
127            ("edge_type", &self.edge_type),
128        ] {
129            if s.is_empty() {
130                return Err(format!("{what} must not be empty"));
131            }
132        }
133        validate_pred(&self.predicate)?;
134        let depth = predicate_nesting_depth(&self.predicate);
135        if depth > MAX_PREDICATE_NESTING_DEPTH {
136            return Err(format!(
137                "predicate nesting depth {depth} exceeds cap of \
138                 {MAX_PREDICATE_NESTING_DEPTH}"
139            ));
140        }
141        if self.approximate && !predicate_is_vector_similar_rooted(&self.predicate) {
142            return Err(
143                "approximate=true requires a VectorSimilar-rooted predicate \
144                 (VectorSimilar, or All whose first element is VectorSimilar)"
145                    .into(),
146            );
147        }
148        if self.via_label.is_some() && self.approximate {
149            return Err("via-hop rules do not support approximate: true".into());
150        }
151        // via_label and via_edge must both be Some or both None.
152        match (&self.via_label, &self.via_edge) {
153            (Some(_), None) | (None, Some(_)) => {
154                return Err("via_label and via_edge must both be set or both absent".into());
155            }
156            (Some(l), Some(e)) => {
157                if l.is_empty() {
158                    return Err("via_label must not be empty".into());
159                }
160                if e.is_empty() {
161                    return Err("via_edge must not be empty".into());
162                }
163            }
164            (None, None) => {}
165        }
166        Ok(())
167    }
168
169    pub fn watched_fields(&self) -> BTreeSet<String> {
170        let mut out = BTreeSet::new();
171        collect_fields(&self.predicate, &mut out);
172        out
173    }
174}
175
176/// Maximum nesting depth for compound predicates (`All` / `Any`).
177///
178/// Depth is defined as the number of nested compound-predicate layers:
179/// a bare scalar predicate has depth 0; `Any([X, Y])` has depth 1;
180/// `All([Any([X]), Y])` has depth 2; etc.  `validate()` returns a named
181/// error when this cap is exceeded.
182pub const MAX_PREDICATE_NESTING_DEPTH: usize = 4;
183
184/// Per-source top-k when a scored (non-KeyMatch-rooted) rule omits `max_edges`.
185pub const DEFAULT_SCORED_TOP_K: u64 = 32;
186
187/// Per-source top-k when a KeyMatch-rooted rule omits `max_edges`.
188pub const DEFAULT_KEYMATCH_TOP_K: u64 = 1;
189
190/// KeyMatch itself, or `All` whose first element is KeyMatch-rooted.
191/// `Any` is never KeyMatch-rooted — the FK fast-path does not apply to OR.
192pub fn is_keymatch_rooted(p: &Predicate) -> bool {
193    match p {
194        Predicate::KeyMatch { .. } => true,
195        Predicate::All(parts) => !parts.is_empty() && is_keymatch_rooted(&parts[0]),
196        Predicate::Any(_) => false,
197        _ => false,
198    }
199}
200
201/// Default `RuleDef.max_edges` for suggest, auto-FK, demo, and HTTP omit.
202pub fn default_max_edges(predicate: &Predicate) -> u64 {
203    if is_keymatch_rooted(predicate) {
204        DEFAULT_KEYMATCH_TOP_K
205    } else {
206        DEFAULT_SCORED_TOP_K
207    }
208}
209
210/// Returns true when the predicate is `VectorSimilar` itself, or an `All`
211/// whose first element is `VectorSimilar` — the only predicates that may use
212/// the IVF-Flat approximate candidate path (`approximate: true`).
213pub fn predicate_is_vector_similar_rooted(p: &Predicate) -> bool {
214    match p {
215        Predicate::VectorSimilar { .. } => true,
216        Predicate::All(parts) => {
217            !parts.is_empty() && matches!(parts[0], Predicate::VectorSimilar { .. })
218        }
219        Predicate::Any(_) => false,
220        _ => false,
221    }
222}
223
224/// Returns the nesting depth of a predicate tree.
225///
226/// Scalar predicates return 0.  `All` and `Any` return
227/// `1 + max(depths of children)` (0 when empty, which is guarded by
228/// `validate_pred`).
229fn predicate_nesting_depth(p: &Predicate) -> usize {
230    match p {
231        Predicate::All(parts) | Predicate::Any(parts) => {
232            1 + parts.iter().map(predicate_nesting_depth).max().unwrap_or(0)
233        }
234        _ => 0,
235    }
236}
237
238fn validate_pred(p: &Predicate) -> Result<(), String> {
239    match p {
240        Predicate::KeyMatch { field } | Predicate::FieldEqual { field } => {
241            if field.is_empty() {
242                Err("field must not be empty".into())
243            } else {
244                Ok(())
245            }
246        }
247        Predicate::Overlap { field, min } => {
248            if field.is_empty() {
249                Err("field must not be empty".into())
250            } else if !(*min > 0.0 && *min <= 1.0) {
251                Err(format!("overlap min must be in (0,1], got {min}"))
252            } else {
253                Ok(())
254            }
255        }
256        Predicate::NumericWithin { field, tolerance } => {
257            if field.is_empty() {
258                Err("field must not be empty".into())
259            } else if !(tolerance.is_finite() && *tolerance >= 0.0) {
260                Err(format!(
261                    "numeric_within tolerance must be finite and >= 0, got {tolerance}"
262                ))
263            } else {
264                Ok(())
265            }
266        }
267        Predicate::GeoRadius { field, km } => {
268            if field.is_empty() {
269                Err("field must not be empty".into())
270            } else if !(km.is_finite() && *km > 0.0) {
271                Err(format!("geo_radius km must be finite and > 0, got {km}"))
272            } else {
273                Ok(())
274            }
275        }
276        Predicate::VectorSimilar { field, min } => {
277            if field.is_empty() {
278                Err("field must not be empty".into())
279            } else if !(*min > 0.0 && *min <= 1.0) {
280                Err(format!("vector_similar min must be in (0,1], got {min}"))
281            } else {
282                Ok(())
283            }
284        }
285        Predicate::All(parts) => {
286            if parts.is_empty() {
287                return Err("all() must have at least one predicate".into());
288            }
289            parts.iter().try_for_each(validate_pred)
290        }
291        Predicate::Any(parts) => {
292            if parts.is_empty() {
293                return Err("any() must have at least one predicate".into());
294            }
295            parts.iter().try_for_each(validate_pred)
296        }
297    }
298}
299
300fn collect_fields(p: &Predicate, out: &mut BTreeSet<String>) {
301    match p {
302        Predicate::KeyMatch { field }
303        | Predicate::FieldEqual { field }
304        | Predicate::Overlap { field, .. }
305        | Predicate::NumericWithin { field, .. }
306        | Predicate::GeoRadius { field, .. }
307        | Predicate::VectorSimilar { field, .. } => {
308            out.insert(field.clone());
309        }
310        Predicate::All(parts) | Predicate::Any(parts) => {
311            parts.iter().for_each(|q| collect_fields(q, out))
312        }
313    }
314}
315
316pub fn evaluate(pred: &Predicate, src: &NodeView, dst: &NodeView) -> Option<f64> {
317    match pred {
318        Predicate::KeyMatch { field } => match (src.props)(field)? {
319            Value::Str(s) if s == dst.key => Some(1.0),
320            _ => None,
321        },
322        Predicate::FieldEqual { field } => {
323            let a = ValueKey::from_value(&(src.props)(field)?)?;
324            let b = ValueKey::from_value(&(dst.props)(field)?)?;
325            (a == b).then_some(1.0)
326        }
327        Predicate::Overlap { field, min } => {
328            let a = list_tokens(&(src.props)(field)?)?;
329            let b = list_tokens(&(dst.props)(field)?)?;
330            let inter = a.intersection(&b).count();
331            let union = a.union(&b).count();
332            if union == 0 || inter == 0 {
333                return None;
334            }
335            let j = inter as f64 / union as f64;
336            (j >= *min).then_some(j)
337        }
338        Predicate::All(parts) => {
339            // validate() rejects empty All; this is defense-in-depth against skipped validation.
340            if parts.is_empty() {
341                return None;
342            }
343            let mut score = f64::INFINITY;
344            for part in parts {
345                score = score.min(evaluate(part, src, dst)?);
346            }
347            Some(score)
348        }
349        Predicate::Any(parts) => {
350            // validate() rejects empty Any; this is defense-in-depth against skipped validation.
351            // Score = max over satisfied branches (see doc comment on Predicate).
352            // Returns None only when no branch matches.
353            let mut best: Option<f64> = None;
354            for part in parts {
355                if let Some(s) = evaluate(part, src, dst) {
356                    best = Some(match best {
357                        None => s,
358                        Some(prev) => prev.max(s),
359                    });
360                }
361            }
362            best
363        }
364        Predicate::NumericWithin { field, tolerance } => {
365            // Score: tolerance == 0.0 → 1.0 (exact match required), else
366            // 1.0 − |a − b| / tolerance. Boundary Δ = tolerance yields score
367            // 0.0 — a legal 0-weight edge.
368            if !tolerance.is_finite() || *tolerance < 0.0 {
369                return None;
370            }
371            let a = as_finite_f64(&(src.props)(field)?)?;
372            let b = as_finite_f64(&(dst.props)(field)?)?;
373            let delta = (a - b).abs();
374            if *tolerance == 0.0 {
375                return (delta == 0.0).then_some(1.0);
376            }
377            (delta <= *tolerance).then_some(1.0 - delta / *tolerance)
378        }
379        Predicate::GeoRadius { field, km } => {
380            if !km.is_finite() || *km <= 0.0 {
381                return None;
382            }
383            let (alat, alon) = as_latlon(&(src.props)(field)?)?;
384            let (blat, blon) = as_latlon(&(dst.props)(field)?)?;
385            let d = haversine_km(alat, alon, blat, blon);
386            if !d.is_finite() {
387                return None;
388            }
389            (d <= *km).then_some(1.0 - d / *km)
390        }
391        Predicate::VectorSimilar { field, min } => {
392            let a = as_numeric_list(&(src.props)(field)?)?;
393            let b = as_numeric_list(&(dst.props)(field)?)?;
394            if a.len() != b.len() {
395                return None;
396            }
397            let cos = cosine(&a, &b)?.min(1.0);
398            (cos >= *min).then_some(cos)
399        }
400    }
401}
402
403fn as_finite_f64(v: &Value) -> Option<f64> {
404    match v {
405        Value::Int(i) => Some(*i as f64),
406        Value::Float(f) if f.is_finite() => Some(*f),
407        _ => None,
408    }
409}
410
411fn as_latlon(v: &Value) -> Option<(f64, f64)> {
412    let Value::List(items) = v else {
413        return None;
414    };
415    if items.len() != 2 {
416        return None;
417    }
418    let lat = as_finite_f64(&items[0])?;
419    let lon = as_finite_f64(&items[1])?;
420    if (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon) {
421        Some((lat, lon))
422    } else {
423        None
424    }
425}
426
427fn as_numeric_list(v: &Value) -> Option<Vec<f64>> {
428    let Value::List(items) = v else {
429        return None;
430    };
431    if items.is_empty() {
432        return None;
433    }
434    items.iter().map(as_finite_f64).collect()
435}
436
437/// Mean Earth radius (WGS-84 authalic), kilometres.
438const EARTH_RADIUS_KM: f64 = 6371.0088;
439
440fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
441    let phi1 = lat1.to_radians();
442    let phi2 = lat2.to_radians();
443    let dphi = (lat2 - lat1).to_radians();
444    let dlam = (lon2 - lon1).to_radians();
445    let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
446        .clamp(0.0, 1.0);
447    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
448    EARTH_RADIUS_KM * c
449}
450
451fn cosine(a: &[f64], b: &[f64]) -> Option<f64> {
452    let mut dot = 0.0;
453    let mut na2 = 0.0;
454    let mut nb2 = 0.0;
455    for (x, y) in a.iter().zip(b.iter()) {
456        dot += *x * *y;
457        na2 += *x * *x;
458        nb2 += *y * *y;
459    }
460    let na = na2.sqrt();
461    let nb = nb2.sqrt();
462    if !(na > 0.0 && nb > 0.0) {
463        return None;
464    }
465    let cos = dot / (na * nb);
466    cos.is_finite().then_some(cos)
467}
468
469/// Cosine similarity with Cauchy-Schwarz suffix-norm early exit.
470///
471/// Processes `a` and `b` in 8 equal-sized chunks.  After each chunk (except
472/// the last), computes the upper bound:
473///
474/// ```text
475/// cos_max = (dot_so_far + ckpts_a[c+1] × ckpts_b[c+1]) / (norm_a × norm_b)
476/// ```
477///
478/// where `ckpts_x[i]` = L2 norm of `x[i * dim / 8 ..]`.  If `cos_max <
479/// min − eps` (with `eps = dim × f64::EPSILON × 4`), the pair is provably
480/// below threshold and `None` is returned immediately (exact reject in exact
481/// arithmetic; the epsilon guard absorbs IEEE 754 rounding in suffix-norm
482/// accumulation at dim-scale — approximately 3.4 × 10⁻¹³ at dim = 1536).
483/// If all checkpoints pass, the full dot product has been accumulated and the
484/// cosine is returned normally.
485///
486/// # Correctness requirement — checkpoints must be fresh
487///
488/// `ckpts_a`/`ckpts_b` **must be fresh** for the live vectors (see
489/// `SideIndex::fresh_ckpts_for`).  A permuted vector that shares `(dim, norm)`
490/// with the indexed one passes a pure norm-based gate yet carries a different
491/// suffix energy distribution — stale checkpoints can produce a **false
492/// reject** (under-tight suffix bound), violating the exactness invariant.
493///
494/// The real coherence guarantee is structural: checkpoint rebuilds flow
495/// through the same mutation choke-points as `vec_meta` (insert/remove in
496/// `on_node_changed`), making live/cache divergence unreachable in
497/// single-writer operation.  `fresh_ckpts_for`'s dim/norm/anchor comparison
498/// is defense-in-depth — belt-and-suspenders against bugs in those
499/// choke-points, not a standalone proof.
500///
501/// # Arguments
502/// * `norm_a`, `norm_b` — precomputed L2 norms (must match `ckpts_x[0]`).
503/// * `min` — the minimum cosine threshold from the rule definition.
504pub fn cosine_early_exit(
505    a: &[f64],
506    b: &[f64],
507    ckpts_a: &[f64; 8],
508    ckpts_b: &[f64; 8],
509    norm_a: f64,
510    norm_b: f64,
511    min: f64,
512) -> Option<f64> {
513    let dim = a.len();
514    if dim == 0 || dim != b.len() {
515        return None; // dim mismatch or empty: cosine undefined, no edge
516    }
517    let denom = norm_a * norm_b;
518    if !denom.is_finite() || denom == 0.0 {
519        return None;
520    }
521
522    // Epsilon guard: suffix-norm accumulation rounds suffix_sq slightly low,
523    // making cos_max_fl potentially below the true Cauchy-Schwarz bound.  At
524    // dim=1536 the error floor is ~dim × f64::EPSILON ≈ 3.4×10⁻¹³.  4× margin
525    // keeps the guard conservative without meaningfully expanding the pass-through
526    // zone (a few extra evaluate() calls near threshold, never a false reject).
527    let eps = dim as f64 * f64::EPSILON * 4.0;
528    let mut dot = 0.0f64;
529
530    for ci in 0..8usize {
531        let chunk_start = ci * dim / 8;
532        let chunk_end = if ci < 7 { (ci + 1) * dim / 8 } else { dim };
533        for k in chunk_start..chunk_end {
534            dot += a[k] * b[k];
535        }
536        // After processing this chunk (not the last), compute the upper bound
537        // for the remaining suffix using Cauchy-Schwarz.  Guard: bail only
538        // when the bound is below min - eps to absorb float-rounding slack.
539        if ci < 7 {
540            let bound = ckpts_a[ci + 1] * ckpts_b[ci + 1];
541            let cos_max = (dot + bound) / denom;
542            if cos_max.is_finite() && cos_max < min - eps {
543                return None;
544            }
545        }
546    }
547
548    // Full dot product accumulated; return cosine clamped to [−1, 1].
549    let cos = (dot / denom).min(1.0);
550    if cos.is_finite() && cos >= min {
551        Some(cos)
552    } else {
553        None
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use core_storage::Value;
561    use std::collections::HashMap;
562
563    // The brief's `fn view(...)` helper cannot borrow-check: it returns a NodeView
564    // holding &'a dyn Fn referencing a closure that is local to the helper (dropped
565    // on return). Fix: a macro that expands the closure binding at the call site so
566    // the temporary lives for the surrounding statement. All assertions are identical
567    // in meaning to the brief.
568    macro_rules! eval {
569        ($p:expr, ($sk:expr, $sm:ident) => ($dk:expr, $dm:ident)) => {{
570            let sp = |f: &str| $sm.get(f).cloned();
571            let dp = |f: &str| $dm.get(f).cloned();
572            evaluate(
573                $p,
574                &NodeView {
575                    key: $sk,
576                    props: &sp,
577                },
578                &NodeView {
579                    key: $dk,
580                    props: &dp,
581                },
582            )
583        }};
584    }
585
586    #[test]
587    fn key_match_links_fk_to_key() {
588        let s: HashMap<_, _> = [("cid".to_string(), Value::Str("c1".into()))].into();
589        let d: HashMap<String, Value> = HashMap::new();
590        let p = Predicate::KeyMatch {
591            field: "cid".into(),
592        };
593        assert_eq!(eval!(&p, ("t1", s) => ("c1", d)), Some(1.0));
594        assert_eq!(eval!(&p, ("t1", s) => ("c2", d)), None);
595        assert_eq!(eval!(&p, ("t1", d) => ("c1", d)), None); // field absent
596    }
597
598    #[test]
599    fn field_equal_needs_both_scalars_equal() {
600        let a: HashMap<_, _> = [("ind".to_string(), Value::Str("arch".into()))].into();
601        let b = a.clone();
602        let c: HashMap<_, _> = [("ind".to_string(), Value::Str("law".into()))].into();
603        let p = Predicate::FieldEqual {
604            field: "ind".into(),
605        };
606        assert_eq!(eval!(&p, ("a", a) => ("b", b)), Some(1.0));
607        assert_eq!(eval!(&p, ("a", a) => ("c", c)), None);
608    }
609
610    #[test]
611    fn overlap_is_jaccard_with_threshold() {
612        let mk =
613            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
614        let a: HashMap<_, _> = [("tags".to_string(), mk(&["x", "y"]))].into();
615        let b: HashMap<_, _> = [("tags".to_string(), mk(&["y", "z"]))].into();
616        let p = Predicate::Overlap {
617            field: "tags".into(),
618            min: 0.3,
619        };
620        // jaccard = |{y}| / |{x,y,z}| = 1/3
621        let score = eval!(&p, ("a", a) => ("b", b)).unwrap();
622        assert!((score - 1.0 / 3.0).abs() < 1e-9);
623        let strict = Predicate::Overlap {
624            field: "tags".into(),
625            min: 0.5,
626        };
627        assert_eq!(eval!(&strict, ("a", a) => ("b", b)), None);
628        // empty-vs-anything never matches (union empty or intersection empty)
629        let e: HashMap<_, _> = [("tags".to_string(), mk(&[]))].into();
630        assert_eq!(eval!(&p, ("a", e) => ("b", b)), None);
631    }
632
633    #[test]
634    fn all_takes_min_score_and_requires_every_part() {
635        let mk =
636            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
637        let a: HashMap<_, _> = [
638            ("ind".to_string(), Value::Str("arch".into())),
639            ("tags".to_string(), mk(&["x", "y"])),
640        ]
641        .into();
642        let b: HashMap<_, _> = [
643            ("ind".to_string(), Value::Str("arch".into())),
644            ("tags".to_string(), mk(&["y"])),
645        ]
646        .into();
647        let p = Predicate::All(vec![
648            Predicate::FieldEqual {
649                field: "ind".into(),
650            },
651            Predicate::Overlap {
652                field: "tags".into(),
653                min: 0.4,
654            },
655        ]);
656        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
657        assert!((s - 0.5).abs() < 1e-9); // min(1.0, 0.5)
658    }
659
660    #[test]
661    fn validation_rejects_bad_rules_and_collects_watched_fields() {
662        let ok = RuleDef {
663            name: "r".into(),
664            src_label: "A".into(),
665            dst_label: "B".into(),
666            predicate: Predicate::All(vec![
667                Predicate::KeyMatch { field: "fk".into() },
668                Predicate::Overlap {
669                    field: "tags".into(),
670                    min: 0.5,
671                },
672            ]),
673            edge_type: "E".into(),
674            weight_prop: Some("score".into()),
675            max_edges: None,
676            approximate: false,
677            via_label: None,
678            via_edge: None,
679            via_dir: None,
680        };
681        assert!(ok.validate().is_ok());
682        assert_eq!(
683            ok.watched_fields().into_iter().collect::<Vec<_>>(),
684            vec!["fk".to_string(), "tags".to_string()]
685        );
686        let mut bad = ok.clone();
687        bad.predicate = Predicate::Overlap {
688            field: "t".into(),
689            min: 0.0,
690        };
691        assert!(bad.validate().is_err()); // min must be in (0,1]
692        let mut bad2 = ok.clone();
693        bad2.edge_type = String::new();
694        assert!(bad2.validate().is_err());
695        let mut bad3 = ok;
696        bad3.predicate = Predicate::All(vec![]);
697        assert!(bad3.validate().is_err());
698    }
699
700    #[test]
701    fn evaluate_empty_all_returns_none() {
702        let empty: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
703        let sp = |f: &str| empty.get(f).cloned();
704        let dp = |f: &str| empty.get(f).cloned();
705        let src = NodeView {
706            key: "a",
707            props: &sp,
708        };
709        let dst = NodeView {
710            key: "b",
711            props: &dp,
712        };
713        assert_eq!(evaluate(&Predicate::All(vec![]), &src, &dst), None);
714    }
715
716    #[test]
717    fn numeric_within_int_float_cross_type() {
718        let a: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
719        let b: HashMap<_, _> = [("year".to_string(), Value::Float(2000.0))].into();
720        let tight = Predicate::NumericWithin {
721            field: "year".into(),
722            tolerance: 2.0,
723        };
724        // |1998 − 2000| = 2; Δ = tolerance → score 0.0 (legal 0-weight edge)
725        assert_eq!(eval!(&tight, ("a", a) => ("b", b)), Some(0.0));
726        let loose = Predicate::NumericWithin {
727            field: "year".into(),
728            tolerance: 3.0,
729        };
730        let score = eval!(&loose, ("a", a) => ("b", b)).unwrap();
731        assert!((score - 1.0 / 3.0).abs() < 1e-9);
732    }
733
734    #[test]
735    fn numeric_within_missing_or_non_numeric_is_none() {
736        let num: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
737        let missing: HashMap<String, Value> = HashMap::new();
738        let text: HashMap<_, _> = [("year".to_string(), Value::Str("1998".into()))].into();
739        let p = Predicate::NumericWithin {
740            field: "year".into(),
741            tolerance: 2.0,
742        };
743        assert_eq!(eval!(&p, ("a", num) => ("b", missing)), None);
744        assert_eq!(eval!(&p, ("a", missing) => ("b", num)), None);
745        assert_eq!(eval!(&p, ("a", num) => ("b", text)), None);
746    }
747
748    #[test]
749    fn numeric_within_tol_zero_requires_exact() {
750        let a: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
751        let same: HashMap<_, _> = [("year".to_string(), Value::Float(1998.0))].into();
752        let other: HashMap<_, _> = [("year".to_string(), Value::Int(1999))].into();
753        let p = Predicate::NumericWithin {
754            field: "year".into(),
755            tolerance: 0.0,
756        };
757        assert_eq!(eval!(&p, ("a", a) => ("b", same)), Some(1.0));
758        assert_eq!(eval!(&p, ("a", a) => ("b", other)), None);
759    }
760
761    #[test]
762    fn numeric_within_non_finite_is_none() {
763        let a: HashMap<_, _> = [("year".to_string(), Value::Float(f64::NAN))].into();
764        let b: HashMap<_, _> = [("year".to_string(), Value::Float(1.0))].into();
765        let inf: HashMap<_, _> = [("year".to_string(), Value::Float(f64::INFINITY))].into();
766        let p = Predicate::NumericWithin {
767            field: "year".into(),
768            tolerance: 2.0,
769        };
770        assert_eq!(eval!(&p, ("a", a) => ("b", b)), None);
771        assert_eq!(eval!(&p, ("a", inf) => ("b", b)), None);
772    }
773
774    fn geo_pair(
775        src: (f64, f64),
776        dst: (f64, f64),
777    ) -> (HashMap<String, Value>, HashMap<String, Value>) {
778        let mk = |lat: f64, lon: f64| {
779            let mut m = HashMap::new();
780            m.insert(
781                "loc".to_string(),
782                Value::List(vec![Value::Float(lat), Value::Float(lon)]),
783            );
784            m
785        };
786        (mk(src.0, src.1), mk(dst.0, dst.1))
787    }
788
789    #[test]
790    fn geo_radius_paris_london() {
791        // Paris (48.8566, 2.3522) ↔ London (51.5074, −0.1278) ≈ 343.5 km
792        let (paris, london) = geo_pair((48.8566, 2.3522), (51.5074, -0.1278));
793        let inside = Predicate::GeoRadius {
794            field: "loc".into(),
795            km: 400.0,
796        };
797        let score = eval!(&inside, ("p", paris) => ("l", london)).unwrap();
798        // 1 − 343.5/400 = 0.14125; ±0.001 pins haversine to ~±0.4 km
799        assert!((score - 0.14125).abs() < 0.001);
800        let outside = Predicate::GeoRadius {
801            field: "loc".into(),
802            km: 300.0,
803        };
804        assert_eq!(eval!(&outside, ("p", paris) => ("l", london)), None);
805    }
806
807    #[test]
808    fn geo_radius_identical_coordinates_score_one() {
809        let (a, b) = geo_pair((48.8566, 2.3522), (48.8566, 2.3522));
810        let p = Predicate::GeoRadius {
811            field: "loc".into(),
812            km: 400.0,
813        };
814        assert_eq!(eval!(&p, ("a", a) => ("b", b)), Some(1.0));
815    }
816
817    #[test]
818    fn geo_radius_malformed_is_none() {
819        let paris: HashMap<_, _> = [(
820            "loc".to_string(),
821            Value::List(vec![Value::Float(48.8566), Value::Float(2.3522)]),
822        )]
823        .into();
824        let one: HashMap<_, _> =
825            [("loc".to_string(), Value::List(vec![Value::Float(48.8566)]))].into();
826        let three: HashMap<_, _> = [(
827            "loc".to_string(),
828            Value::List(vec![
829                Value::Float(48.8566),
830                Value::Float(2.3522),
831                Value::Float(0.0),
832            ]),
833        )]
834        .into();
835        let string_el: HashMap<_, _> = [(
836            "loc".to_string(),
837            Value::List(vec![Value::Str("48.8566".into()), Value::Float(2.3522)]),
838        )]
839        .into();
840        let lat91: HashMap<_, _> = [(
841            "loc".to_string(),
842            Value::List(vec![Value::Float(91.0), Value::Float(0.0)]),
843        )]
844        .into();
845        let p = Predicate::GeoRadius {
846            field: "loc".into(),
847            km: 400.0,
848        };
849        assert_eq!(eval!(&p, ("a", paris) => ("b", one)), None);
850        assert_eq!(eval!(&p, ("a", paris) => ("b", three)), None);
851        assert_eq!(eval!(&p, ("a", paris) => ("b", string_el)), None);
852        assert_eq!(eval!(&p, ("a", paris) => ("b", lat91)), None);
853    }
854
855    fn vec_field(vals: &[f64]) -> HashMap<String, Value> {
856        [(
857            "emb".to_string(),
858            Value::List(vals.iter().copied().map(Value::Float).collect()),
859        )]
860        .into()
861    }
862
863    #[test]
864    fn vector_similar_cosine_and_rejects() {
865        let a = vec_field(&[1.0, 0.0]);
866        let same = vec_field(&[1.0, 0.0]);
867        let ortho = vec_field(&[0.0, 1.0]);
868        let p = Predicate::VectorSimilar {
869            field: "emb".into(),
870            min: 0.5,
871        };
872        assert_eq!(eval!(&p, ("a", a) => ("b", same)), Some(1.0));
873        assert_eq!(eval!(&p, ("a", a) => ("b", ortho)), None); // cos 0 < min
874
875        let u = vec_field(&[1.0, 2.0]);
876        let scaled = vec_field(&[2.0, 4.0]);
877        let score = eval!(&p, ("a", u) => ("b", scaled)).unwrap();
878        assert!((1.0 - score).abs() < 1e-9); // parallel → 1.0 − ε
879
880        let dim3 = vec_field(&[1.0, 0.0, 0.0]);
881        assert_eq!(eval!(&p, ("a", a) => ("b", dim3)), None);
882        let zero = vec_field(&[0.0, 0.0]);
883        assert_eq!(eval!(&p, ("a", a) => ("b", zero)), None);
884    }
885
886    #[test]
887    fn approximate_only_valid_with_vector_similar_rooted_predicate() {
888        // approximate=true + VectorSimilar → valid
889        let ok_vec = RuleDef {
890            name: "av".into(),
891            src_label: "V".into(),
892            dst_label: "V".into(),
893            predicate: Predicate::VectorSimilar {
894                field: "emb".into(),
895                min: 0.9,
896            },
897            edge_type: "VEC".into(),
898            weight_prop: None,
899            max_edges: None,
900            approximate: true,
901            via_label: None,
902            via_edge: None,
903            via_dir: None,
904        };
905        assert!(ok_vec.validate().is_ok());
906
907        // approximate=true + All(VectorSimilar, ...) → valid
908        let ok_all = RuleDef {
909            name: "av2".into(),
910            src_label: "V".into(),
911            dst_label: "V".into(),
912            predicate: Predicate::All(vec![
913                Predicate::VectorSimilar {
914                    field: "emb".into(),
915                    min: 0.9,
916                },
917                Predicate::FieldEqual {
918                    field: "kind".into(),
919                },
920            ]),
921            edge_type: "VEC2".into(),
922            weight_prop: None,
923            max_edges: None,
924            approximate: true,
925            via_label: None,
926            via_edge: None,
927            via_dir: None,
928        };
929        assert!(ok_all.validate().is_ok());
930
931        // approximate=true + FieldEqual → invalid
932        let bad_fe = RuleDef {
933            name: "bfe".into(),
934            src_label: "A".into(),
935            dst_label: "A".into(),
936            predicate: Predicate::FieldEqual { field: "f".into() },
937            edge_type: "FE".into(),
938            weight_prop: None,
939            max_edges: None,
940            approximate: true,
941            via_label: None,
942            via_edge: None,
943            via_dir: None,
944        };
945        assert!(bad_fe.validate().is_err());
946
947        // approximate=true + Overlap → invalid
948        let bad_ov = RuleDef {
949            name: "bov".into(),
950            src_label: "A".into(),
951            dst_label: "A".into(),
952            predicate: Predicate::Overlap {
953                field: "tags".into(),
954                min: 0.5,
955            },
956            edge_type: "OV".into(),
957            weight_prop: None,
958            max_edges: None,
959            approximate: true,
960            via_label: None,
961            via_edge: None,
962            via_dir: None,
963        };
964        assert!(bad_ov.validate().is_err());
965
966        // approximate=true + All(FieldEqual, VectorSimilar) → invalid (first part is not VectorSimilar)
967        let bad_all_order = RuleDef {
968            name: "bao".into(),
969            src_label: "A".into(),
970            dst_label: "A".into(),
971            predicate: Predicate::All(vec![
972                Predicate::FieldEqual { field: "f".into() },
973                Predicate::VectorSimilar {
974                    field: "emb".into(),
975                    min: 0.9,
976                },
977            ]),
978            edge_type: "E".into(),
979            weight_prop: None,
980            max_edges: None,
981            approximate: true,
982            via_label: None,
983            via_edge: None,
984            via_dir: None,
985        };
986        assert!(bad_all_order.validate().is_err());
987    }
988
989    #[test]
990    fn validate_rejects_via_with_approximate() {
991        // via_label set + approximate=true → invalid (via bypasses HNSW entirely)
992        let bad = RuleDef {
993            name: "vbad".into(),
994            src_label: "A".into(),
995            dst_label: "B".into(),
996            predicate: Predicate::VectorSimilar {
997                field: "emb".into(),
998                min: 0.9,
999            },
1000            edge_type: "VEC".into(),
1001            weight_prop: None,
1002            max_edges: None,
1003            approximate: true,
1004            via_label: Some("Mid".into()),
1005            via_edge: Some("hop".into()),
1006            via_dir: None,
1007        };
1008        let err = bad.validate().unwrap_err();
1009        assert_eq!(err, "via-hop rules do not support approximate: true");
1010
1011        // via_label set + approximate=false → still valid (only the via+approx combo is banned)
1012        let ok = RuleDef {
1013            approximate: false,
1014            ..bad.clone()
1015        };
1016        assert!(ok.validate().is_ok());
1017    }
1018
1019    #[test]
1020    fn all_composes_field_equal_and_numeric_within() {
1021        let a: HashMap<_, _> = [
1022            ("ind".to_string(), Value::Str("arch".into())),
1023            ("year".to_string(), Value::Int(1998)),
1024        ]
1025        .into();
1026        let b: HashMap<_, _> = [
1027            ("ind".to_string(), Value::Str("arch".into())),
1028            ("year".to_string(), Value::Float(2000.0)),
1029        ]
1030        .into();
1031        let p = Predicate::All(vec![
1032            Predicate::FieldEqual {
1033                field: "ind".into(),
1034            },
1035            Predicate::NumericWithin {
1036                field: "year".into(),
1037                tolerance: 3.0,
1038            },
1039        ]);
1040        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1041        assert!((s - 1.0 / 3.0).abs() < 1e-9); // min(1.0, 1/3)
1042    }
1043
1044    fn sample_rule(pred: Predicate) -> RuleDef {
1045        RuleDef {
1046            name: "r".into(),
1047            src_label: "A".into(),
1048            dst_label: "B".into(),
1049            predicate: pred,
1050            edge_type: "E".into(),
1051            weight_prop: None,
1052            max_edges: None,
1053            approximate: false,
1054            via_label: None,
1055            via_edge: None,
1056            via_dir: None,
1057        }
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // Any predicate tests (TDD — written before implementation)
1062    // -----------------------------------------------------------------------
1063
1064    /// Score = max over satisfied branches; None only when all branches fail.
1065    #[test]
1066    fn any_takes_max_score_and_requires_at_least_one_branch() {
1067        let mk =
1068            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
1069        // src: ind="arch", tags=["x","y"]; dst: ind="law", tags=["y","z"]
1070        // Branch A: FieldEqual(ind) → None  (arch ≠ law)
1071        // Branch B: Overlap(tags, 0.3) → jaccard = 1/3 ≥ 0.3 → Some(1/3)
1072        // Any → Some(max(_, 1/3)) = Some(1/3)
1073        let a: HashMap<_, _> = [
1074            ("ind".to_string(), Value::Str("arch".into())),
1075            ("tags".to_string(), mk(&["x", "y"])),
1076        ]
1077        .into();
1078        let b: HashMap<_, _> = [
1079            ("ind".to_string(), Value::Str("law".into())),
1080            ("tags".to_string(), mk(&["y", "z"])),
1081        ]
1082        .into();
1083        let p = Predicate::Any(vec![
1084            Predicate::FieldEqual {
1085                field: "ind".into(),
1086            },
1087            Predicate::Overlap {
1088                field: "tags".into(),
1089                min: 0.3,
1090            },
1091        ]);
1092        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1093        assert!(
1094            (s - 1.0 / 3.0).abs() < 1e-9,
1095            "score must be max(None, 1/3) = 1/3, got {s}"
1096        );
1097    }
1098
1099    /// When both branches match, Any returns the larger score.
1100    #[test]
1101    fn any_score_is_max_when_both_branches_match() {
1102        // src: ind="arch", year=2000; dst: ind="arch", year=2001
1103        // Branch A: FieldEqual(ind) → Some(1.0)
1104        // Branch B: NumericWithin(year, tol=3) → 1 - 1/3 = 2/3 → Some(2/3)
1105        // Any → Some(max(1.0, 2/3)) = Some(1.0)
1106        let a: HashMap<_, _> = [
1107            ("ind".to_string(), Value::Str("arch".into())),
1108            ("year".to_string(), Value::Int(2000)),
1109        ]
1110        .into();
1111        let b: HashMap<_, _> = [
1112            ("ind".to_string(), Value::Str("arch".into())),
1113            ("year".to_string(), Value::Float(2001.0)),
1114        ]
1115        .into();
1116        let p = Predicate::Any(vec![
1117            Predicate::FieldEqual {
1118                field: "ind".into(),
1119            },
1120            Predicate::NumericWithin {
1121                field: "year".into(),
1122                tolerance: 3.0,
1123            },
1124        ]);
1125        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1126        assert!(
1127            (s - 1.0).abs() < 1e-9,
1128            "score must be max(1.0, 2/3) = 1.0, got {s}"
1129        );
1130    }
1131
1132    /// None when all branches fail.
1133    #[test]
1134    fn any_returns_none_when_all_branches_fail() {
1135        let a: HashMap<_, _> = [("ind".to_string(), Value::Str("arch".into()))].into();
1136        let b: HashMap<_, _> = [("ind".to_string(), Value::Str("law".into()))].into();
1137        let p = Predicate::Any(vec![
1138            Predicate::FieldEqual {
1139                field: "ind".into(),
1140            },
1141            Predicate::FieldEqual {
1142                field: "ind".into(),
1143            },
1144        ]);
1145        assert_eq!(eval!(&p, ("a", a) => ("b", b)), None);
1146    }
1147
1148    /// Nested All(FieldEqual, Any(Overlap, NumericWithin)).
1149    /// All uses min; Any uses max.  Combined: min(1.0, max(1/3, 2/3)) = 2/3.
1150    #[test]
1151    fn nested_all_of_any_uses_min_over_max() {
1152        let mk =
1153            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
1154        // src: ind="arch", tags=["x","y"], year=2000
1155        // dst: ind="arch", tags=["y","z"], year=2001
1156        // FieldEqual(ind)             → Some(1.0)
1157        // Overlap(tags, 0.3)          → jaccard=1/3 → Some(1/3)
1158        // NumericWithin(year, tol=3)  → 1 - 1/3 = 2/3 → Some(2/3)
1159        // Any(Overlap, Numeric)       → max(1/3, 2/3) = 2/3
1160        // All(FieldEqual, Any(...))   → min(1.0, 2/3) = 2/3
1161        let a: HashMap<_, _> = [
1162            ("ind".to_string(), Value::Str("arch".into())),
1163            ("tags".to_string(), mk(&["x", "y"])),
1164            ("year".to_string(), Value::Int(2000)),
1165        ]
1166        .into();
1167        let b: HashMap<_, _> = [
1168            ("ind".to_string(), Value::Str("arch".into())),
1169            ("tags".to_string(), mk(&["y", "z"])),
1170            ("year".to_string(), Value::Float(2001.0)),
1171        ]
1172        .into();
1173        let p = Predicate::All(vec![
1174            Predicate::FieldEqual {
1175                field: "ind".into(),
1176            },
1177            Predicate::Any(vec![
1178                Predicate::Overlap {
1179                    field: "tags".into(),
1180                    min: 0.3,
1181                },
1182                Predicate::NumericWithin {
1183                    field: "year".into(),
1184                    tolerance: 3.0,
1185                },
1186            ]),
1187        ]);
1188        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1189        assert!(
1190            (s - 2.0 / 3.0).abs() < 1e-9,
1191            "expected min(1.0, max(1/3, 2/3)) = 2/3, got {s}"
1192        );
1193    }
1194
1195    /// Any([All([X, Y]), Z]) — score = max(min(X, Y), Z).
1196    /// Tests two scenarios: one where the All branch wins, one where Z wins.
1197    #[test]
1198    fn nested_any_of_all_uses_max_over_min() {
1199        // Predicate: Any([All([FieldEqual(gen), NumericWithin(yr, 4)]), NumericWithin(yr2, 10)])
1200        let p = Predicate::Any(vec![
1201            Predicate::All(vec![
1202                Predicate::FieldEqual {
1203                    field: "gen".into(),
1204                },
1205                Predicate::NumericWithin {
1206                    field: "yr".into(),
1207                    tolerance: 4.0,
1208                },
1209            ]),
1210            Predicate::NumericWithin {
1211                field: "yr2".into(),
1212                tolerance: 10.0,
1213            },
1214        ]);
1215
1216        // Scenario A: All branch wins (0.75 > 0.5).
1217        // gen match → FieldEqual = 1.0
1218        // yr diff = 1, tol = 4  → score = 1 − 1/4 = 0.75
1219        // All = min(1.0, 0.75) = 0.75
1220        // yr2 diff = 5, tol = 10 → score = 1 − 5/10 = 0.5
1221        // Any = max(0.75, 0.5) = 0.75
1222        let a: HashMap<_, _> = [
1223            ("gen".to_string(), Value::Str("pop".into())),
1224            ("yr".to_string(), Value::Int(2000)),
1225            ("yr2".to_string(), Value::Int(2000)),
1226        ]
1227        .into();
1228        let b: HashMap<_, _> = [
1229            ("gen".to_string(), Value::Str("pop".into())),
1230            ("yr".to_string(), Value::Float(2001.0)),
1231            ("yr2".to_string(), Value::Float(2005.0)),
1232        ]
1233        .into();
1234        let s_a = eval!(&p, ("a", a) => ("b", b)).unwrap();
1235        assert!(
1236            (s_a - 0.75).abs() < 1e-9,
1237            "Any-of-All scenario A: max(min(1.0,0.75), 0.5) must be 0.75, got {s_a}"
1238        );
1239
1240        // Scenario B: Z branch wins (All = None because gen differs).
1241        // gen mismatch → FieldEqual = None → All = None
1242        // yr2 diff = 1, tol = 10 → score = 1 − 1/10 = 0.9
1243        // Any = max(None, 0.9) = 0.9
1244        let a2: HashMap<_, _> = [
1245            ("gen".to_string(), Value::Str("pop".into())),
1246            ("yr".to_string(), Value::Int(2000)),
1247            ("yr2".to_string(), Value::Int(2000)),
1248        ]
1249        .into();
1250        let c: HashMap<_, _> = [
1251            ("gen".to_string(), Value::Str("rock".into())),
1252            ("yr".to_string(), Value::Float(2001.0)),
1253            ("yr2".to_string(), Value::Float(2001.0)),
1254        ]
1255        .into();
1256        let s_b = eval!(&p, ("a", a2) => ("c", c)).unwrap();
1257        assert!(
1258            (s_b - 0.9).abs() < 1e-9,
1259            "Any-of-All scenario B: max(None, 0.9) must be 0.9, got {s_b}"
1260        );
1261    }
1262
1263    /// validate() rejects empty Any; depth cap 4 is enforced with a named error.
1264    #[test]
1265    fn any_validation_errors() {
1266        // Empty Any → named error (pinned text)
1267        let empty = sample_rule(Predicate::Any(vec![]));
1268        let err = empty.validate().unwrap_err();
1269        assert_eq!(err, "any() must have at least one predicate");
1270
1271        // Helper: build a singly-nested Any chain of the given depth.
1272        fn any_chain(depth: usize) -> Predicate {
1273            if depth == 0 {
1274                Predicate::FieldEqual { field: "f".into() }
1275            } else {
1276                Predicate::Any(vec![any_chain(depth - 1)])
1277            }
1278        }
1279
1280        // depth 4 = cap → valid
1281        assert!(
1282            sample_rule(any_chain(4)).validate().is_ok(),
1283            "depth 4 must be valid (at cap)"
1284        );
1285        // depth 5 > cap → named error
1286        let too_deep = sample_rule(any_chain(5));
1287        let err = too_deep.validate().unwrap_err();
1288        assert!(
1289            err.contains("nesting depth"),
1290            "error must mention 'nesting depth', got: {err}"
1291        );
1292
1293        // Any containing empty All → error propagated from inner validate_pred
1294        let bad_inner = sample_rule(Predicate::Any(vec![Predicate::All(vec![])]));
1295        assert!(bad_inner.validate().is_err());
1296    }
1297
1298    /// watched_fields collects fields from all branches of Any.
1299    #[test]
1300    fn any_watched_fields_collected() {
1301        let p = Predicate::Any(vec![
1302            Predicate::FieldEqual {
1303                field: "ind".into(),
1304            },
1305            Predicate::NumericWithin {
1306                field: "year".into(),
1307                tolerance: 1.0,
1308            },
1309        ]);
1310        let r = sample_rule(p);
1311        assert!(r.validate().is_ok());
1312        let fields: Vec<_> = r.watched_fields().into_iter().collect();
1313        assert_eq!(fields, vec!["ind".to_string(), "year".to_string()]);
1314    }
1315
1316    #[test]
1317    fn new_predicates_validate_and_watch_fields() {
1318        let num = sample_rule(Predicate::NumericWithin {
1319            field: "year".into(),
1320            tolerance: 2.0,
1321        });
1322        assert!(num.validate().is_ok());
1323        assert_eq!(
1324            num.watched_fields().into_iter().collect::<Vec<_>>(),
1325            vec!["year".to_string()]
1326        );
1327        let geo = sample_rule(Predicate::GeoRadius {
1328            field: "loc".into(),
1329            km: 400.0,
1330        });
1331        assert!(geo.validate().is_ok());
1332        let vecp = sample_rule(Predicate::VectorSimilar {
1333            field: "emb".into(),
1334            min: 0.9,
1335        });
1336        assert!(vecp.validate().is_ok());
1337
1338        let mut bad = num.clone();
1339        bad.predicate = Predicate::NumericWithin {
1340            field: "year".into(),
1341            tolerance: -1.0,
1342        };
1343        assert!(bad.validate().is_err());
1344        bad.predicate = Predicate::NumericWithin {
1345            field: "year".into(),
1346            tolerance: f64::NAN,
1347        };
1348        assert!(bad.validate().is_err());
1349
1350        let mut bad_geo = geo;
1351        bad_geo.predicate = Predicate::GeoRadius {
1352            field: "loc".into(),
1353            km: 0.0,
1354        };
1355        assert!(bad_geo.validate().is_err());
1356        bad_geo.predicate = Predicate::GeoRadius {
1357            field: "loc".into(),
1358            km: f64::NAN,
1359        };
1360        assert!(bad_geo.validate().is_err());
1361
1362        let mut bad_vec = vecp;
1363        bad_vec.predicate = Predicate::VectorSimilar {
1364            field: "emb".into(),
1365            min: 0.0,
1366        };
1367        assert!(bad_vec.validate().is_err());
1368        bad_vec.predicate = Predicate::VectorSimilar {
1369            field: "emb".into(),
1370            min: 1.5,
1371        };
1372        assert!(bad_vec.validate().is_err());
1373    }
1374
1375    #[test]
1376    fn default_max_edges_keymatch_is_1_else_32() {
1377        assert_eq!(DEFAULT_SCORED_TOP_K, 32);
1378        assert_eq!(DEFAULT_KEYMATCH_TOP_K, 1);
1379        assert_eq!(
1380            default_max_edges(&Predicate::KeyMatch { field: "fk".into() }),
1381            DEFAULT_KEYMATCH_TOP_K
1382        );
1383        assert_eq!(
1384            default_max_edges(&Predicate::All(vec![Predicate::KeyMatch {
1385                field: "fk".into()
1386            }])),
1387            DEFAULT_KEYMATCH_TOP_K
1388        );
1389        assert_eq!(
1390            default_max_edges(&Predicate::All(vec![Predicate::All(vec![
1391                Predicate::KeyMatch { field: "fk".into() }
1392            ])])),
1393            DEFAULT_KEYMATCH_TOP_K
1394        );
1395        assert_eq!(
1396            default_max_edges(&Predicate::Overlap {
1397                field: "tags".into(),
1398                min: 0.5,
1399            }),
1400            DEFAULT_SCORED_TOP_K
1401        );
1402        assert_eq!(
1403            default_max_edges(&Predicate::Any(vec![Predicate::KeyMatch {
1404                field: "fk".into()
1405            }])),
1406            DEFAULT_SCORED_TOP_K
1407        );
1408        assert!(!is_keymatch_rooted(&Predicate::FieldEqual {
1409            field: "f".into()
1410        }));
1411    }
1412}
1413
1414#[cfg(test)]
1415mod wire_pins {
1416    use super::*;
1417
1418    fn pin(pred: Predicate) -> RuleDef {
1419        RuleDef {
1420            name: "r".into(),
1421            src_label: "A".into(),
1422            dst_label: "B".into(),
1423            predicate: pred,
1424            edge_type: "E".into(),
1425            weight_prop: None,
1426            max_edges: None,
1427            approximate: false,
1428            via_label: None,
1429            via_edge: None,
1430            via_dir: None,
1431        }
1432    }
1433
1434    fn pin_approx(pred: Predicate) -> RuleDef {
1435        RuleDef {
1436            name: "r".into(),
1437            src_label: "A".into(),
1438            dst_label: "B".into(),
1439            predicate: pred,
1440            edge_type: "E".into(),
1441            weight_prop: None,
1442            max_edges: None,
1443            approximate: true,
1444            via_label: None,
1445            via_edge: None,
1446            via_dir: None,
1447        }
1448    }
1449
1450    #[test]
1451    fn old_predicate_variants_keep_encoding() {
1452        // Captured before Plan 7 appends. Discriminants 0..=3 must not move.
1453        // Plan 11 T4: `approximate: false` appends one zero byte at the end
1454        // of every existing record. Plan 14 T2: `via_label`, `via_edge`,
1455        // `via_dir` (all `None`) append three more zero bytes. Old WAL records
1456        // written before these fields break positional bincode decode —
1457        // pre-alpha no-migration ruling.
1458        assert_eq!(
1459            bincode::serialize(&pin(Predicate::KeyMatch { field: "fk".into() })).unwrap(),
1460            vec![
1461                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1462                66, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 102, 107, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0,
1463                0, 0, 0, 0
1464            ]
1465        );
1466        assert_eq!(
1467            bincode::serialize(&pin(Predicate::FieldEqual {
1468                field: "ind".into()
1469            }))
1470            .unwrap(),
1471            vec![
1472                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1473                66, 1, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 105, 110, 100, 1, 0, 0, 0, 0, 0, 0, 0, 69,
1474                0, 0, 0, 0, 0, 0
1475            ]
1476        );
1477        assert_eq!(
1478            bincode::serialize(&pin(Predicate::Overlap {
1479                field: "tags".into(),
1480                min: 0.5,
1481            }))
1482            .unwrap(),
1483            vec![
1484                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1485                66, 2, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 116, 97, 103, 115, 0, 0, 0, 0, 0, 0, 224,
1486                63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0
1487            ]
1488        );
1489        assert_eq!(
1490            bincode::serialize(&pin(Predicate::All(vec![
1491                Predicate::KeyMatch { field: "fk".into() },
1492                Predicate::Overlap {
1493                    field: "tags".into(),
1494                    min: 0.5,
1495                },
1496            ])))
1497            .unwrap(),
1498            vec![
1499                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1500                66, 3, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 102,
1501                107, 2, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 116, 97, 103, 115, 0, 0, 0, 0, 0, 0, 224,
1502                63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0
1503            ]
1504        );
1505    }
1506
1507    #[test]
1508    fn new_predicate_variants_have_pinned_encoding() {
1509        // Trailing `0, 0, 0` = via_label/via_edge/via_dir all None (Plan 14 T2).
1510        assert_eq!(
1511            bincode::serialize(&pin(Predicate::NumericWithin {
1512                field: "year".into(),
1513                tolerance: 2.0,
1514            }))
1515            .unwrap(),
1516            vec![
1517                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1518                66, 4, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 121, 101, 97, 114, 0, 0, 0, 0, 0, 0, 0, 64,
1519                1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0
1520            ]
1521        );
1522        assert_eq!(
1523            bincode::serialize(&pin(Predicate::GeoRadius {
1524                field: "loc".into(),
1525                km: 400.0,
1526            }))
1527            .unwrap(),
1528            vec![
1529                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1530                66, 5, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 108, 111, 99, 0, 0, 0, 0, 0, 0, 121, 64, 1,
1531                0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0
1532            ]
1533        );
1534        assert_eq!(
1535            bincode::serialize(&pin(Predicate::VectorSimilar {
1536                field: "emb".into(),
1537                min: 0.9,
1538            }))
1539            .unwrap(),
1540            vec![
1541                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1542                66, 6, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 101, 109, 98, 205, 204, 204, 204, 204, 204,
1543                236, 63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0
1544            ]
1545        );
1546    }
1547
1548    #[test]
1549    fn any_variant_is_appended_at_discriminant_7() {
1550        // Any is discriminant 7 (appended after VectorSimilar=6).
1551        // Old WAL/snapshot records never contain discriminant 7, so old data
1552        // still round-trips via the existing variants 0–6.
1553        //
1554        // Exact-bytes pin for Any([FieldEqual{field:"f"}]) via pin():
1555        //   name "r"        → [1,0,0,0,0,0,0,0, 114]
1556        //   src_label "A"   → [1,0,0,0,0,0,0,0, 65]
1557        //   dst_label "B"   → [1,0,0,0,0,0,0,0, 66]
1558        //   disc 7 (Any)    → [7,0,0,0]
1559        //   vec len 1       → [1,0,0,0,0,0,0,0]
1560        //   disc 1 (FE)     → [1,0,0,0]
1561        //   field "f"       → [1,0,0,0,0,0,0,0, 102]
1562        //   edge_type "E"   → [1,0,0,0,0,0,0,0, 69]
1563        //   weight/edges/approx → [0,0,0]
1564        //   via_label/via_edge/via_dir (all None, Plan 14 T2) → [0,0,0]
1565        let any_fe = pin(Predicate::Any(vec![Predicate::FieldEqual {
1566            field: "f".into(),
1567        }]));
1568        assert_eq!(
1569            bincode::serialize(&any_fe).unwrap(),
1570            vec![
1571                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1572                66, 7, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 102, 1,
1573                0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0,
1574            ],
1575            "Any([FieldEqual{{f}}]) exact-bytes pin failed — discriminant or field layout changed"
1576        );
1577        // Verify round-trip.
1578        let decoded: RuleDef = bincode::deserialize(&bincode::serialize(&any_fe).unwrap()).unwrap();
1579        assert_eq!(decoded, any_fe, "Any must round-trip via bincode");
1580        // Verify that VectorSimilar (old variant) still decodes cleanly — adding
1581        // via fields does not change how the Predicate discriminant 6 is read.
1582        let vs = pin(Predicate::VectorSimilar {
1583            field: "emb".into(),
1584            min: 0.9,
1585        });
1586        let vs_bytes = bincode::serialize(&vs).unwrap();
1587        let vs_decoded: RuleDef = bincode::deserialize(&vs_bytes).unwrap();
1588        assert_eq!(
1589            vs_decoded.predicate,
1590            Predicate::VectorSimilar {
1591                field: "emb".into(),
1592                min: 0.9
1593            },
1594            "VectorSimilar record must still decode after via fields appended"
1595        );
1596    }
1597
1598    #[test]
1599    fn approximate_variant_has_pinned_encoding() {
1600        // Pin: VectorSimilar with approximate=true.
1601        // Layout: ... 69 (edge_type 'E') | 0 (weight None) | 0 (max_edges None)
1602        //         | 1 (approximate=true) | 0 0 0 (via fields all None).
1603        assert_eq!(
1604            bincode::serialize(&pin_approx(Predicate::VectorSimilar {
1605                field: "emb".into(),
1606                min: 0.9,
1607            }))
1608            .unwrap(),
1609            vec![
1610                1, 0, 0, 0, 0, 0, 0, 0, 114, 1, 0, 0, 0, 0, 0, 0, 0, 65, 1, 0, 0, 0, 0, 0, 0, 0,
1611                66, 6, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 101, 109, 98, 205, 204, 204, 204, 204, 204,
1612                236, 63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 1, 0, 0, 0
1613            ]
1614        );
1615        // exact vs approx: same length; differ only at the `approximate` byte
1616        // (4th from the end; last 3 bytes are via_label/via_edge/via_dir = None).
1617        let exact = bincode::serialize(&pin(Predicate::VectorSimilar {
1618            field: "emb".into(),
1619            min: 0.9,
1620        }))
1621        .unwrap();
1622        let approx = bincode::serialize(&pin_approx(Predicate::VectorSimilar {
1623            field: "emb".into(),
1624            min: 0.9,
1625        }))
1626        .unwrap();
1627        assert_eq!(exact.len(), approx.len());
1628        let n = exact.len();
1629        // Everything before `approximate` is identical.
1630        assert_eq!(&exact[..n - 4], &approx[..n - 4]);
1631        // `approximate` byte at index n-4.
1632        assert_eq!(exact[n - 4], 0u8, "exact: approximate=false");
1633        assert_eq!(approx[n - 4], 1u8, "approx: approximate=true");
1634        // Trailing via bytes are both None.
1635        assert_eq!(&exact[n - 3..], &[0u8, 0, 0]);
1636        assert_eq!(&approx[n - 3..], &[0u8, 0, 0]);
1637    }
1638}