Skip to main content

core_rules/
suggest.rs

1/// Rule suggestion: profile the data and propose linking rules with previewed edge counts.
2///
3/// The database proposes its own schema: call [`suggest_rules`] to get a ranked list of
4/// candidate [`RuleDef`]s with estimated edge counts and example pairs. No rule is
5/// created automatically — the caller must call `db.create_rule(suggestion.def)` explicitly.
6use crate::def::{default_max_edges, evaluate, NodeView, Predicate, RuleDef};
7use core_storage::{list_tokens, Value, ValueKey};
8use serde::Serialize;
9use std::collections::{BTreeMap, BTreeSet};
10use std::time::{Duration, Instant};
11
12// ---------------------------------------------------------------------------
13// Public constants and config
14// ---------------------------------------------------------------------------
15
16/// Default seed for seeded sampling (hex encoding of "Mushroom").
17pub const DEFAULT_SEED: u64 = 0x4d75_7368_726f_6f6d;
18
19/// Maximum cardinality to propose a [`Predicate::FieldEqual`] suggestion.
20pub const LOW_CARDINALITY_MAX: usize = 20;
21
22/// Default minimum cosine similarity for [`Predicate::VectorSimilar`] suggestions.
23pub const VECTOR_SIMILAR_MIN: f64 = 0.8;
24
25/// Suggest `approximate: true` when dst label has more than this many nodes.
26pub const VECTOR_APPROX_THRESHOLD: usize = 2_000;
27
28/// Tuning parameters for [`suggest_rules`].
29#[derive(Debug, Clone)]
30pub struct SuggestConfig {
31    /// Max nodes per label sampled during profiling.
32    pub max_sample_nodes: usize,
33    /// Max source nodes per candidate during preview evaluation.
34    pub max_sample_sources: usize,
35    /// Max example pairs returned per suggestion.
36    pub max_examples: usize,
37    /// Per-candidate preview time budget in milliseconds.
38    pub budget_ms: u64,
39    /// Global time budget across all candidates in milliseconds.
40    ///
41    /// When elapsed time exceeds this value before a candidate's preview begins,
42    /// generation stops and [`SuggestReport::truncated`] is set to `true`.
43    /// Partial results are returned. The lock is held for at most this long
44    /// (plus profiling time, which is fast). Set to `0` to truncate immediately
45    /// after profiling (useful for tests).
46    pub global_budget_ms: u64,
47}
48
49impl Default for SuggestConfig {
50    fn default() -> Self {
51        Self {
52            max_sample_nodes: 10_000,
53            max_sample_sources: 200,
54            max_examples: 3,
55            budget_ms: 250,
56            global_budget_ms: 5_000,
57        }
58    }
59}
60
61/// Result of [`suggest_rules`]. Carries the candidate list and a flag indicating
62/// whether the global budget fired before all candidates were evaluated.
63#[derive(Debug, Clone, Serialize)]
64pub struct SuggestReport {
65    /// Proposed rules, sorted by `est_edges` descending.
66    pub suggestions: Vec<RuleSuggestion>,
67    /// `true` if the global time budget (`SuggestConfig::global_budget_ms`) caused
68    /// early termination. Partial results are still returned.
69    pub truncated: bool,
70}
71
72/// One suggested rule with estimated edge count, example pairs, and rationale.
73///
74/// NO auto-accept: call `db.create_rule(suggestion.def)` explicitly to apply.
75#[derive(Debug, Clone, Serialize)]
76pub struct RuleSuggestion {
77    /// The proposed rule definition (not yet created in the database).
78    pub def: RuleDef,
79    /// Estimated edge count if the rule were applied. Labeled as an estimate —
80    /// derived by extrapolating from a sample of source nodes. When
81    /// `def.max_edges` is `Some(k)`, the estimate is per-source top-k
82    /// (never more than `k × |src|`).
83    pub est_edges: u64,
84    /// Up to [`SuggestConfig::max_examples`] example `(src_key, dst_key, score)` pairs
85    /// drawn from the sample evaluation.
86    pub examples: Vec<(String, String, f64)>,
87    /// Human-readable explanation of why this rule was suggested.
88    pub rationale: String,
89}
90
91// ---------------------------------------------------------------------------
92// Seeded LCG sampler
93// ---------------------------------------------------------------------------
94
95#[inline]
96fn lcg_step(state: &mut u64) -> u64 {
97    *state = state
98        .wrapping_mul(6_364_136_223_846_793_005)
99        .wrapping_add(1_442_695_040_888_963_407);
100    *state
101}
102
103/// Seeded Fisher-Yates partial shuffle returning `k` selected indices from `0..n`.
104/// Deterministic for the same `(n, k, seed)`.
105fn sample_indices(n: usize, k: usize, seed: u64) -> Vec<usize> {
106    if n == 0 {
107        return Vec::new();
108    }
109    let take = k.min(n);
110    let mut indices: Vec<usize> = (0..n).collect();
111    let mut rng = seed;
112    for i in 0..take {
113        let r = lcg_step(&mut rng);
114        let j = i + (r as usize % (n - i));
115        indices.swap(i, j);
116    }
117    indices[..take].to_vec()
118}
119
120// ---------------------------------------------------------------------------
121// Value helpers
122// ---------------------------------------------------------------------------
123
124fn as_float_val(v: &Value) -> Option<f64> {
125    match v {
126        Value::Int(i) => Some(*i as f64),
127        Value::Float(f) if f.is_finite() => Some(*f),
128        _ => None,
129    }
130}
131
132/// Returns Some(Vec<f64>) if all items in a List are numeric (Int/Float), None otherwise.
133fn as_float_list(v: &Value) -> Option<Vec<f64>> {
134    let Value::List(items) = v else {
135        return None;
136    };
137    if items.is_empty() {
138        return None;
139    }
140    items.iter().map(as_float_val).collect()
141}
142
143// ---------------------------------------------------------------------------
144// Per-label field profile
145// ---------------------------------------------------------------------------
146
147#[derive(Default)]
148struct FieldProfile {
149    /// Count of sampled nodes that carry this field.
150    present: usize,
151    /// Distinct string values (for cardinality and KeyMatch).
152    str_distinct: BTreeSet<String>,
153    /// Sampled numeric values (for NumericWithin).
154    numeric_vals: Vec<f64>,
155    /// Token sets per node (for Overlap).
156    list_tokens: Vec<(u32, BTreeSet<ValueKey>)>,
157    /// (node_id, dimension) for float-array fields (for VectorSimilar).
158    vec_entries: Vec<(u32, usize)>,
159}
160
161/// Profile all fields for a sampled subset of `nodes`.
162fn profile_label(
163    nodes: &[(u32, String)],
164    get_prop: &dyn Fn(u32, &str) -> Option<Value>,
165    all_fields: &[String],
166    max_sample: usize,
167    seed: u64,
168) -> BTreeMap<String, FieldProfile> {
169    let sample = sample_indices(nodes.len(), max_sample, seed);
170    let mut profiles: BTreeMap<String, FieldProfile> = BTreeMap::new();
171
172    for si in sample {
173        let (node_id, _) = &nodes[si];
174        for field in all_fields {
175            let Some(val) = get_prop(*node_id, field) else {
176                continue;
177            };
178            let p = profiles.entry(field.clone()).or_default();
179            p.present += 1;
180
181            match &val {
182                Value::Str(s) => {
183                    p.str_distinct.insert(s.clone());
184                }
185                Value::Int(_) | Value::Float(_) => {
186                    if let Some(f) = as_float_val(&val) {
187                        p.numeric_vals.push(f);
188                    }
189                }
190                Value::List(_) => {
191                    if let Some(fvec) = as_float_list(&val) {
192                        // Float-array: candidate for VectorSimilar.
193                        p.vec_entries.push((*node_id, fvec.len()));
194                    } else if let Some(toks) = list_tokens(&val) {
195                        // Token list: candidate for Overlap.
196                        p.list_tokens.push((*node_id, toks));
197                    }
198                }
199                _ => {}
200            }
201        }
202    }
203
204    profiles
205}
206
207/// Returns the dimension that ≥ 80 % of `entries` agree on, or `None`.
208fn dominant_dim(entries: &[(u32, usize)]) -> Option<usize> {
209    if entries.is_empty() {
210        return None;
211    }
212    let mut counts: BTreeMap<usize, usize> = BTreeMap::new();
213    for (_, dim) in entries {
214        *counts.entry(*dim).or_default() += 1;
215    }
216    let total = entries.len();
217    counts
218        .into_iter()
219        .find(|&(_, count)| count * 10 >= total * 8)
220        .map(|(dim, _)| dim)
221}
222
223// ---------------------------------------------------------------------------
224// Dedup against existing rules
225// ---------------------------------------------------------------------------
226
227fn is_covered(existing: &[RuleDef], src_label: &str, dst_label: &str, pred: &Predicate) -> bool {
228    existing.iter().any(|r| {
229        r.src_label == src_label
230            && r.dst_label == dst_label
231            && same_pred_kind_field(&r.predicate, pred)
232    })
233}
234
235fn same_pred_kind_field(a: &Predicate, b: &Predicate) -> bool {
236    match (a, b) {
237        (Predicate::KeyMatch { field: fa }, Predicate::KeyMatch { field: fb }) => fa == fb,
238        (Predicate::FieldEqual { field: fa }, Predicate::FieldEqual { field: fb }) => fa == fb,
239        (Predicate::Overlap { field: fa, .. }, Predicate::Overlap { field: fb, .. }) => fa == fb,
240        (
241            Predicate::NumericWithin { field: fa, .. },
242            Predicate::NumericWithin { field: fb, .. },
243        ) => fa == fb,
244        (
245            Predicate::VectorSimilar { field: fa, .. },
246            Predicate::VectorSimilar { field: fb, .. },
247        ) => fa == fb,
248        _ => false,
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Per-candidate preview evaluation
254// ---------------------------------------------------------------------------
255
256struct Preview {
257    est_edges: u64,
258    examples: Vec<(String, String, f64)>,
259}
260
261fn run_preview(
262    def: &RuleDef,
263    src_nodes: &[(u32, String)],
264    dst_nodes: &[(u32, String)],
265    get_prop: &dyn Fn(u32, &str) -> Option<Value>,
266    config: &SuggestConfig,
267) -> Preview {
268    let src_n = src_nodes.len();
269    let dst_n = dst_nodes.len();
270    if src_n == 0 || dst_n == 0 {
271        return Preview {
272            est_edges: 0,
273            examples: Vec::new(),
274        };
275    }
276
277    // We do NOT use a seed here — the preview seed was baked into the def index
278    // before this call. Use a fixed offset from the def name for reproducibility.
279    let seed = def.name.bytes().fold(DEFAULT_SEED, |acc, b| {
280        acc.wrapping_mul(31).wrapping_add(b as u64)
281    });
282    let src_sample = sample_indices(src_n, config.max_sample_sources, seed);
283    let deadline = Instant::now() + Duration::from_millis(config.budget_ms);
284
285    let mut hit_edges = 0u64;
286    let mut examples: Vec<(String, String, f64)> = Vec::new();
287    let mut processed = 0usize;
288
289    'outer: for &si in &src_sample {
290        // Structural time-budget enforcement: check between source iterations.
291        if Instant::now() >= deadline {
292            break;
293        }
294        let (src_id, src_key) = &src_nodes[si];
295        let sp = |f: &str| get_prop(*src_id, f);
296        let src_view = NodeView {
297            key: src_key.as_str(),
298            props: &sp,
299        };
300
301        let mut src_hits = 0u64;
302        for (dst_id, dst_key) in dst_nodes {
303            if src_key == dst_key {
304                continue; // skip self-loops
305            }
306            let dp = |f: &str| get_prop(*dst_id, f);
307            let dst_view = NodeView {
308                key: dst_key.as_str(),
309                props: &dp,
310            };
311            if let Some(score) = evaluate(&def.predicate, &src_view, &dst_view) {
312                src_hits += 1;
313                if examples.len() < config.max_examples {
314                    examples.push((src_key.clone(), dst_key.clone(), score));
315                }
316            }
317        }
318        // Per-source top-k (engine `max_edges: Some(k)`), not global first-N.
319        let kept = match def.max_edges {
320            Some(k) => src_hits.min(k),
321            None => src_hits,
322        };
323        hit_edges += kept;
324        processed += 1;
325
326        // Second time-budget check: bail after each source completes.
327        if Instant::now() >= deadline {
328            break 'outer;
329        }
330    }
331
332    let est_edges = if processed == 0 {
333        0
334    } else {
335        // `hit_edges` is already per-source-capped; extrapolate mean kept × |src|.
336        let avg_kept = hit_edges as f64 / processed as f64;
337        let raw = (avg_kept * src_n as f64).round() as u64;
338        match def.max_edges {
339            Some(k) => raw.min(k.saturating_mul(src_n as u64)),
340            None => raw,
341        }
342    };
343
344    Preview {
345        est_edges,
346        examples,
347    }
348}
349
350// ---------------------------------------------------------------------------
351// Main entry point
352// ---------------------------------------------------------------------------
353
354/// Suggest linking rules by profiling the database and generating candidates.
355///
356/// # Arguments
357/// - `label_nodes` — maps each label name to its `(node_id, key)` pairs.
358/// - `get_prop` — returns `Some(Value)` for `(node_id, field_name)`, or `None` if absent.
359/// - `all_fields` — all field names present anywhere in the store.
360/// - `existing` — currently registered rules. Suggestions identical to an existing rule
361///   (same `src_label`, `dst_label`, predicate kind, and field) are suppressed.
362/// - `config` — tuning parameters. Use [`SuggestConfig::default()`] for the standard settings.
363/// - `seed` — seed for deterministic sampling. Use [`DEFAULT_SEED`] for the stable default.
364///
365/// Returns a [`SuggestReport`] sorted by `est_edges` descending. Never panics on an empty or
366/// degenerate database — returns an empty report instead.
367///
368/// The global budget (`config.global_budget_ms`) caps total wall time. When it fires,
369/// `report.truncated` is `true` and partial results are returned.
370pub fn suggest_rules(
371    label_nodes: &BTreeMap<String, Vec<(u32, String)>>,
372    get_prop: &dyn Fn(u32, &str) -> Option<Value>,
373    all_fields: &[String],
374    existing: &[RuleDef],
375    config: &SuggestConfig,
376    seed: u64,
377) -> SuggestReport {
378    if label_nodes.is_empty() || all_fields.is_empty() {
379        return SuggestReport {
380            suggestions: Vec::new(),
381            truncated: false,
382        };
383    }
384
385    let global_deadline = Instant::now() + Duration::from_millis(config.global_budget_ms);
386
387    // Build key sets per label for KeyMatch detection.
388    let label_keys: BTreeMap<&str, BTreeSet<&str>> = label_nodes
389        .iter()
390        .map(|(label, nodes)| {
391            let keys: BTreeSet<&str> = nodes.iter().map(|(_, k)| k.as_str()).collect();
392            (label.as_str(), keys)
393        })
394        .collect();
395
396    // Profile each label. The global deadline is checked between labels so the
397    // budget bounds the whole run, not only the candidate-preview phase; labels
398    // profiled after the deadline are skipped and the report is marked truncated.
399    let mut profiling_truncated = false;
400    let profiles: BTreeMap<String, BTreeMap<String, FieldProfile>> = label_nodes
401        .iter()
402        .enumerate()
403        .filter_map(|(i, (label, nodes))| {
404            if Instant::now() >= global_deadline {
405                profiling_truncated = true;
406                return None;
407            }
408            let label_seed = seed.wrapping_add(i as u64 ^ 0x9e37_79b9_7f4a_7c15);
409            let p = profile_label(
410                nodes,
411                get_prop,
412                all_fields,
413                config.max_sample_nodes,
414                label_seed,
415            );
416            Some((label.clone(), p))
417        })
418        .collect();
419
420    let labels: Vec<&str> = label_nodes.keys().map(String::as_str).collect();
421    let mut results: Vec<RuleSuggestion> = Vec::new();
422    let mut truncated = false;
423
424    // All candidate-generation is wrapped in a labeled block so any detector
425    // can break out early when the global deadline fires.
426    'detect: {
427        // -----------------------------------------------------------------------
428        // (a) KeyMatch: _id-suffix fields matching another label's keys
429        // -----------------------------------------------------------------------
430        for src_label in &labels {
431            let Some(src_profile) = profiles.get(*src_label) else {
432                continue;
433            };
434            let src_nodes = &label_nodes[*src_label];
435
436            for (field, fp) in src_profile {
437                if !field.ends_with("_id") || fp.str_distinct.is_empty() {
438                    continue;
439                }
440                for dst_label in &labels {
441                    let Some(dst_keys) = label_keys.get(dst_label) else {
442                        continue;
443                    };
444                    let match_count = fp
445                        .str_distinct
446                        .iter()
447                        .filter(|v| dst_keys.contains(v.as_str()))
448                        .count();
449                    if match_count == 0 {
450                        continue;
451                    }
452                    let pred = Predicate::KeyMatch {
453                        field: field.clone(),
454                    };
455                    if is_covered(existing, src_label, dst_label, &pred) {
456                        continue;
457                    }
458                    // Global budget check before each preview.
459                    if Instant::now() >= global_deadline {
460                        truncated = true;
461                        break 'detect;
462                    }
463                    let base = field.trim_end_matches("_id").to_uppercase();
464                    let name = format!(
465                        "suggest_km_{}_{}_{field}",
466                        src_label.to_lowercase(),
467                        dst_label.to_lowercase(),
468                    );
469                    let max_edges = Some(default_max_edges(&pred));
470                    let def = RuleDef {
471                        name,
472                        src_label: src_label.to_string(),
473                        dst_label: dst_label.to_string(),
474                        predicate: pred,
475                        edge_type: format!("{base}_OF"),
476                        weight_prop: None,
477                        max_edges,
478                        approximate: false,
479                        via_label: None,
480                        via_edge: None,
481                        via_dir: None,
482                        namespace: None,
483                    };
484                    let examples_preview: Vec<String> = fp
485                        .str_distinct
486                        .iter()
487                        .filter(|v| dst_keys.contains(v.as_str()))
488                        .take(3)
489                        .cloned()
490                        .collect();
491                    let rationale = format!(
492                        "Field '{field}' in {src_label} ends with '_id' and {match_count} \
493                         sampled value(s) match keys in {dst_label} \
494                         (e.g. {}). Suggests a foreign-key relationship.",
495                        examples_preview.join(", ")
496                    );
497                    let preview =
498                        run_preview(&def, src_nodes, &label_nodes[*dst_label], get_prop, config);
499                    results.push(RuleSuggestion {
500                        def,
501                        est_edges: preview.est_edges,
502                        examples: preview.examples,
503                        rationale,
504                    });
505                }
506            }
507        }
508
509        // -----------------------------------------------------------------------
510        // (b) Overlap: list-field cross-label Jaccard ≥ p50
511        // -----------------------------------------------------------------------
512        for (si, src_label) in labels.iter().enumerate() {
513            let Some(src_profile) = profiles.get(*src_label) else {
514                continue;
515            };
516            let src_nodes = &label_nodes[*src_label];
517
518            for (di, dst_label) in labels.iter().enumerate() {
519                if di < si {
520                    continue; // process each (unordered) pair once
521                }
522                let Some(dst_profile) = profiles.get(*dst_label) else {
523                    continue;
524                };
525                let dst_nodes = &label_nodes[*dst_label];
526
527                for field in all_fields {
528                    let Some(src_fp) = src_profile.get(field) else {
529                        continue;
530                    };
531                    let Some(dst_fp) = dst_profile.get(field) else {
532                        continue;
533                    };
534                    if src_fp.list_tokens.is_empty() || dst_fp.list_tokens.is_empty() {
535                        continue;
536                    }
537
538                    // Sample Jaccard values from the profiled token sets.
539                    let n_src_toks = src_fp.list_tokens.len();
540                    let n_dst_toks = dst_fp.list_tokens.len();
541                    let n_pairs = 200.min(n_src_toks * n_dst_toks);
542                    let mut rng = seed
543                        .wrapping_add(0xAB_CD_EF_01u64)
544                        .wrapping_add(si as u64 * 0x1111)
545                        .wrapping_add(di as u64 * 0x2222)
546                        .wrapping_add(field.len() as u64 * 0x3333);
547
548                    let mut jaccards: Vec<f64> = Vec::with_capacity(n_pairs);
549                    for _ in 0..n_pairs {
550                        let si2 = lcg_step(&mut rng) as usize % n_src_toks;
551                        let di2 = lcg_step(&mut rng) as usize % n_dst_toks;
552                        let (_, src_toks) = &src_fp.list_tokens[si2];
553                        let (_, dst_toks) = &dst_fp.list_tokens[di2];
554                        let inter = src_toks.intersection(dst_toks).count();
555                        let union = src_toks.union(dst_toks).count();
556                        if union > 0 {
557                            jaccards.push(inter as f64 / union as f64);
558                        }
559                    }
560
561                    if jaccards.is_empty() {
562                        continue;
563                    }
564                    jaccards.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
565                    let p50 = jaccards[jaccards.len() / 2];
566                    if p50 <= 0.0 {
567                        continue;
568                    }
569
570                    let min_val = ((p50 * 100.0).round() / 100.0).clamp(0.01, 1.0);
571                    let pred = Predicate::Overlap {
572                        field: field.clone(),
573                        min: min_val,
574                    };
575                    if is_covered(existing, src_label, dst_label, &pred) {
576                        continue;
577                    }
578
579                    // Global budget check before each preview.
580                    if Instant::now() >= global_deadline {
581                        truncated = true;
582                        break 'detect;
583                    }
584
585                    let name = format!(
586                        "suggest_ov_{}_{}_{field}",
587                        src_label.to_lowercase(),
588                        dst_label.to_lowercase(),
589                    );
590                    let max_edges = Some(default_max_edges(&pred));
591                    let def = RuleDef {
592                        name,
593                        src_label: src_label.to_string(),
594                        dst_label: dst_label.to_string(),
595                        predicate: pred,
596                        edge_type: format!("OVERLAPS_{}", field.to_uppercase()),
597                        weight_prop: Some("score".into()),
598                        max_edges,
599                        approximate: false,
600                        via_label: None,
601                        via_edge: None,
602                        via_dir: None,
603                        namespace: None,
604                    };
605                    let rationale = format!(
606                        "Field '{field}' is a token list in both {src_label} and {dst_label}. \
607                         Sampled Jaccard p50={p50:.2}; using that as the minimum threshold \
608                         (min={min_val:.2}). Lists share common tokens suggesting semantic affinity."
609                    );
610                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
611                    results.push(RuleSuggestion {
612                        def,
613                        est_edges: preview.est_edges,
614                        examples: preview.examples,
615                        rationale,
616                    });
617                }
618            }
619        }
620
621        // -----------------------------------------------------------------------
622        // (c) FieldEqual: low-cardinality string fields with shared values
623        // -----------------------------------------------------------------------
624        for (si, src_label) in labels.iter().enumerate() {
625            let Some(src_profile) = profiles.get(*src_label) else {
626                continue;
627            };
628            let src_nodes = &label_nodes[*src_label];
629
630            for (di, dst_label) in labels.iter().enumerate() {
631                if di < si {
632                    continue;
633                }
634                let Some(dst_profile) = profiles.get(*dst_label) else {
635                    continue;
636                };
637                let dst_nodes = &label_nodes[*dst_label];
638
639                for field in all_fields {
640                    let Some(src_fp) = src_profile.get(field) else {
641                        continue;
642                    };
643                    let Some(dst_fp) = dst_profile.get(field) else {
644                        continue;
645                    };
646                    if src_fp.str_distinct.is_empty() || dst_fp.str_distinct.is_empty() {
647                        continue;
648                    }
649                    if src_fp.str_distinct.len() > LOW_CARDINALITY_MAX
650                        || dst_fp.str_distinct.len() > LOW_CARDINALITY_MAX
651                    {
652                        continue;
653                    }
654                    let shared = src_fp
655                        .str_distinct
656                        .intersection(&dst_fp.str_distinct)
657                        .count();
658                    if shared == 0 {
659                        continue;
660                    }
661
662                    let pred = Predicate::FieldEqual {
663                        field: field.clone(),
664                    };
665                    if is_covered(existing, src_label, dst_label, &pred) {
666                        continue;
667                    }
668
669                    // Global budget check before each preview.
670                    if Instant::now() >= global_deadline {
671                        truncated = true;
672                        break 'detect;
673                    }
674
675                    let name = format!(
676                        "suggest_fe_{}_{}_{field}",
677                        src_label.to_lowercase(),
678                        dst_label.to_lowercase(),
679                    );
680                    let max_edges = Some(default_max_edges(&pred));
681                    let def = RuleDef {
682                        name,
683                        src_label: src_label.to_string(),
684                        dst_label: dst_label.to_string(),
685                        predicate: pred,
686                        edge_type: format!("SAME_{}", field.to_uppercase()),
687                        weight_prop: None,
688                        max_edges,
689                        approximate: false,
690                        via_label: None,
691                        via_edge: None,
692                        via_dir: None,
693                        namespace: None,
694                    };
695                    let rationale = format!(
696                        "Field '{field}' has low cardinality in {src_label} \
697                         ({} distinct value(s)) and {dst_label} ({} distinct value(s)), \
698                         with {shared} shared value(s). Suggests a categorical grouping predicate.",
699                        src_fp.str_distinct.len(),
700                        dst_fp.str_distinct.len(),
701                    );
702                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
703                    results.push(RuleSuggestion {
704                        def,
705                        est_edges: preview.est_edges,
706                        examples: preview.examples,
707                        rationale,
708                    });
709                }
710            }
711        }
712
713        // -----------------------------------------------------------------------
714        // (d) NumericWithin: overlapping numeric ranges → tolerance from spread
715        // -----------------------------------------------------------------------
716        for (si, src_label) in labels.iter().enumerate() {
717            let Some(src_profile) = profiles.get(*src_label) else {
718                continue;
719            };
720            let src_nodes = &label_nodes[*src_label];
721
722            for (di, dst_label) in labels.iter().enumerate() {
723                if di < si {
724                    continue;
725                }
726                let Some(dst_profile) = profiles.get(*dst_label) else {
727                    continue;
728                };
729                let dst_nodes = &label_nodes[*dst_label];
730
731                for field in all_fields {
732                    let Some(src_fp) = src_profile.get(field) else {
733                        continue;
734                    };
735                    let Some(dst_fp) = dst_profile.get(field) else {
736                        continue;
737                    };
738                    if src_fp.numeric_vals.is_empty() || dst_fp.numeric_vals.is_empty() {
739                        continue;
740                    }
741
742                    let src_min = src_fp
743                        .numeric_vals
744                        .iter()
745                        .cloned()
746                        .fold(f64::INFINITY, f64::min);
747                    let src_max = src_fp
748                        .numeric_vals
749                        .iter()
750                        .cloned()
751                        .fold(f64::NEG_INFINITY, f64::max);
752                    let dst_min = dst_fp
753                        .numeric_vals
754                        .iter()
755                        .cloned()
756                        .fold(f64::INFINITY, f64::min);
757                    let dst_max = dst_fp
758                        .numeric_vals
759                        .iter()
760                        .cloned()
761                        .fold(f64::NEG_INFINITY, f64::max);
762
763                    // Check range overlap.
764                    if src_max < dst_min || dst_max < src_min {
765                        continue;
766                    }
767
768                    let combined_min = src_min.min(dst_min);
769                    let combined_max = src_max.max(dst_max);
770                    let spread = combined_max - combined_min;
771                    if !spread.is_finite() || spread <= 0.0 {
772                        continue;
773                    }
774                    // Tolerance = spread / 4, minimum 1.0 so exact-match rules are avoided.
775                    let tolerance = (spread / 4.0).max(1.0);
776
777                    let pred = Predicate::NumericWithin {
778                        field: field.clone(),
779                        tolerance,
780                    };
781                    if is_covered(existing, src_label, dst_label, &pred) {
782                        continue;
783                    }
784
785                    // Global budget check before each preview.
786                    if Instant::now() >= global_deadline {
787                        truncated = true;
788                        break 'detect;
789                    }
790
791                    let name = format!(
792                        "suggest_nw_{}_{}_{field}",
793                        src_label.to_lowercase(),
794                        dst_label.to_lowercase(),
795                    );
796                    let max_edges = Some(default_max_edges(&pred));
797                    let def = RuleDef {
798                        name,
799                        src_label: src_label.to_string(),
800                        dst_label: dst_label.to_string(),
801                        predicate: pred,
802                        edge_type: format!("NEAR_{}", field.to_uppercase()),
803                        weight_prop: Some("score".into()),
804                        max_edges,
805                        approximate: false,
806                        via_label: None,
807                        via_edge: None,
808                        via_dir: None,
809                        namespace: None,
810                    };
811                    let rationale = format!(
812                        "Field '{field}' is numeric in {src_label} (range [{src_min:.2}, {src_max:.2}]) \
813                         and {dst_label} (range [{dst_min:.2}, {dst_max:.2}]); ranges overlap. \
814                         Tolerance {tolerance:.2} derived from combined spread {spread:.2}."
815                    );
816                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
817                    results.push(RuleSuggestion {
818                        def,
819                        est_edges: preview.est_edges,
820                        examples: preview.examples,
821                        rationale,
822                    });
823                }
824            }
825        }
826
827        // -----------------------------------------------------------------------
828        // (e) VectorSimilar: equal-dim float arrays → cosine similarity
829        // -----------------------------------------------------------------------
830        for (si, src_label) in labels.iter().enumerate() {
831            let Some(src_profile) = profiles.get(*src_label) else {
832                continue;
833            };
834            let src_nodes = &label_nodes[*src_label];
835
836            for (di, dst_label) in labels.iter().enumerate() {
837                if di < si {
838                    continue;
839                }
840                let Some(dst_profile) = profiles.get(*dst_label) else {
841                    continue;
842                };
843                let dst_nodes = &label_nodes[*dst_label];
844
845                for field in all_fields {
846                    let Some(src_fp) = src_profile.get(field) else {
847                        continue;
848                    };
849                    let Some(dst_fp) = dst_profile.get(field) else {
850                        continue;
851                    };
852                    if src_fp.vec_entries.is_empty() || dst_fp.vec_entries.is_empty() {
853                        continue;
854                    }
855
856                    let src_dim = dominant_dim(&src_fp.vec_entries);
857                    let dst_dim = dominant_dim(&dst_fp.vec_entries);
858                    let (Some(sdim), Some(ddim)) = (src_dim, dst_dim) else {
859                        continue;
860                    };
861                    if sdim != ddim || sdim == 0 {
862                        continue;
863                    }
864
865                    let approximate = dst_nodes.len() > VECTOR_APPROX_THRESHOLD;
866                    let pred = Predicate::VectorSimilar {
867                        field: field.clone(),
868                        min: VECTOR_SIMILAR_MIN,
869                    };
870                    if is_covered(existing, src_label, dst_label, &pred) {
871                        continue;
872                    }
873
874                    // Global budget check before each preview.
875                    if Instant::now() >= global_deadline {
876                        truncated = true;
877                        break 'detect;
878                    }
879
880                    let name = format!(
881                        "suggest_vs_{}_{}_{field}",
882                        src_label.to_lowercase(),
883                        dst_label.to_lowercase(),
884                    );
885                    let max_edges = Some(default_max_edges(&pred));
886                    let def = RuleDef {
887                        name,
888                        src_label: src_label.to_string(),
889                        dst_label: dst_label.to_string(),
890                        predicate: pred,
891                        edge_type: format!("SIMILAR_{}", field.to_uppercase()),
892                        weight_prop: Some("score".into()),
893                        max_edges,
894                        approximate,
895                        via_label: None,
896                        via_edge: None,
897                        via_dir: None,
898                        namespace: None,
899                    };
900                    let rationale = format!(
901                        "Field '{field}' is a float-array of dim {sdim} in both {src_label} \
902                         and {dst_label}. Suggests embedding-based similarity (min={VECTOR_SIMILAR_MIN}){}.",
903                        if approximate {
904                            ", approximate=true suggested (n>2000)"
905                        } else {
906                            ""
907                        }
908                    );
909                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
910                    results.push(RuleSuggestion {
911                        def,
912                        est_edges: preview.est_edges,
913                        examples: preview.examples,
914                        rationale,
915                    });
916                }
917            }
918        }
919    } // end 'detect block
920
921    // Sort by estimated edge count descending so the highest-value suggestions come first.
922    results.sort_by(|a, b| {
923        b.est_edges
924            .cmp(&a.est_edges)
925            .then(a.def.name.cmp(&b.def.name))
926    });
927    SuggestReport {
928        suggestions: results,
929        truncated: truncated || profiling_truncated,
930    }
931}