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    /// The namespace this rule operates in.
69    ///
70    /// `None` = **global**: the rule sees every node and its derived edges may
71    /// cross namespaces. `Some(ns)` = **scoped**: it sees only nodes in `ns` —
72    /// src, via and dst alike — so every edge it derives is intra-namespace by
73    /// construction, with no pair-level check anywhere.
74    ///
75    /// APPENDED field — same pre-alpha no-migration ruling as `max_edges`,
76    /// `approximate` and `via_dir` (def.rs:33, :51): a rule bincoded before this
77    /// field existed breaks positional decode. `#[serde(default)]` covers JSON
78    /// only.
79    #[serde(default)]
80    pub namespace: Option<String>,
81}
82
83/// Score-combination conventions for composed predicates:
84///
85/// - `All(parts)` — **minimum** of the individual branch scores.  Every
86///   branch must match; the weakest link controls the edge weight.
87///   Verified by test `all_takes_min_score_and_requires_every_part`.
88///
89/// - `Any(parts)` — **maximum** of the satisfied branches' scores.  At
90///   least one branch must match; the strongest match controls the edge
91///   weight.  Verified by test `any_score_is_max_when_both_branches_match`.
92///
93/// These conventions are opposites: `All` is pessimistic (min), `Any` is
94/// optimistic (max).  Nesting is allowed up to depth
95/// `MAX_PREDICATE_NESTING_DEPTH`; `validate()` returns a named error beyond
96/// that.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub enum Predicate {
99    KeyMatch {
100        field: String,
101    },
102    FieldEqual {
103        field: String,
104    },
105    Overlap {
106        field: String,
107        min: f64,
108    },
109    All(Vec<Predicate>),
110    // APPENDED (Plan 7) — positional bincode: never reorder.
111    NumericWithin {
112        field: String,
113        tolerance: f64,
114    },
115    GeoRadius {
116        field: String,
117        km: f64,
118    },
119    VectorSimilar {
120        field: String,
121        min: f64,
122    },
123    // APPENDED (Plan 13 T2) — positional bincode: never reorder.
124    /// OR composition: matches when at least one branch matches.
125    /// Score = max over satisfied branches (see doc comment on `Predicate`).
126    Any(Vec<Predicate>),
127}
128
129pub struct NodeView<'a> {
130    pub key: &'a str,
131    pub props: &'a dyn Fn(&str) -> Option<Value>,
132}
133
134impl RuleDef {
135    pub fn validate(&self) -> Result<(), String> {
136        for (what, s) in [
137            ("name", &self.name),
138            ("src_label", &self.src_label),
139            ("dst_label", &self.dst_label),
140            ("edge_type", &self.edge_type),
141        ] {
142            if s.is_empty() {
143                return Err(format!("{what} must not be empty"));
144            }
145        }
146        validate_pred(&self.predicate)?;
147        let depth = predicate_nesting_depth(&self.predicate);
148        if depth > MAX_PREDICATE_NESTING_DEPTH {
149            return Err(format!(
150                "predicate nesting depth {depth} exceeds cap of \
151                 {MAX_PREDICATE_NESTING_DEPTH}"
152            ));
153        }
154        if self.approximate && !predicate_is_vector_similar_rooted(&self.predicate) {
155            return Err(
156                "approximate=true requires a VectorSimilar-rooted predicate \
157                 (VectorSimilar, or All whose first element is VectorSimilar)"
158                    .into(),
159            );
160        }
161        if self.via_label.is_some() && self.approximate {
162            return Err("via-hop rules do not support approximate: true".into());
163        }
164        // via_label and via_edge must both be Some or both None.
165        match (&self.via_label, &self.via_edge) {
166            (Some(_), None) | (None, Some(_)) => {
167                return Err("via_label and via_edge must both be set or both absent".into());
168            }
169            (Some(l), Some(e)) => {
170                if l.is_empty() {
171                    return Err("via_label must not be empty".into());
172                }
173                if e.is_empty() {
174                    return Err("via_edge must not be empty".into());
175                }
176            }
177            (None, None) => {}
178        }
179        if let Some(ns) = &self.namespace {
180            if !core_storage::valid_namespace(ns) {
181                return Err(format!(
182                    "namespace {ns:?} is not a valid namespace name — 1 to {} characters \
183                     of [A-Za-z0-9_.-]",
184                    core_storage::NS_MAX_LEN
185                ));
186            }
187        }
188        Ok(())
189    }
190
191    /// Whether a node in `namespace` is visible to this rule.
192    ///
193    /// Always true for a global rule, which is what keeps every rule written
194    /// before namespaces existed on exactly the code path it had.
195    pub fn sees_namespace(&self, namespace: &str) -> bool {
196        match &self.namespace {
197            None => true,
198            Some(ns) => ns == namespace,
199        }
200    }
201
202    pub fn watched_fields(&self) -> BTreeSet<String> {
203        let mut out = BTreeSet::new();
204        collect_fields(&self.predicate, &mut out);
205        out
206    }
207}
208
209/// Maximum nesting depth for compound predicates (`All` / `Any`).
210///
211/// Depth is defined as the number of nested compound-predicate layers:
212/// a bare scalar predicate has depth 0; `Any([X, Y])` has depth 1;
213/// `All([Any([X]), Y])` has depth 2; etc.  `validate()` returns a named
214/// error when this cap is exceeded.
215pub const MAX_PREDICATE_NESTING_DEPTH: usize = 4;
216
217/// Per-source top-k when a scored (non-KeyMatch-rooted) rule omits `max_edges`.
218pub const DEFAULT_SCORED_TOP_K: u64 = 32;
219
220/// Per-source top-k when a KeyMatch-rooted rule omits `max_edges`.
221///
222/// Equal to [`MAX_KEYMATCH_LIST`], the most destinations a single `KeyMatch`
223/// source can ever name: a list-valued FK field must fire on every element by
224/// default. A scalar FK field names at most one destination, so the higher cap
225/// is inert there — the top-k filter has nothing to truncate.
226pub const DEFAULT_KEYMATCH_TOP_K: u64 = MAX_KEYMATCH_LIST as u64;
227
228/// How many elements of a list-valued `KeyMatch` field are considered.
229///
230/// A `KeyMatch` field holding a `Value::List` acts as a set of foreign keys:
231/// each string element that names a live destination node yields its own edge.
232/// Only the first `MAX_KEYMATCH_LIST` elements in stored order participate —
233/// in matching, in the src-side index, and in candidate lookup — so one node
234/// can never fan out without bound. Elements past the cap are ignored
235/// deterministically: the same list always produces the same edges.
236pub const MAX_KEYMATCH_LIST: usize = 512;
237
238/// KeyMatch itself, or `All` whose first element is KeyMatch-rooted.
239/// `Any` is never KeyMatch-rooted — the FK fast-path does not apply to OR.
240pub fn is_keymatch_rooted(p: &Predicate) -> bool {
241    match p {
242        Predicate::KeyMatch { .. } => true,
243        Predicate::All(parts) => !parts.is_empty() && is_keymatch_rooted(&parts[0]),
244        Predicate::Any(_) => false,
245        _ => false,
246    }
247}
248
249/// True when a `KeyMatch` appears anywhere in the predicate tree.
250///
251/// Broader than [`is_keymatch_rooted`] and answers a different question: not
252/// "does the FK fast path apply" but "can the candidate index answer this
253/// predicate at all". `KeyMatch` candidates are resolved by id lookup, so the
254/// spec it compiles to (`CandidateSpec::ByKey`) contributes no index keys. A
255/// predicate holding one anywhere the fast path does not cover — under `Any`,
256/// or as a non-first conjunct of `All` — must therefore fall back to the exact
257/// full candidate set, or every destination reachable only through the
258/// `KeyMatch` branch is silently never considered.
259pub fn predicate_contains_keymatch(p: &Predicate) -> bool {
260    match p {
261        Predicate::KeyMatch { .. } => true,
262        Predicate::All(parts) | Predicate::Any(parts) => {
263            parts.iter().any(predicate_contains_keymatch)
264        }
265        _ => false,
266    }
267}
268
269/// Default `RuleDef.max_edges` for suggest, auto-FK, demo, and HTTP omit.
270pub fn default_max_edges(predicate: &Predicate) -> u64 {
271    if is_keymatch_rooted(predicate) {
272        DEFAULT_KEYMATCH_TOP_K
273    } else {
274        DEFAULT_SCORED_TOP_K
275    }
276}
277
278/// Returns true when the predicate is `VectorSimilar` itself, or an `All`
279/// whose first element is `VectorSimilar` — the only predicates that may use
280/// the IVF-Flat approximate candidate path (`approximate: true`).
281pub fn predicate_is_vector_similar_rooted(p: &Predicate) -> bool {
282    match p {
283        Predicate::VectorSimilar { .. } => true,
284        Predicate::All(parts) => {
285            !parts.is_empty() && matches!(parts[0], Predicate::VectorSimilar { .. })
286        }
287        Predicate::Any(_) => false,
288        _ => false,
289    }
290}
291
292/// Returns the nesting depth of a predicate tree.
293///
294/// Scalar predicates return 0.  `All` and `Any` return
295/// `1 + max(depths of children)` (0 when empty, which is guarded by
296/// `validate_pred`).
297fn predicate_nesting_depth(p: &Predicate) -> usize {
298    match p {
299        Predicate::All(parts) | Predicate::Any(parts) => {
300            1 + parts.iter().map(predicate_nesting_depth).max().unwrap_or(0)
301        }
302        _ => 0,
303    }
304}
305
306fn validate_pred(p: &Predicate) -> Result<(), String> {
307    match p {
308        Predicate::KeyMatch { field } | Predicate::FieldEqual { field } => {
309            if field.is_empty() {
310                Err("field must not be empty".into())
311            } else {
312                Ok(())
313            }
314        }
315        Predicate::Overlap { field, min } => {
316            if field.is_empty() {
317                Err("field must not be empty".into())
318            } else if !(*min > 0.0 && *min <= 1.0) {
319                Err(format!("overlap min must be in (0,1], got {min}"))
320            } else {
321                Ok(())
322            }
323        }
324        Predicate::NumericWithin { field, tolerance } => {
325            if field.is_empty() {
326                Err("field must not be empty".into())
327            } else if !(tolerance.is_finite() && *tolerance >= 0.0) {
328                Err(format!(
329                    "numeric_within tolerance must be finite and >= 0, got {tolerance}"
330                ))
331            } else {
332                Ok(())
333            }
334        }
335        Predicate::GeoRadius { field, km } => {
336            if field.is_empty() {
337                Err("field must not be empty".into())
338            } else if !(km.is_finite() && *km > 0.0) {
339                Err(format!("geo_radius km must be finite and > 0, got {km}"))
340            } else {
341                Ok(())
342            }
343        }
344        Predicate::VectorSimilar { field, min } => {
345            if field.is_empty() {
346                Err("field must not be empty".into())
347            } else if !(*min > 0.0 && *min <= 1.0) {
348                Err(format!("vector_similar min must be in (0,1], got {min}"))
349            } else {
350                Ok(())
351            }
352        }
353        Predicate::All(parts) => {
354            if parts.is_empty() {
355                return Err("all() must have at least one predicate".into());
356            }
357            parts.iter().try_for_each(validate_pred)
358        }
359        Predicate::Any(parts) => {
360            if parts.is_empty() {
361                return Err("any() must have at least one predicate".into());
362            }
363            parts.iter().try_for_each(validate_pred)
364        }
365    }
366}
367
368fn collect_fields(p: &Predicate, out: &mut BTreeSet<String>) {
369    match p {
370        Predicate::KeyMatch { field }
371        | Predicate::FieldEqual { field }
372        | Predicate::Overlap { field, .. }
373        | Predicate::NumericWithin { field, .. }
374        | Predicate::GeoRadius { field, .. }
375        | Predicate::VectorSimilar { field, .. } => {
376            out.insert(field.clone());
377        }
378        Predicate::All(parts) | Predicate::Any(parts) => {
379            parts.iter().for_each(|q| collect_fields(q, out))
380        }
381    }
382}
383
384pub fn evaluate(pred: &Predicate, src: &NodeView, dst: &NodeView) -> Option<f64> {
385    match pred {
386        Predicate::KeyMatch { field } => match (src.props)(field)? {
387            Value::Str(s) if s == dst.key => Some(1.0),
388            // A list-valued field is a set of foreign keys: it matches when any
389            // of its first `MAX_KEYMATCH_LIST` elements is the dst key. Only
390            // string elements can name a node; others are skipped but still
391            // count against the cap, so the cap depends on stored order alone.
392            Value::List(items) => items
393                .iter()
394                .take(MAX_KEYMATCH_LIST)
395                .any(|v| matches!(v, Value::Str(s) if s == dst.key))
396                .then_some(1.0),
397            _ => None,
398        },
399        Predicate::FieldEqual { field } => {
400            let a = ValueKey::from_value(&(src.props)(field)?)?;
401            let b = ValueKey::from_value(&(dst.props)(field)?)?;
402            (a == b).then_some(1.0)
403        }
404        Predicate::Overlap { field, min } => {
405            let a = list_tokens(&(src.props)(field)?)?;
406            let b = list_tokens(&(dst.props)(field)?)?;
407            let inter = a.intersection(&b).count();
408            let union = a.union(&b).count();
409            if union == 0 || inter == 0 {
410                return None;
411            }
412            let j = inter as f64 / union as f64;
413            (j >= *min).then_some(j)
414        }
415        Predicate::All(parts) => {
416            // validate() rejects empty All; this is defense-in-depth against skipped validation.
417            if parts.is_empty() {
418                return None;
419            }
420            let mut score = f64::INFINITY;
421            for part in parts {
422                score = score.min(evaluate(part, src, dst)?);
423            }
424            Some(score)
425        }
426        Predicate::Any(parts) => {
427            // validate() rejects empty Any; this is defense-in-depth against skipped validation.
428            // Score = max over satisfied branches (see doc comment on Predicate).
429            // Returns None only when no branch matches.
430            let mut best: Option<f64> = None;
431            for part in parts {
432                if let Some(s) = evaluate(part, src, dst) {
433                    best = Some(match best {
434                        None => s,
435                        Some(prev) => prev.max(s),
436                    });
437                }
438            }
439            best
440        }
441        Predicate::NumericWithin { field, tolerance } => {
442            // Score: tolerance == 0.0 → 1.0 (exact match required), else
443            // 1.0 − |a − b| / tolerance. Boundary Δ = tolerance yields score
444            // 0.0 — a legal 0-weight edge.
445            if !tolerance.is_finite() || *tolerance < 0.0 {
446                return None;
447            }
448            let a = as_finite_f64(&(src.props)(field)?)?;
449            let b = as_finite_f64(&(dst.props)(field)?)?;
450            let delta = (a - b).abs();
451            if *tolerance == 0.0 {
452                return (delta == 0.0).then_some(1.0);
453            }
454            (delta <= *tolerance).then_some(1.0 - delta / *tolerance)
455        }
456        Predicate::GeoRadius { field, km } => {
457            if !km.is_finite() || *km <= 0.0 {
458                return None;
459            }
460            let (alat, alon) = as_latlon(&(src.props)(field)?)?;
461            let (blat, blon) = as_latlon(&(dst.props)(field)?)?;
462            let d = haversine_km(alat, alon, blat, blon);
463            if !d.is_finite() {
464                return None;
465            }
466            (d <= *km).then_some(1.0 - d / *km)
467        }
468        Predicate::VectorSimilar { field, min } => {
469            let a = as_numeric_list(&(src.props)(field)?)?;
470            let b = as_numeric_list(&(dst.props)(field)?)?;
471            if a.len() != b.len() {
472                return None;
473            }
474            let cos = cosine(&a, &b)?.min(1.0);
475            (cos >= *min).then_some(cos)
476        }
477    }
478}
479
480fn as_finite_f64(v: &Value) -> Option<f64> {
481    match v {
482        Value::Int(i) => Some(*i as f64),
483        Value::Float(f) if f.is_finite() => Some(*f),
484        _ => None,
485    }
486}
487
488fn as_latlon(v: &Value) -> Option<(f64, f64)> {
489    let Value::List(items) = v else {
490        return None;
491    };
492    if items.len() != 2 {
493        return None;
494    }
495    let lat = as_finite_f64(&items[0])?;
496    let lon = as_finite_f64(&items[1])?;
497    if (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon) {
498        Some((lat, lon))
499    } else {
500        None
501    }
502}
503
504fn as_numeric_list(v: &Value) -> Option<Vec<f64>> {
505    let Value::List(items) = v else {
506        return None;
507    };
508    if items.is_empty() {
509        return None;
510    }
511    items.iter().map(as_finite_f64).collect()
512}
513
514/// Mean Earth radius (WGS-84 authalic), kilometres.
515const EARTH_RADIUS_KM: f64 = 6371.0088;
516
517fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
518    let phi1 = lat1.to_radians();
519    let phi2 = lat2.to_radians();
520    let dphi = (lat2 - lat1).to_radians();
521    let dlam = (lon2 - lon1).to_radians();
522    let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
523        .clamp(0.0, 1.0);
524    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
525    EARTH_RADIUS_KM * c
526}
527
528fn cosine(a: &[f64], b: &[f64]) -> Option<f64> {
529    let mut dot = 0.0;
530    let mut na2 = 0.0;
531    let mut nb2 = 0.0;
532    for (x, y) in a.iter().zip(b.iter()) {
533        dot += *x * *y;
534        na2 += *x * *x;
535        nb2 += *y * *y;
536    }
537    let na = na2.sqrt();
538    let nb = nb2.sqrt();
539    if !(na > 0.0 && nb > 0.0) {
540        return None;
541    }
542    let cos = dot / (na * nb);
543    cos.is_finite().then_some(cos)
544}
545
546/// Cosine similarity with Cauchy-Schwarz suffix-norm early exit.
547///
548/// Processes `a` and `b` in 8 equal-sized chunks.  After each chunk (except
549/// the last), computes the upper bound:
550///
551/// ```text
552/// cos_max = (dot_so_far + ckpts_a[c+1] × ckpts_b[c+1]) / (norm_a × norm_b)
553/// ```
554///
555/// where `ckpts_x[i]` = L2 norm of `x[i * dim / 8 ..]`.  If `cos_max <
556/// min − eps` (with `eps = dim × f64::EPSILON × 4`), the pair is provably
557/// below threshold and `None` is returned immediately (exact reject in exact
558/// arithmetic; the epsilon guard absorbs IEEE 754 rounding in suffix-norm
559/// accumulation at dim-scale — approximately 3.4 × 10⁻¹³ at dim = 1536).
560/// If all checkpoints pass, the full dot product has been accumulated and the
561/// cosine is returned normally.
562///
563/// # Correctness requirement — checkpoints must be fresh
564///
565/// `ckpts_a`/`ckpts_b` **must be fresh** for the live vectors (see
566/// `SideIndex::fresh_ckpts_for`).  A permuted vector that shares `(dim, norm)`
567/// with the indexed one passes a pure norm-based gate yet carries a different
568/// suffix energy distribution — stale checkpoints can produce a **false
569/// reject** (under-tight suffix bound), violating the exactness invariant.
570///
571/// The real coherence guarantee is structural: checkpoint rebuilds flow
572/// through the same mutation choke-points as `vec_meta` (insert/remove in
573/// `on_node_changed`), making live/cache divergence unreachable in
574/// single-writer operation.  `fresh_ckpts_for`'s dim/norm/anchor comparison
575/// is defense-in-depth — belt-and-suspenders against bugs in those
576/// choke-points, not a standalone proof.
577///
578/// # Arguments
579/// * `norm_a`, `norm_b` — precomputed L2 norms (must match `ckpts_x[0]`).
580/// * `min` — the minimum cosine threshold from the rule definition.
581pub fn cosine_early_exit(
582    a: &[f64],
583    b: &[f64],
584    ckpts_a: &[f64; 8],
585    ckpts_b: &[f64; 8],
586    norm_a: f64,
587    norm_b: f64,
588    min: f64,
589) -> Option<f64> {
590    let dim = a.len();
591    if dim == 0 || dim != b.len() {
592        return None; // dim mismatch or empty: cosine undefined, no edge
593    }
594    let denom = norm_a * norm_b;
595    if !denom.is_finite() || denom == 0.0 {
596        return None;
597    }
598
599    // Epsilon guard: suffix-norm accumulation rounds suffix_sq slightly low,
600    // making cos_max_fl potentially below the true Cauchy-Schwarz bound.  At
601    // dim=1536 the error floor is ~dim × f64::EPSILON ≈ 3.4×10⁻¹³.  4× margin
602    // keeps the guard conservative without meaningfully expanding the pass-through
603    // zone (a few extra evaluate() calls near threshold, never a false reject).
604    let eps = dim as f64 * f64::EPSILON * 4.0;
605    let mut dot = 0.0f64;
606
607    for ci in 0..8usize {
608        let chunk_start = ci * dim / 8;
609        let chunk_end = if ci < 7 { (ci + 1) * dim / 8 } else { dim };
610        for k in chunk_start..chunk_end {
611            dot += a[k] * b[k];
612        }
613        // After processing this chunk (not the last), compute the upper bound
614        // for the remaining suffix using Cauchy-Schwarz.  Guard: bail only
615        // when the bound is below min - eps to absorb float-rounding slack.
616        if ci < 7 {
617            let bound = ckpts_a[ci + 1] * ckpts_b[ci + 1];
618            let cos_max = (dot + bound) / denom;
619            if cos_max.is_finite() && cos_max < min - eps {
620                return None;
621            }
622        }
623    }
624
625    // Full dot product accumulated; return cosine clamped to [−1, 1].
626    let cos = (dot / denom).min(1.0);
627    if cos.is_finite() && cos >= min {
628        Some(cos)
629    } else {
630        None
631    }
632}
633
634// ---------------------------------------------------------------------------
635// Backward-compatible RuleDef decoder
636// ---------------------------------------------------------------------------
637
638/// Pre-0.1.2 wire shape for `RuleDef`.
639///
640/// Stores created before the `via_label`/`via_edge`/`via_dir` fields were
641/// added (phase-4, post-release 0.1.2) encode `RuleDef` with only these eight
642/// fields.  This struct is the canonical decoder for that wire shape.
643///
644/// **FROZEN — never add, remove, or reorder fields.**  Its sole purpose is to
645/// match the exact positional bincode layout of the 0.1.2 release.  Any
646/// schema change must produce a new `LegacyRuleDef*` variant instead.
647#[derive(serde::Serialize, serde::Deserialize)]
648struct LegacyRuleDefNoVia {
649    name: String,
650    src_label: String,
651    dst_label: String,
652    predicate: Predicate,
653    edge_type: String,
654    weight_prop: Option<String>,
655    max_edges: Option<u64>,
656    approximate: bool,
657}
658
659/// Stores created before the `namespace` field was added (v0.6.6) encode
660/// `RuleDef` with these eleven fields — everything through `via_dir`.
661///
662/// **FROZEN — never add, remove, or reorder fields.**  Its sole purpose is to
663/// match the exact positional bincode layout that shipped through v0.6.5. Any
664/// schema change must produce a new `LegacyRuleDef*` variant instead.
665#[derive(serde::Serialize, serde::Deserialize)]
666struct LegacyRuleDefNoNamespace {
667    name: String,
668    src_label: String,
669    dst_label: String,
670    predicate: Predicate,
671    edge_type: String,
672    weight_prop: Option<String>,
673    max_edges: Option<u64>,
674    approximate: bool,
675    via_label: Option<String>,
676    via_edge: Option<String>,
677    via_dir: Option<core_storage::Direction>,
678}
679
680/// Decode a bincode-encoded `RuleDef` from persisted bytes.
681///
682/// Tries the current wire shape first (all fields including `namespace`), then
683/// the v0.6.5 shape (`LegacyRuleDefNoNamespace` — eleven fields, no
684/// `namespace`), then the pre-0.1.2 shape (`LegacyRuleDefNoVia` — eight fields,
685/// no `via_*`), mapping each missing field to `None`.
686///
687/// Every decoder uses `reject_trailing_bytes`, which makes the three wire shapes
688/// unambiguous — each older shape is a strict prefix of the next, so a newer
689/// decoder hits EOF on older bytes and an older decoder sees trailing bytes on
690/// newer ones:
691/// - v0.6.5 bytes lack the `namespace` Option; the current-shape decoder hits
692///   EOF reading it → falls through.
693/// - Current bytes with `namespace=None` carry one extra trailing `0x00` that
694///   the eleven-field decoder rejects, so the current-shape decoder wins.
695/// - The same argument one step down for the three `via_*` fields.
696///
697/// If every attempt fails, returns `Err` naming the error messages.
698pub fn decode_rule_def(bytes: &[u8]) -> Result<RuleDef, String> {
699    use bincode::Options as _;
700    // Use fixint encoding to match bincode::serialize / bincode::deserialize
701    // (the legacy default that all persisted bytes in this codebase use).
702    // reject_trailing_bytes makes the two wire shapes unambiguous: current
703    // bytes with via_*=None have 3 extra 0x00 bytes that the legacy decoder
704    // rejects; legacy bytes lack those bytes so current-shape decode hits EOF.
705    let opts = bincode::options()
706        .with_fixint_encoding()
707        .with_no_limit()
708        .reject_trailing_bytes();
709
710    // Current-shape decode (exact consumption required).
711    match opts.deserialize::<RuleDef>(bytes) {
712        Ok(def) => Ok(def),
713        Err(current_err) => {
714            // v0.6.5 shape: everything through `via_dir`, no `namespace`. A rule
715            // written before namespaces existed is global, which is the
716            // behaviour it had.
717            if let Ok(prev) = opts.deserialize::<LegacyRuleDefNoNamespace>(bytes) {
718                return Ok(RuleDef {
719                    name: prev.name,
720                    src_label: prev.src_label,
721                    dst_label: prev.dst_label,
722                    predicate: prev.predicate,
723                    edge_type: prev.edge_type,
724                    weight_prop: prev.weight_prop,
725                    max_edges: prev.max_edges,
726                    approximate: prev.approximate,
727                    via_label: prev.via_label,
728                    via_edge: prev.via_edge,
729                    via_dir: prev.via_dir,
730                    namespace: None,
731                });
732            }
733            // Fall through to legacy attempt; preserve error for final message.
734            match opts.deserialize::<LegacyRuleDefNoVia>(bytes) {
735                Ok(legacy) => Ok(RuleDef {
736                    name: legacy.name,
737                    src_label: legacy.src_label,
738                    dst_label: legacy.dst_label,
739                    predicate: legacy.predicate,
740                    edge_type: legacy.edge_type,
741                    weight_prop: legacy.weight_prop,
742                    max_edges: legacy.max_edges,
743                    approximate: legacy.approximate,
744                    via_label: None,
745                    via_edge: None,
746                    via_dir: None,
747                    namespace: None,
748                }),
749                Err(legacy_err) => Err(format!(
750                    "corrupt rule_def — current-shape: {current_err}; \
751                     legacy-shape (pre-0.1.2 no-via): {legacy_err}"
752                )),
753            }
754        }
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use core_storage::Value;
762    use std::collections::HashMap;
763
764    // The brief's `fn view(...)` helper cannot borrow-check: it returns a NodeView
765    // holding &'a dyn Fn referencing a closure that is local to the helper (dropped
766    // on return). Fix: a macro that expands the closure binding at the call site so
767    // the temporary lives for the surrounding statement. All assertions are identical
768    // in meaning to the brief.
769    macro_rules! eval {
770        ($p:expr, ($sk:expr, $sm:ident) => ($dk:expr, $dm:ident)) => {{
771            let sp = |f: &str| $sm.get(f).cloned();
772            let dp = |f: &str| $dm.get(f).cloned();
773            evaluate(
774                $p,
775                &NodeView {
776                    key: $sk,
777                    props: &sp,
778                },
779                &NodeView {
780                    key: $dk,
781                    props: &dp,
782                },
783            )
784        }};
785    }
786
787    #[test]
788    fn key_match_links_fk_to_key() {
789        let s: HashMap<_, _> = [("cid".to_string(), Value::Str("c1".into()))].into();
790        let d: HashMap<String, Value> = HashMap::new();
791        let p = Predicate::KeyMatch {
792            field: "cid".into(),
793        };
794        assert_eq!(eval!(&p, ("t1", s) => ("c1", d)), Some(1.0));
795        assert_eq!(eval!(&p, ("t1", s) => ("c2", d)), None);
796        assert_eq!(eval!(&p, ("t1", d) => ("c1", d)), None); // field absent
797    }
798
799    #[test]
800    fn field_equal_needs_both_scalars_equal() {
801        let a: HashMap<_, _> = [("ind".to_string(), Value::Str("arch".into()))].into();
802        let b = a.clone();
803        let c: HashMap<_, _> = [("ind".to_string(), Value::Str("law".into()))].into();
804        let p = Predicate::FieldEqual {
805            field: "ind".into(),
806        };
807        assert_eq!(eval!(&p, ("a", a) => ("b", b)), Some(1.0));
808        assert_eq!(eval!(&p, ("a", a) => ("c", c)), None);
809    }
810
811    #[test]
812    fn overlap_is_jaccard_with_threshold() {
813        let mk =
814            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
815        let a: HashMap<_, _> = [("tags".to_string(), mk(&["x", "y"]))].into();
816        let b: HashMap<_, _> = [("tags".to_string(), mk(&["y", "z"]))].into();
817        let p = Predicate::Overlap {
818            field: "tags".into(),
819            min: 0.3,
820        };
821        // jaccard = |{y}| / |{x,y,z}| = 1/3
822        let score = eval!(&p, ("a", a) => ("b", b)).unwrap();
823        assert!((score - 1.0 / 3.0).abs() < 1e-9);
824        let strict = Predicate::Overlap {
825            field: "tags".into(),
826            min: 0.5,
827        };
828        assert_eq!(eval!(&strict, ("a", a) => ("b", b)), None);
829        // empty-vs-anything never matches (union empty or intersection empty)
830        let e: HashMap<_, _> = [("tags".to_string(), mk(&[]))].into();
831        assert_eq!(eval!(&p, ("a", e) => ("b", b)), None);
832    }
833
834    #[test]
835    fn all_takes_min_score_and_requires_every_part() {
836        let mk =
837            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
838        let a: HashMap<_, _> = [
839            ("ind".to_string(), Value::Str("arch".into())),
840            ("tags".to_string(), mk(&["x", "y"])),
841        ]
842        .into();
843        let b: HashMap<_, _> = [
844            ("ind".to_string(), Value::Str("arch".into())),
845            ("tags".to_string(), mk(&["y"])),
846        ]
847        .into();
848        let p = Predicate::All(vec![
849            Predicate::FieldEqual {
850                field: "ind".into(),
851            },
852            Predicate::Overlap {
853                field: "tags".into(),
854                min: 0.4,
855            },
856        ]);
857        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
858        assert!((s - 0.5).abs() < 1e-9); // min(1.0, 0.5)
859    }
860
861    #[test]
862    fn validation_rejects_bad_rules_and_collects_watched_fields() {
863        let ok = RuleDef {
864            name: "r".into(),
865            src_label: "A".into(),
866            dst_label: "B".into(),
867            predicate: Predicate::All(vec![
868                Predicate::KeyMatch { field: "fk".into() },
869                Predicate::Overlap {
870                    field: "tags".into(),
871                    min: 0.5,
872                },
873            ]),
874            edge_type: "E".into(),
875            weight_prop: Some("score".into()),
876            max_edges: None,
877            approximate: false,
878            via_label: None,
879            via_edge: None,
880            via_dir: None,
881            namespace: None,
882        };
883        assert!(ok.validate().is_ok());
884        assert_eq!(
885            ok.watched_fields().into_iter().collect::<Vec<_>>(),
886            vec!["fk".to_string(), "tags".to_string()]
887        );
888        let mut bad = ok.clone();
889        bad.predicate = Predicate::Overlap {
890            field: "t".into(),
891            min: 0.0,
892        };
893        assert!(bad.validate().is_err()); // min must be in (0,1]
894        let mut bad2 = ok.clone();
895        bad2.edge_type = String::new();
896        assert!(bad2.validate().is_err());
897        let mut bad3 = ok;
898        bad3.predicate = Predicate::All(vec![]);
899        assert!(bad3.validate().is_err());
900    }
901
902    #[test]
903    fn evaluate_empty_all_returns_none() {
904        let empty: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
905        let sp = |f: &str| empty.get(f).cloned();
906        let dp = |f: &str| empty.get(f).cloned();
907        let src = NodeView {
908            key: "a",
909            props: &sp,
910        };
911        let dst = NodeView {
912            key: "b",
913            props: &dp,
914        };
915        assert_eq!(evaluate(&Predicate::All(vec![]), &src, &dst), None);
916    }
917
918    #[test]
919    fn numeric_within_int_float_cross_type() {
920        let a: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
921        let b: HashMap<_, _> = [("year".to_string(), Value::Float(2000.0))].into();
922        let tight = Predicate::NumericWithin {
923            field: "year".into(),
924            tolerance: 2.0,
925        };
926        // |1998 − 2000| = 2; Δ = tolerance → score 0.0 (legal 0-weight edge)
927        assert_eq!(eval!(&tight, ("a", a) => ("b", b)), Some(0.0));
928        let loose = Predicate::NumericWithin {
929            field: "year".into(),
930            tolerance: 3.0,
931        };
932        let score = eval!(&loose, ("a", a) => ("b", b)).unwrap();
933        assert!((score - 1.0 / 3.0).abs() < 1e-9);
934    }
935
936    #[test]
937    fn numeric_within_missing_or_non_numeric_is_none() {
938        let num: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
939        let missing: HashMap<String, Value> = HashMap::new();
940        let text: HashMap<_, _> = [("year".to_string(), Value::Str("1998".into()))].into();
941        let p = Predicate::NumericWithin {
942            field: "year".into(),
943            tolerance: 2.0,
944        };
945        assert_eq!(eval!(&p, ("a", num) => ("b", missing)), None);
946        assert_eq!(eval!(&p, ("a", missing) => ("b", num)), None);
947        assert_eq!(eval!(&p, ("a", num) => ("b", text)), None);
948    }
949
950    #[test]
951    fn numeric_within_tol_zero_requires_exact() {
952        let a: HashMap<_, _> = [("year".to_string(), Value::Int(1998))].into();
953        let same: HashMap<_, _> = [("year".to_string(), Value::Float(1998.0))].into();
954        let other: HashMap<_, _> = [("year".to_string(), Value::Int(1999))].into();
955        let p = Predicate::NumericWithin {
956            field: "year".into(),
957            tolerance: 0.0,
958        };
959        assert_eq!(eval!(&p, ("a", a) => ("b", same)), Some(1.0));
960        assert_eq!(eval!(&p, ("a", a) => ("b", other)), None);
961    }
962
963    #[test]
964    fn numeric_within_non_finite_is_none() {
965        let a: HashMap<_, _> = [("year".to_string(), Value::Float(f64::NAN))].into();
966        let b: HashMap<_, _> = [("year".to_string(), Value::Float(1.0))].into();
967        let inf: HashMap<_, _> = [("year".to_string(), Value::Float(f64::INFINITY))].into();
968        let p = Predicate::NumericWithin {
969            field: "year".into(),
970            tolerance: 2.0,
971        };
972        assert_eq!(eval!(&p, ("a", a) => ("b", b)), None);
973        assert_eq!(eval!(&p, ("a", inf) => ("b", b)), None);
974    }
975
976    fn geo_pair(
977        src: (f64, f64),
978        dst: (f64, f64),
979    ) -> (HashMap<String, Value>, HashMap<String, Value>) {
980        let mk = |lat: f64, lon: f64| {
981            let mut m = HashMap::new();
982            m.insert(
983                "loc".to_string(),
984                Value::List(vec![Value::Float(lat), Value::Float(lon)]),
985            );
986            m
987        };
988        (mk(src.0, src.1), mk(dst.0, dst.1))
989    }
990
991    #[test]
992    fn geo_radius_paris_london() {
993        // Paris (48.8566, 2.3522) ↔ London (51.5074, −0.1278) ≈ 343.5 km
994        let (paris, london) = geo_pair((48.8566, 2.3522), (51.5074, -0.1278));
995        let inside = Predicate::GeoRadius {
996            field: "loc".into(),
997            km: 400.0,
998        };
999        let score = eval!(&inside, ("p", paris) => ("l", london)).unwrap();
1000        // 1 − 343.5/400 = 0.14125; ±0.001 pins haversine to ~±0.4 km
1001        assert!((score - 0.14125).abs() < 0.001);
1002        let outside = Predicate::GeoRadius {
1003            field: "loc".into(),
1004            km: 300.0,
1005        };
1006        assert_eq!(eval!(&outside, ("p", paris) => ("l", london)), None);
1007    }
1008
1009    #[test]
1010    fn geo_radius_identical_coordinates_score_one() {
1011        let (a, b) = geo_pair((48.8566, 2.3522), (48.8566, 2.3522));
1012        let p = Predicate::GeoRadius {
1013            field: "loc".into(),
1014            km: 400.0,
1015        };
1016        assert_eq!(eval!(&p, ("a", a) => ("b", b)), Some(1.0));
1017    }
1018
1019    #[test]
1020    fn geo_radius_malformed_is_none() {
1021        let paris: HashMap<_, _> = [(
1022            "loc".to_string(),
1023            Value::List(vec![Value::Float(48.8566), Value::Float(2.3522)]),
1024        )]
1025        .into();
1026        let one: HashMap<_, _> =
1027            [("loc".to_string(), Value::List(vec![Value::Float(48.8566)]))].into();
1028        let three: HashMap<_, _> = [(
1029            "loc".to_string(),
1030            Value::List(vec![
1031                Value::Float(48.8566),
1032                Value::Float(2.3522),
1033                Value::Float(0.0),
1034            ]),
1035        )]
1036        .into();
1037        let string_el: HashMap<_, _> = [(
1038            "loc".to_string(),
1039            Value::List(vec![Value::Str("48.8566".into()), Value::Float(2.3522)]),
1040        )]
1041        .into();
1042        let lat91: HashMap<_, _> = [(
1043            "loc".to_string(),
1044            Value::List(vec![Value::Float(91.0), Value::Float(0.0)]),
1045        )]
1046        .into();
1047        let p = Predicate::GeoRadius {
1048            field: "loc".into(),
1049            km: 400.0,
1050        };
1051        assert_eq!(eval!(&p, ("a", paris) => ("b", one)), None);
1052        assert_eq!(eval!(&p, ("a", paris) => ("b", three)), None);
1053        assert_eq!(eval!(&p, ("a", paris) => ("b", string_el)), None);
1054        assert_eq!(eval!(&p, ("a", paris) => ("b", lat91)), None);
1055    }
1056
1057    fn vec_field(vals: &[f64]) -> HashMap<String, Value> {
1058        [(
1059            "emb".to_string(),
1060            Value::List(vals.iter().copied().map(Value::Float).collect()),
1061        )]
1062        .into()
1063    }
1064
1065    #[test]
1066    fn vector_similar_cosine_and_rejects() {
1067        let a = vec_field(&[1.0, 0.0]);
1068        let same = vec_field(&[1.0, 0.0]);
1069        let ortho = vec_field(&[0.0, 1.0]);
1070        let p = Predicate::VectorSimilar {
1071            field: "emb".into(),
1072            min: 0.5,
1073        };
1074        assert_eq!(eval!(&p, ("a", a) => ("b", same)), Some(1.0));
1075        assert_eq!(eval!(&p, ("a", a) => ("b", ortho)), None); // cos 0 < min
1076
1077        let u = vec_field(&[1.0, 2.0]);
1078        let scaled = vec_field(&[2.0, 4.0]);
1079        let score = eval!(&p, ("a", u) => ("b", scaled)).unwrap();
1080        assert!((1.0 - score).abs() < 1e-9); // parallel → 1.0 − ε
1081
1082        let dim3 = vec_field(&[1.0, 0.0, 0.0]);
1083        assert_eq!(eval!(&p, ("a", a) => ("b", dim3)), None);
1084        let zero = vec_field(&[0.0, 0.0]);
1085        assert_eq!(eval!(&p, ("a", a) => ("b", zero)), None);
1086    }
1087
1088    #[test]
1089    fn approximate_only_valid_with_vector_similar_rooted_predicate() {
1090        // approximate=true + VectorSimilar → valid
1091        let ok_vec = RuleDef {
1092            name: "av".into(),
1093            src_label: "V".into(),
1094            dst_label: "V".into(),
1095            predicate: Predicate::VectorSimilar {
1096                field: "emb".into(),
1097                min: 0.9,
1098            },
1099            edge_type: "VEC".into(),
1100            weight_prop: None,
1101            max_edges: None,
1102            approximate: true,
1103            via_label: None,
1104            via_edge: None,
1105            via_dir: None,
1106            namespace: None,
1107        };
1108        assert!(ok_vec.validate().is_ok());
1109
1110        // approximate=true + All(VectorSimilar, ...) → valid
1111        let ok_all = RuleDef {
1112            name: "av2".into(),
1113            src_label: "V".into(),
1114            dst_label: "V".into(),
1115            predicate: Predicate::All(vec![
1116                Predicate::VectorSimilar {
1117                    field: "emb".into(),
1118                    min: 0.9,
1119                },
1120                Predicate::FieldEqual {
1121                    field: "kind".into(),
1122                },
1123            ]),
1124            edge_type: "VEC2".into(),
1125            weight_prop: None,
1126            max_edges: None,
1127            approximate: true,
1128            via_label: None,
1129            via_edge: None,
1130            via_dir: None,
1131            namespace: None,
1132        };
1133        assert!(ok_all.validate().is_ok());
1134
1135        // approximate=true + FieldEqual → invalid
1136        let bad_fe = RuleDef {
1137            name: "bfe".into(),
1138            src_label: "A".into(),
1139            dst_label: "A".into(),
1140            predicate: Predicate::FieldEqual { field: "f".into() },
1141            edge_type: "FE".into(),
1142            weight_prop: None,
1143            max_edges: None,
1144            approximate: true,
1145            via_label: None,
1146            via_edge: None,
1147            via_dir: None,
1148            namespace: None,
1149        };
1150        assert!(bad_fe.validate().is_err());
1151
1152        // approximate=true + Overlap → invalid
1153        let bad_ov = RuleDef {
1154            name: "bov".into(),
1155            src_label: "A".into(),
1156            dst_label: "A".into(),
1157            predicate: Predicate::Overlap {
1158                field: "tags".into(),
1159                min: 0.5,
1160            },
1161            edge_type: "OV".into(),
1162            weight_prop: None,
1163            max_edges: None,
1164            approximate: true,
1165            via_label: None,
1166            via_edge: None,
1167            via_dir: None,
1168            namespace: None,
1169        };
1170        assert!(bad_ov.validate().is_err());
1171
1172        // approximate=true + All(FieldEqual, VectorSimilar) → invalid (first part is not VectorSimilar)
1173        let bad_all_order = RuleDef {
1174            name: "bao".into(),
1175            src_label: "A".into(),
1176            dst_label: "A".into(),
1177            predicate: Predicate::All(vec![
1178                Predicate::FieldEqual { field: "f".into() },
1179                Predicate::VectorSimilar {
1180                    field: "emb".into(),
1181                    min: 0.9,
1182                },
1183            ]),
1184            edge_type: "E".into(),
1185            weight_prop: None,
1186            max_edges: None,
1187            approximate: true,
1188            via_label: None,
1189            via_edge: None,
1190            via_dir: None,
1191            namespace: None,
1192        };
1193        assert!(bad_all_order.validate().is_err());
1194    }
1195
1196    #[test]
1197    fn validate_rejects_via_with_approximate() {
1198        // via_label set + approximate=true → invalid (via bypasses HNSW entirely)
1199        let bad = RuleDef {
1200            name: "vbad".into(),
1201            src_label: "A".into(),
1202            dst_label: "B".into(),
1203            predicate: Predicate::VectorSimilar {
1204                field: "emb".into(),
1205                min: 0.9,
1206            },
1207            edge_type: "VEC".into(),
1208            weight_prop: None,
1209            max_edges: None,
1210            approximate: true,
1211            via_label: Some("Mid".into()),
1212            via_edge: Some("hop".into()),
1213            via_dir: None,
1214            namespace: None,
1215        };
1216        let err = bad.validate().unwrap_err();
1217        assert_eq!(err, "via-hop rules do not support approximate: true");
1218
1219        // via_label set + approximate=false → still valid (only the via+approx combo is banned)
1220        let ok = RuleDef {
1221            approximate: false,
1222            ..bad.clone()
1223        };
1224        assert!(ok.validate().is_ok());
1225    }
1226
1227    #[test]
1228    fn all_composes_field_equal_and_numeric_within() {
1229        let a: HashMap<_, _> = [
1230            ("ind".to_string(), Value::Str("arch".into())),
1231            ("year".to_string(), Value::Int(1998)),
1232        ]
1233        .into();
1234        let b: HashMap<_, _> = [
1235            ("ind".to_string(), Value::Str("arch".into())),
1236            ("year".to_string(), Value::Float(2000.0)),
1237        ]
1238        .into();
1239        let p = Predicate::All(vec![
1240            Predicate::FieldEqual {
1241                field: "ind".into(),
1242            },
1243            Predicate::NumericWithin {
1244                field: "year".into(),
1245                tolerance: 3.0,
1246            },
1247        ]);
1248        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1249        assert!((s - 1.0 / 3.0).abs() < 1e-9); // min(1.0, 1/3)
1250    }
1251
1252    fn sample_rule(pred: Predicate) -> RuleDef {
1253        RuleDef {
1254            name: "r".into(),
1255            src_label: "A".into(),
1256            dst_label: "B".into(),
1257            predicate: pred,
1258            edge_type: "E".into(),
1259            weight_prop: None,
1260            max_edges: None,
1261            approximate: false,
1262            via_label: None,
1263            via_edge: None,
1264            via_dir: None,
1265            namespace: None,
1266        }
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // Any predicate tests (TDD — written before implementation)
1271    // -----------------------------------------------------------------------
1272
1273    /// Score = max over satisfied branches; None only when all branches fail.
1274    #[test]
1275    fn any_takes_max_score_and_requires_at_least_one_branch() {
1276        let mk =
1277            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
1278        // src: ind="arch", tags=["x","y"]; dst: ind="law", tags=["y","z"]
1279        // Branch A: FieldEqual(ind) → None  (arch ≠ law)
1280        // Branch B: Overlap(tags, 0.3) → jaccard = 1/3 ≥ 0.3 → Some(1/3)
1281        // Any → Some(max(_, 1/3)) = Some(1/3)
1282        let a: HashMap<_, _> = [
1283            ("ind".to_string(), Value::Str("arch".into())),
1284            ("tags".to_string(), mk(&["x", "y"])),
1285        ]
1286        .into();
1287        let b: HashMap<_, _> = [
1288            ("ind".to_string(), Value::Str("law".into())),
1289            ("tags".to_string(), mk(&["y", "z"])),
1290        ]
1291        .into();
1292        let p = Predicate::Any(vec![
1293            Predicate::FieldEqual {
1294                field: "ind".into(),
1295            },
1296            Predicate::Overlap {
1297                field: "tags".into(),
1298                min: 0.3,
1299            },
1300        ]);
1301        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1302        assert!(
1303            (s - 1.0 / 3.0).abs() < 1e-9,
1304            "score must be max(None, 1/3) = 1/3, got {s}"
1305        );
1306    }
1307
1308    /// When both branches match, Any returns the larger score.
1309    #[test]
1310    fn any_score_is_max_when_both_branches_match() {
1311        // src: ind="arch", year=2000; dst: ind="arch", year=2001
1312        // Branch A: FieldEqual(ind) → Some(1.0)
1313        // Branch B: NumericWithin(year, tol=3) → 1 - 1/3 = 2/3 → Some(2/3)
1314        // Any → Some(max(1.0, 2/3)) = Some(1.0)
1315        let a: HashMap<_, _> = [
1316            ("ind".to_string(), Value::Str("arch".into())),
1317            ("year".to_string(), Value::Int(2000)),
1318        ]
1319        .into();
1320        let b: HashMap<_, _> = [
1321            ("ind".to_string(), Value::Str("arch".into())),
1322            ("year".to_string(), Value::Float(2001.0)),
1323        ]
1324        .into();
1325        let p = Predicate::Any(vec![
1326            Predicate::FieldEqual {
1327                field: "ind".into(),
1328            },
1329            Predicate::NumericWithin {
1330                field: "year".into(),
1331                tolerance: 3.0,
1332            },
1333        ]);
1334        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1335        assert!(
1336            (s - 1.0).abs() < 1e-9,
1337            "score must be max(1.0, 2/3) = 1.0, got {s}"
1338        );
1339    }
1340
1341    /// None when all branches fail.
1342    #[test]
1343    fn any_returns_none_when_all_branches_fail() {
1344        let a: HashMap<_, _> = [("ind".to_string(), Value::Str("arch".into()))].into();
1345        let b: HashMap<_, _> = [("ind".to_string(), Value::Str("law".into()))].into();
1346        let p = Predicate::Any(vec![
1347            Predicate::FieldEqual {
1348                field: "ind".into(),
1349            },
1350            Predicate::FieldEqual {
1351                field: "ind".into(),
1352            },
1353        ]);
1354        assert_eq!(eval!(&p, ("a", a) => ("b", b)), None);
1355    }
1356
1357    /// Nested All(FieldEqual, Any(Overlap, NumericWithin)).
1358    /// All uses min; Any uses max.  Combined: min(1.0, max(1/3, 2/3)) = 2/3.
1359    #[test]
1360    fn nested_all_of_any_uses_min_over_max() {
1361        let mk =
1362            |items: &[&str]| Value::List(items.iter().map(|s| Value::Str((*s).into())).collect());
1363        // src: ind="arch", tags=["x","y"], year=2000
1364        // dst: ind="arch", tags=["y","z"], year=2001
1365        // FieldEqual(ind)             → Some(1.0)
1366        // Overlap(tags, 0.3)          → jaccard=1/3 → Some(1/3)
1367        // NumericWithin(year, tol=3)  → 1 - 1/3 = 2/3 → Some(2/3)
1368        // Any(Overlap, Numeric)       → max(1/3, 2/3) = 2/3
1369        // All(FieldEqual, Any(...))   → min(1.0, 2/3) = 2/3
1370        let a: HashMap<_, _> = [
1371            ("ind".to_string(), Value::Str("arch".into())),
1372            ("tags".to_string(), mk(&["x", "y"])),
1373            ("year".to_string(), Value::Int(2000)),
1374        ]
1375        .into();
1376        let b: HashMap<_, _> = [
1377            ("ind".to_string(), Value::Str("arch".into())),
1378            ("tags".to_string(), mk(&["y", "z"])),
1379            ("year".to_string(), Value::Float(2001.0)),
1380        ]
1381        .into();
1382        let p = Predicate::All(vec![
1383            Predicate::FieldEqual {
1384                field: "ind".into(),
1385            },
1386            Predicate::Any(vec![
1387                Predicate::Overlap {
1388                    field: "tags".into(),
1389                    min: 0.3,
1390                },
1391                Predicate::NumericWithin {
1392                    field: "year".into(),
1393                    tolerance: 3.0,
1394                },
1395            ]),
1396        ]);
1397        let s = eval!(&p, ("a", a) => ("b", b)).unwrap();
1398        assert!(
1399            (s - 2.0 / 3.0).abs() < 1e-9,
1400            "expected min(1.0, max(1/3, 2/3)) = 2/3, got {s}"
1401        );
1402    }
1403
1404    /// Any([All([X, Y]), Z]) — score = max(min(X, Y), Z).
1405    /// Tests two scenarios: one where the All branch wins, one where Z wins.
1406    #[test]
1407    fn nested_any_of_all_uses_max_over_min() {
1408        // Predicate: Any([All([FieldEqual(gen), NumericWithin(yr, 4)]), NumericWithin(yr2, 10)])
1409        let p = Predicate::Any(vec![
1410            Predicate::All(vec![
1411                Predicate::FieldEqual {
1412                    field: "gen".into(),
1413                },
1414                Predicate::NumericWithin {
1415                    field: "yr".into(),
1416                    tolerance: 4.0,
1417                },
1418            ]),
1419            Predicate::NumericWithin {
1420                field: "yr2".into(),
1421                tolerance: 10.0,
1422            },
1423        ]);
1424
1425        // Scenario A: All branch wins (0.75 > 0.5).
1426        // gen match → FieldEqual = 1.0
1427        // yr diff = 1, tol = 4  → score = 1 − 1/4 = 0.75
1428        // All = min(1.0, 0.75) = 0.75
1429        // yr2 diff = 5, tol = 10 → score = 1 − 5/10 = 0.5
1430        // Any = max(0.75, 0.5) = 0.75
1431        let a: HashMap<_, _> = [
1432            ("gen".to_string(), Value::Str("pop".into())),
1433            ("yr".to_string(), Value::Int(2000)),
1434            ("yr2".to_string(), Value::Int(2000)),
1435        ]
1436        .into();
1437        let b: HashMap<_, _> = [
1438            ("gen".to_string(), Value::Str("pop".into())),
1439            ("yr".to_string(), Value::Float(2001.0)),
1440            ("yr2".to_string(), Value::Float(2005.0)),
1441        ]
1442        .into();
1443        let s_a = eval!(&p, ("a", a) => ("b", b)).unwrap();
1444        assert!(
1445            (s_a - 0.75).abs() < 1e-9,
1446            "Any-of-All scenario A: max(min(1.0,0.75), 0.5) must be 0.75, got {s_a}"
1447        );
1448
1449        // Scenario B: Z branch wins (All = None because gen differs).
1450        // gen mismatch → FieldEqual = None → All = None
1451        // yr2 diff = 1, tol = 10 → score = 1 − 1/10 = 0.9
1452        // Any = max(None, 0.9) = 0.9
1453        let a2: HashMap<_, _> = [
1454            ("gen".to_string(), Value::Str("pop".into())),
1455            ("yr".to_string(), Value::Int(2000)),
1456            ("yr2".to_string(), Value::Int(2000)),
1457        ]
1458        .into();
1459        let c: HashMap<_, _> = [
1460            ("gen".to_string(), Value::Str("rock".into())),
1461            ("yr".to_string(), Value::Float(2001.0)),
1462            ("yr2".to_string(), Value::Float(2001.0)),
1463        ]
1464        .into();
1465        let s_b = eval!(&p, ("a", a2) => ("c", c)).unwrap();
1466        assert!(
1467            (s_b - 0.9).abs() < 1e-9,
1468            "Any-of-All scenario B: max(None, 0.9) must be 0.9, got {s_b}"
1469        );
1470    }
1471
1472    /// validate() rejects empty Any; depth cap 4 is enforced with a named error.
1473    #[test]
1474    fn any_validation_errors() {
1475        // Empty Any → named error (pinned text)
1476        let empty = sample_rule(Predicate::Any(vec![]));
1477        let err = empty.validate().unwrap_err();
1478        assert_eq!(err, "any() must have at least one predicate");
1479
1480        // Helper: build a singly-nested Any chain of the given depth.
1481        fn any_chain(depth: usize) -> Predicate {
1482            if depth == 0 {
1483                Predicate::FieldEqual { field: "f".into() }
1484            } else {
1485                Predicate::Any(vec![any_chain(depth - 1)])
1486            }
1487        }
1488
1489        // depth 4 = cap → valid
1490        assert!(
1491            sample_rule(any_chain(4)).validate().is_ok(),
1492            "depth 4 must be valid (at cap)"
1493        );
1494        // depth 5 > cap → named error
1495        let too_deep = sample_rule(any_chain(5));
1496        let err = too_deep.validate().unwrap_err();
1497        assert!(
1498            err.contains("nesting depth"),
1499            "error must mention 'nesting depth', got: {err}"
1500        );
1501
1502        // Any containing empty All → error propagated from inner validate_pred
1503        let bad_inner = sample_rule(Predicate::Any(vec![Predicate::All(vec![])]));
1504        assert!(bad_inner.validate().is_err());
1505    }
1506
1507    /// watched_fields collects fields from all branches of Any.
1508    #[test]
1509    fn any_watched_fields_collected() {
1510        let p = Predicate::Any(vec![
1511            Predicate::FieldEqual {
1512                field: "ind".into(),
1513            },
1514            Predicate::NumericWithin {
1515                field: "year".into(),
1516                tolerance: 1.0,
1517            },
1518        ]);
1519        let r = sample_rule(p);
1520        assert!(r.validate().is_ok());
1521        let fields: Vec<_> = r.watched_fields().into_iter().collect();
1522        assert_eq!(fields, vec!["ind".to_string(), "year".to_string()]);
1523    }
1524
1525    #[test]
1526    fn new_predicates_validate_and_watch_fields() {
1527        let num = sample_rule(Predicate::NumericWithin {
1528            field: "year".into(),
1529            tolerance: 2.0,
1530        });
1531        assert!(num.validate().is_ok());
1532        assert_eq!(
1533            num.watched_fields().into_iter().collect::<Vec<_>>(),
1534            vec!["year".to_string()]
1535        );
1536        let geo = sample_rule(Predicate::GeoRadius {
1537            field: "loc".into(),
1538            km: 400.0,
1539        });
1540        assert!(geo.validate().is_ok());
1541        let vecp = sample_rule(Predicate::VectorSimilar {
1542            field: "emb".into(),
1543            min: 0.9,
1544        });
1545        assert!(vecp.validate().is_ok());
1546
1547        let mut bad = num.clone();
1548        bad.predicate = Predicate::NumericWithin {
1549            field: "year".into(),
1550            tolerance: -1.0,
1551        };
1552        assert!(bad.validate().is_err());
1553        bad.predicate = Predicate::NumericWithin {
1554            field: "year".into(),
1555            tolerance: f64::NAN,
1556        };
1557        assert!(bad.validate().is_err());
1558
1559        let mut bad_geo = geo;
1560        bad_geo.predicate = Predicate::GeoRadius {
1561            field: "loc".into(),
1562            km: 0.0,
1563        };
1564        assert!(bad_geo.validate().is_err());
1565        bad_geo.predicate = Predicate::GeoRadius {
1566            field: "loc".into(),
1567            km: f64::NAN,
1568        };
1569        assert!(bad_geo.validate().is_err());
1570
1571        let mut bad_vec = vecp;
1572        bad_vec.predicate = Predicate::VectorSimilar {
1573            field: "emb".into(),
1574            min: 0.0,
1575        };
1576        assert!(bad_vec.validate().is_err());
1577        bad_vec.predicate = Predicate::VectorSimilar {
1578            field: "emb".into(),
1579            min: 1.5,
1580        };
1581        assert!(bad_vec.validate().is_err());
1582    }
1583
1584    #[test]
1585    fn default_max_edges_keymatch_is_512_else_32() {
1586        assert_eq!(DEFAULT_SCORED_TOP_K, 32);
1587        // One per element of a list-valued FK field; inert for a scalar one.
1588        assert_eq!(DEFAULT_KEYMATCH_TOP_K, 512);
1589        assert_eq!(DEFAULT_KEYMATCH_TOP_K, MAX_KEYMATCH_LIST as u64);
1590        assert_eq!(
1591            default_max_edges(&Predicate::KeyMatch { field: "fk".into() }),
1592            DEFAULT_KEYMATCH_TOP_K
1593        );
1594        assert_eq!(
1595            default_max_edges(&Predicate::All(vec![Predicate::KeyMatch {
1596                field: "fk".into()
1597            }])),
1598            DEFAULT_KEYMATCH_TOP_K
1599        );
1600        assert_eq!(
1601            default_max_edges(&Predicate::All(vec![Predicate::All(vec![
1602                Predicate::KeyMatch { field: "fk".into() }
1603            ])])),
1604            DEFAULT_KEYMATCH_TOP_K
1605        );
1606        assert_eq!(
1607            default_max_edges(&Predicate::Overlap {
1608                field: "tags".into(),
1609                min: 0.5,
1610            }),
1611            DEFAULT_SCORED_TOP_K
1612        );
1613        assert_eq!(
1614            default_max_edges(&Predicate::Any(vec![Predicate::KeyMatch {
1615                field: "fk".into()
1616            }])),
1617            DEFAULT_SCORED_TOP_K
1618        );
1619        assert!(!is_keymatch_rooted(&Predicate::FieldEqual {
1620            field: "f".into()
1621        }));
1622    }
1623}
1624
1625#[cfg(test)]
1626mod wire_pins {
1627    use super::*;
1628
1629    fn pin(pred: Predicate) -> RuleDef {
1630        RuleDef {
1631            name: "r".into(),
1632            src_label: "A".into(),
1633            dst_label: "B".into(),
1634            predicate: pred,
1635            edge_type: "E".into(),
1636            weight_prop: None,
1637            max_edges: None,
1638            approximate: false,
1639            via_label: None,
1640            via_edge: None,
1641            via_dir: None,
1642            namespace: None,
1643        }
1644    }
1645
1646    fn pin_approx(pred: Predicate) -> RuleDef {
1647        RuleDef {
1648            name: "r".into(),
1649            src_label: "A".into(),
1650            dst_label: "B".into(),
1651            predicate: pred,
1652            edge_type: "E".into(),
1653            weight_prop: None,
1654            max_edges: None,
1655            approximate: true,
1656            via_label: None,
1657            via_edge: None,
1658            via_dir: None,
1659            namespace: None,
1660        }
1661    }
1662
1663    #[test]
1664    fn old_predicate_variants_keep_encoding() {
1665        // Captured before Plan 7 appends. Discriminants 0..=3 must not move.
1666        // Plan 11 T4: `approximate: false` appends one zero byte at the end
1667        // of every existing record. Plan 14 T2: `via_label`, `via_edge`,
1668        // `via_dir` (all `None`) append three more zero bytes. Old WAL records
1669        // written before these fields break positional bincode decode —
1670        // pre-alpha no-migration ruling.
1671        assert_eq!(
1672            bincode::serialize(&pin(Predicate::KeyMatch { field: "fk".into() })).unwrap(),
1673            vec![
1674                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,
1675                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,
1676                0, 0, 0, 0, 0
1677            ]
1678        );
1679        assert_eq!(
1680            bincode::serialize(&pin(Predicate::FieldEqual {
1681                field: "ind".into()
1682            }))
1683            .unwrap(),
1684            vec![
1685                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,
1686                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,
1687                0, 0, 0, 0, 0, 0, 0
1688            ]
1689        );
1690        assert_eq!(
1691            bincode::serialize(&pin(Predicate::Overlap {
1692                field: "tags".into(),
1693                min: 0.5,
1694            }))
1695            .unwrap(),
1696            vec![
1697                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,
1698                66, 2, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 116, 97, 103, 115, 0, 0, 0, 0, 0, 0, 224,
1699                63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1700            ]
1701        );
1702        assert_eq!(
1703            bincode::serialize(&pin(Predicate::All(vec![
1704                Predicate::KeyMatch { field: "fk".into() },
1705                Predicate::Overlap {
1706                    field: "tags".into(),
1707                    min: 0.5,
1708                },
1709            ])))
1710            .unwrap(),
1711            vec![
1712                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,
1713                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,
1714                107, 2, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 116, 97, 103, 115, 0, 0, 0, 0, 0, 0, 224,
1715                63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1716            ]
1717        );
1718    }
1719
1720    #[test]
1721    fn new_predicate_variants_have_pinned_encoding() {
1722        // Trailing `0, 0, 0` = via_label/via_edge/via_dir all None (Plan 14 T2).
1723        assert_eq!(
1724            bincode::serialize(&pin(Predicate::NumericWithin {
1725                field: "year".into(),
1726                tolerance: 2.0,
1727            }))
1728            .unwrap(),
1729            vec![
1730                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,
1731                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,
1732                1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1733            ]
1734        );
1735        assert_eq!(
1736            bincode::serialize(&pin(Predicate::GeoRadius {
1737                field: "loc".into(),
1738                km: 400.0,
1739            }))
1740            .unwrap(),
1741            vec![
1742                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,
1743                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,
1744                0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1745            ]
1746        );
1747        assert_eq!(
1748            bincode::serialize(&pin(Predicate::VectorSimilar {
1749                field: "emb".into(),
1750                min: 0.9,
1751            }))
1752            .unwrap(),
1753            vec![
1754                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,
1755                66, 6, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 101, 109, 98, 205, 204, 204, 204, 204, 204,
1756                236, 63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1757            ]
1758        );
1759    }
1760
1761    #[test]
1762    fn any_variant_is_appended_at_discriminant_7() {
1763        // Any is discriminant 7 (appended after VectorSimilar=6).
1764        // Old WAL/snapshot records never contain discriminant 7, so old data
1765        // still round-trips via the existing variants 0–6.
1766        //
1767        // Exact-bytes pin for Any([FieldEqual{field:"f"}]) via pin():
1768        //   name "r"        → [1,0,0,0,0,0,0,0, 114]
1769        //   src_label "A"   → [1,0,0,0,0,0,0,0, 65]
1770        //   dst_label "B"   → [1,0,0,0,0,0,0,0, 66]
1771        //   disc 7 (Any)    → [7,0,0,0]
1772        //   vec len 1       → [1,0,0,0,0,0,0,0]
1773        //   disc 1 (FE)     → [1,0,0,0]
1774        //   field "f"       → [1,0,0,0,0,0,0,0, 102]
1775        //   edge_type "E"   → [1,0,0,0,0,0,0,0, 69]
1776        //   weight/edges/approx → [0,0,0]
1777        //   via_label/via_edge/via_dir (all None, Plan 14 T2) → [0,0,0]
1778        let any_fe = pin(Predicate::Any(vec![Predicate::FieldEqual {
1779            field: "f".into(),
1780        }]));
1781        assert_eq!(
1782            bincode::serialize(&any_fe).unwrap(),
1783            vec![
1784                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,
1785                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,
1786                0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0
1787            ],
1788            "Any([FieldEqual{{f}}]) exact-bytes pin failed — discriminant or field layout changed"
1789        );
1790        // Verify round-trip.
1791        let decoded: RuleDef = bincode::deserialize(&bincode::serialize(&any_fe).unwrap()).unwrap();
1792        assert_eq!(decoded, any_fe, "Any must round-trip via bincode");
1793        // Verify that VectorSimilar (old variant) still decodes cleanly — adding
1794        // via fields does not change how the Predicate discriminant 6 is read.
1795        let vs = pin(Predicate::VectorSimilar {
1796            field: "emb".into(),
1797            min: 0.9,
1798        });
1799        let vs_bytes = bincode::serialize(&vs).unwrap();
1800        let vs_decoded: RuleDef = bincode::deserialize(&vs_bytes).unwrap();
1801        assert_eq!(
1802            vs_decoded.predicate,
1803            Predicate::VectorSimilar {
1804                field: "emb".into(),
1805                min: 0.9
1806            },
1807            "VectorSimilar record must still decode after via fields appended"
1808        );
1809    }
1810
1811    #[test]
1812    fn approximate_variant_has_pinned_encoding() {
1813        // Pin: VectorSimilar with approximate=true.
1814        // Layout: ... 69 (edge_type 'E') | 0 (weight None) | 0 (max_edges None)
1815        //         | 1 (approximate=true) | 0 0 0 (via fields all None).
1816        assert_eq!(
1817            bincode::serialize(&pin_approx(Predicate::VectorSimilar {
1818                field: "emb".into(),
1819                min: 0.9,
1820            }))
1821            .unwrap(),
1822            vec![
1823                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,
1824                66, 6, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 101, 109, 98, 205, 204, 204, 204, 204, 204,
1825                236, 63, 1, 0, 0, 0, 0, 0, 0, 0, 69, 0, 0, 1, 0, 0, 0, 0
1826            ]
1827        );
1828        // exact vs approx: same length; differ only at the `approximate` byte
1829        // (5th from the end; the last 4 bytes are via_label/via_edge/via_dir and
1830        // namespace, all None).
1831        let exact = bincode::serialize(&pin(Predicate::VectorSimilar {
1832            field: "emb".into(),
1833            min: 0.9,
1834        }))
1835        .unwrap();
1836        let approx = bincode::serialize(&pin_approx(Predicate::VectorSimilar {
1837            field: "emb".into(),
1838            min: 0.9,
1839        }))
1840        .unwrap();
1841        assert_eq!(exact.len(), approx.len());
1842        let n = exact.len();
1843        // Everything before `approximate` is identical.
1844        assert_eq!(&exact[..n - 5], &approx[..n - 5]);
1845        // `approximate` byte at index n-5.
1846        assert_eq!(exact[n - 5], 0u8, "exact: approximate=false");
1847        assert_eq!(approx[n - 5], 1u8, "approx: approximate=true");
1848        // Trailing via + namespace bytes are both None.
1849        assert_eq!(&exact[n - 4..], &[0u8, 0, 0, 0]);
1850        assert_eq!(&approx[n - 4..], &[0u8, 0, 0, 0]);
1851    }
1852
1853    // -----------------------------------------------------------------------
1854    // decode_rule_def — backward-compat decoder tests
1855    // -----------------------------------------------------------------------
1856
1857    fn base_legacy() -> LegacyRuleDefNoVia {
1858        LegacyRuleDefNoVia {
1859            name: "r".into(),
1860            src_label: "A".into(),
1861            dst_label: "B".into(),
1862            predicate: Predicate::FieldEqual {
1863                field: "ind".into(),
1864            },
1865            edge_type: "E".into(),
1866            weight_prop: None,
1867            max_edges: Some(10),
1868            approximate: false,
1869        }
1870    }
1871
1872    fn base_current() -> RuleDef {
1873        RuleDef {
1874            name: "r".into(),
1875            src_label: "A".into(),
1876            dst_label: "B".into(),
1877            predicate: Predicate::FieldEqual {
1878                field: "ind".into(),
1879            },
1880            edge_type: "E".into(),
1881            weight_prop: None,
1882            max_edges: Some(10),
1883            approximate: false,
1884            via_label: None,
1885            via_edge: None,
1886            via_dir: None,
1887            namespace: None,
1888        }
1889    }
1890
1891    /// (a) Legacy bytes (pre-0.1.2 wire shape) decode successfully; via_* fields
1892    /// default to None.
1893    #[test]
1894    fn decode_rule_def_legacy_roundtrip() {
1895        let legacy_bytes = bincode::serialize(&base_legacy()).unwrap();
1896        let got = decode_rule_def(&legacy_bytes).expect("legacy decode must succeed");
1897        assert_eq!(got.name, "r");
1898        assert_eq!(got.src_label, "A");
1899        assert_eq!(got.max_edges, Some(10));
1900        assert!(!got.approximate);
1901        assert!(got.via_label.is_none(), "via_label must default to None");
1902        assert!(got.via_edge.is_none(), "via_edge must default to None");
1903        assert!(got.via_dir.is_none(), "via_dir must default to None");
1904    }
1905
1906    /// (b) Current-shape bytes (with via fields) round-trip through decode_rule_def
1907    /// exactly.
1908    #[test]
1909    fn decode_rule_def_current_shape_roundtrip() {
1910        let current = RuleDef {
1911            via_label: Some("Mid".into()),
1912            via_edge: Some("hop".into()),
1913            via_dir: Some(core_storage::Direction::Out),
1914            namespace: None,
1915            ..base_current()
1916        };
1917        let bytes = bincode::serialize(&current).unwrap();
1918        let got = decode_rule_def(&bytes).expect("current-shape decode must succeed");
1919        assert_eq!(got, current);
1920    }
1921
1922    /// (c) Garbage bytes produce a descriptive Err naming both attempted decoders.
1923    #[test]
1924    fn decode_rule_def_garbage_returns_err() {
1925        let garbage = b"\xde\xad\xbe\xef\x00\x00\x00\x00";
1926        let err = decode_rule_def(garbage).unwrap_err();
1927        assert!(
1928            err.contains("current-shape"),
1929            "error must name current-shape attempt: {err}"
1930        );
1931        assert!(
1932            err.contains("legacy-shape"),
1933            "error must name legacy-shape attempt: {err}"
1934        );
1935    }
1936
1937    /// (d) CRITICAL disambiguation: bytes encoding a current-shape rule with
1938    /// via_*=None carry three trailing `0x00` bytes that legacy-shape decode
1939    /// would reject (trailing bytes), so they take the current-shape path.
1940    /// Legacy bytes lack those trailing bytes so the current-shape decoder hits
1941    /// EOF and they take the legacy path.  Both paths produce the same RuleDef.
1942    #[test]
1943    fn decode_rule_def_current_none_via_not_misidentified_as_legacy() {
1944        let current = base_current(); // via_* = None
1945        let legacy = base_legacy();
1946        let current_bytes = bincode::serialize(&current).unwrap();
1947        let legacy_bytes = bincode::serialize(&legacy).unwrap();
1948
1949        // The two encodings must differ (current has 4 extra Option::None bytes:
1950        // the three `via_*` fields plus `namespace`).
1951        assert_ne!(
1952            current_bytes, legacy_bytes,
1953            "current and legacy encodings must not be byte-identical"
1954        );
1955        assert_eq!(
1956            current_bytes.len(),
1957            legacy_bytes.len() + 4,
1958            "current is exactly 4 bytes longer (three via Option::None fields \
1959             plus namespace)"
1960        );
1961
1962        // Both decode to the same RuleDef.
1963        let from_current =
1964            decode_rule_def(&current_bytes).expect("current-shape must decode via current path");
1965        let from_legacy =
1966            decode_rule_def(&legacy_bytes).expect("legacy bytes must decode via legacy path");
1967        assert_eq!(
1968            from_current, from_legacy,
1969            "both paths must produce the same RuleDef"
1970        );
1971        assert!(from_current.via_label.is_none());
1972        assert!(from_current.via_edge.is_none());
1973        assert!(from_current.via_dir.is_none());
1974        assert!(from_current.namespace.is_none());
1975    }
1976
1977    /// The v0.6.5 wire shape (eleven fields, no `namespace`) decodes as a global
1978    /// rule, which is the behaviour it had. Its bytes are one byte shorter than
1979    /// the current shape, so `reject_trailing_bytes` keeps the two unambiguous.
1980    #[test]
1981    fn decode_rule_def_pre_namespace_shape_decodes_as_global() {
1982        let prev = LegacyRuleDefNoNamespace {
1983            name: "r".into(),
1984            src_label: "A".into(),
1985            dst_label: "B".into(),
1986            predicate: Predicate::FieldEqual {
1987                field: "ind".into(),
1988            },
1989            edge_type: "E".into(),
1990            weight_prop: None,
1991            max_edges: Some(10),
1992            approximate: false,
1993            via_label: Some("Mid".into()),
1994            via_edge: Some("hop".into()),
1995            via_dir: Some(core_storage::Direction::In),
1996        };
1997        let prev_bytes = bincode::serialize(&prev).unwrap();
1998        let got = decode_rule_def(&prev_bytes).expect("the v0.6.5 shape must still decode");
1999        assert_eq!(got.via_label.as_deref(), Some("Mid"));
2000        assert_eq!(got.via_dir, Some(core_storage::Direction::In));
2001        assert!(
2002            got.namespace.is_none(),
2003            "a rule written before namespaces existed is global"
2004        );
2005
2006        // Current bytes with namespace=None carry one extra trailing 0x00, so
2007        // they are never misread as the eleven-field shape.
2008        let current = RuleDef {
2009            namespace: None,
2010            ..base_current()
2011        };
2012        let current_bytes = bincode::serialize(&current).unwrap();
2013        let eleven = bincode::serialize(&LegacyRuleDefNoNamespace {
2014            name: current.name.clone(),
2015            src_label: current.src_label.clone(),
2016            dst_label: current.dst_label.clone(),
2017            predicate: current.predicate.clone(),
2018            edge_type: current.edge_type.clone(),
2019            weight_prop: current.weight_prop.clone(),
2020            max_edges: current.max_edges,
2021            approximate: current.approximate,
2022            via_label: current.via_label.clone(),
2023            via_edge: current.via_edge.clone(),
2024            via_dir: current.via_dir,
2025        })
2026        .unwrap();
2027        assert_eq!(current_bytes.len(), eleven.len() + 1);
2028        assert_eq!(decode_rule_def(&current_bytes).unwrap(), current);
2029
2030        // A scoped rule round-trips through the current shape.
2031        let scoped = RuleDef {
2032            namespace: Some("tenant-a".into()),
2033            ..base_current()
2034        };
2035        let bytes = bincode::serialize(&scoped).unwrap();
2036        assert_eq!(decode_rule_def(&bytes).unwrap(), scoped);
2037    }
2038
2039    /// **0.6.5 cannot read a rule 0.6.6 wrote, and that means it cannot open
2040    /// the store at all.**
2041    ///
2042    /// The `namespace` append is positional, so 0.6.6's encoding carries one
2043    /// trailing byte that 0.6.5's decoder — which knows only the eleven- and
2044    /// eight-field shapes, and also rejects trailing bytes — errors on twice.
2045    /// Both of its load sites map that to `GraphError::Corrupt`, so the open
2046    /// fails rather than degrading. Every rule 0.6.6 snapshots is re-encoded
2047    /// this way, including rules 0.6.5 itself created, and a store with a vector
2048    /// index has a rule by definition.
2049    ///
2050    /// This is the test behind the CHANGELOG's BREAKING line: the downgrade is a
2051    /// refused open, not a slower read, and an operator planning a rollback has
2052    /// to hear that from the release notes rather than from the error.
2053    #[test]
2054    fn a_0_6_5_decoder_cannot_read_a_0_6_6_rule() {
2055        use bincode::Options as _;
2056        // Exactly what 0.6.5 does: the two shapes it knows, fixint, trailing
2057        // bytes rejected.
2058        let opts = bincode::options()
2059            .with_fixint_encoding()
2060            .with_no_limit()
2061            .reject_trailing_bytes();
2062
2063        for namespace in [None, Some("tenant-a".to_string())] {
2064            let def = RuleDef {
2065                namespace,
2066                ..base_current()
2067            };
2068            let bytes = bincode::serialize(&def).unwrap();
2069
2070            assert!(
2071                opts.deserialize::<LegacyRuleDefNoNamespace>(&bytes)
2072                    .is_err(),
2073                "0.6.5's eleven-field decoder must refuse 0.6.6 bytes"
2074            );
2075            assert!(
2076                opts.deserialize::<LegacyRuleDefNoVia>(&bytes).is_err(),
2077                "0.6.5's eight-field decoder must refuse them too"
2078            );
2079            // And this build still reads them, which is the forward direction.
2080            assert_eq!(decode_rule_def(&bytes).unwrap(), def);
2081        }
2082    }
2083}