Skip to main content

core_rules/
def.rs

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