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