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                    };
483                    let examples_preview: Vec<String> = fp
484                        .str_distinct
485                        .iter()
486                        .filter(|v| dst_keys.contains(v.as_str()))
487                        .take(3)
488                        .cloned()
489                        .collect();
490                    let rationale = format!(
491                        "Field '{field}' in {src_label} ends with '_id' and {match_count} \
492                         sampled value(s) match keys in {dst_label} \
493                         (e.g. {}). Suggests a foreign-key relationship.",
494                        examples_preview.join(", ")
495                    );
496                    let preview =
497                        run_preview(&def, src_nodes, &label_nodes[*dst_label], get_prop, config);
498                    results.push(RuleSuggestion {
499                        def,
500                        est_edges: preview.est_edges,
501                        examples: preview.examples,
502                        rationale,
503                    });
504                }
505            }
506        }
507
508        // -----------------------------------------------------------------------
509        // (b) Overlap: list-field cross-label Jaccard ≥ p50
510        // -----------------------------------------------------------------------
511        for (si, src_label) in labels.iter().enumerate() {
512            let Some(src_profile) = profiles.get(*src_label) else {
513                continue;
514            };
515            let src_nodes = &label_nodes[*src_label];
516
517            for (di, dst_label) in labels.iter().enumerate() {
518                if di < si {
519                    continue; // process each (unordered) pair once
520                }
521                let Some(dst_profile) = profiles.get(*dst_label) else {
522                    continue;
523                };
524                let dst_nodes = &label_nodes[*dst_label];
525
526                for field in all_fields {
527                    let Some(src_fp) = src_profile.get(field) else {
528                        continue;
529                    };
530                    let Some(dst_fp) = dst_profile.get(field) else {
531                        continue;
532                    };
533                    if src_fp.list_tokens.is_empty() || dst_fp.list_tokens.is_empty() {
534                        continue;
535                    }
536
537                    // Sample Jaccard values from the profiled token sets.
538                    let n_src_toks = src_fp.list_tokens.len();
539                    let n_dst_toks = dst_fp.list_tokens.len();
540                    let n_pairs = 200.min(n_src_toks * n_dst_toks);
541                    let mut rng = seed
542                        .wrapping_add(0xAB_CD_EF_01u64)
543                        .wrapping_add(si as u64 * 0x1111)
544                        .wrapping_add(di as u64 * 0x2222)
545                        .wrapping_add(field.len() as u64 * 0x3333);
546
547                    let mut jaccards: Vec<f64> = Vec::with_capacity(n_pairs);
548                    for _ in 0..n_pairs {
549                        let si2 = lcg_step(&mut rng) as usize % n_src_toks;
550                        let di2 = lcg_step(&mut rng) as usize % n_dst_toks;
551                        let (_, src_toks) = &src_fp.list_tokens[si2];
552                        let (_, dst_toks) = &dst_fp.list_tokens[di2];
553                        let inter = src_toks.intersection(dst_toks).count();
554                        let union = src_toks.union(dst_toks).count();
555                        if union > 0 {
556                            jaccards.push(inter as f64 / union as f64);
557                        }
558                    }
559
560                    if jaccards.is_empty() {
561                        continue;
562                    }
563                    jaccards.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
564                    let p50 = jaccards[jaccards.len() / 2];
565                    if p50 <= 0.0 {
566                        continue;
567                    }
568
569                    let min_val = ((p50 * 100.0).round() / 100.0).clamp(0.01, 1.0);
570                    let pred = Predicate::Overlap {
571                        field: field.clone(),
572                        min: min_val,
573                    };
574                    if is_covered(existing, src_label, dst_label, &pred) {
575                        continue;
576                    }
577
578                    // Global budget check before each preview.
579                    if Instant::now() >= global_deadline {
580                        truncated = true;
581                        break 'detect;
582                    }
583
584                    let name = format!(
585                        "suggest_ov_{}_{}_{field}",
586                        src_label.to_lowercase(),
587                        dst_label.to_lowercase(),
588                    );
589                    let max_edges = Some(default_max_edges(&pred));
590                    let def = RuleDef {
591                        name,
592                        src_label: src_label.to_string(),
593                        dst_label: dst_label.to_string(),
594                        predicate: pred,
595                        edge_type: format!("OVERLAPS_{}", field.to_uppercase()),
596                        weight_prop: Some("score".into()),
597                        max_edges,
598                        approximate: false,
599                        via_label: None,
600                        via_edge: None,
601                        via_dir: None,
602                    };
603                    let rationale = format!(
604                        "Field '{field}' is a token list in both {src_label} and {dst_label}. \
605                         Sampled Jaccard p50={p50:.2}; using that as the minimum threshold \
606                         (min={min_val:.2}). Lists share common tokens suggesting semantic affinity."
607                    );
608                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
609                    results.push(RuleSuggestion {
610                        def,
611                        est_edges: preview.est_edges,
612                        examples: preview.examples,
613                        rationale,
614                    });
615                }
616            }
617        }
618
619        // -----------------------------------------------------------------------
620        // (c) FieldEqual: low-cardinality string fields with shared values
621        // -----------------------------------------------------------------------
622        for (si, src_label) in labels.iter().enumerate() {
623            let Some(src_profile) = profiles.get(*src_label) else {
624                continue;
625            };
626            let src_nodes = &label_nodes[*src_label];
627
628            for (di, dst_label) in labels.iter().enumerate() {
629                if di < si {
630                    continue;
631                }
632                let Some(dst_profile) = profiles.get(*dst_label) else {
633                    continue;
634                };
635                let dst_nodes = &label_nodes[*dst_label];
636
637                for field in all_fields {
638                    let Some(src_fp) = src_profile.get(field) else {
639                        continue;
640                    };
641                    let Some(dst_fp) = dst_profile.get(field) else {
642                        continue;
643                    };
644                    if src_fp.str_distinct.is_empty() || dst_fp.str_distinct.is_empty() {
645                        continue;
646                    }
647                    if src_fp.str_distinct.len() > LOW_CARDINALITY_MAX
648                        || dst_fp.str_distinct.len() > LOW_CARDINALITY_MAX
649                    {
650                        continue;
651                    }
652                    let shared = src_fp
653                        .str_distinct
654                        .intersection(&dst_fp.str_distinct)
655                        .count();
656                    if shared == 0 {
657                        continue;
658                    }
659
660                    let pred = Predicate::FieldEqual {
661                        field: field.clone(),
662                    };
663                    if is_covered(existing, src_label, dst_label, &pred) {
664                        continue;
665                    }
666
667                    // Global budget check before each preview.
668                    if Instant::now() >= global_deadline {
669                        truncated = true;
670                        break 'detect;
671                    }
672
673                    let name = format!(
674                        "suggest_fe_{}_{}_{field}",
675                        src_label.to_lowercase(),
676                        dst_label.to_lowercase(),
677                    );
678                    let max_edges = Some(default_max_edges(&pred));
679                    let def = RuleDef {
680                        name,
681                        src_label: src_label.to_string(),
682                        dst_label: dst_label.to_string(),
683                        predicate: pred,
684                        edge_type: format!("SAME_{}", field.to_uppercase()),
685                        weight_prop: None,
686                        max_edges,
687                        approximate: false,
688                        via_label: None,
689                        via_edge: None,
690                        via_dir: None,
691                    };
692                    let rationale = format!(
693                        "Field '{field}' has low cardinality in {src_label} \
694                         ({} distinct value(s)) and {dst_label} ({} distinct value(s)), \
695                         with {shared} shared value(s). Suggests a categorical grouping predicate.",
696                        src_fp.str_distinct.len(),
697                        dst_fp.str_distinct.len(),
698                    );
699                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
700                    results.push(RuleSuggestion {
701                        def,
702                        est_edges: preview.est_edges,
703                        examples: preview.examples,
704                        rationale,
705                    });
706                }
707            }
708        }
709
710        // -----------------------------------------------------------------------
711        // (d) NumericWithin: overlapping numeric ranges → tolerance from spread
712        // -----------------------------------------------------------------------
713        for (si, src_label) in labels.iter().enumerate() {
714            let Some(src_profile) = profiles.get(*src_label) else {
715                continue;
716            };
717            let src_nodes = &label_nodes[*src_label];
718
719            for (di, dst_label) in labels.iter().enumerate() {
720                if di < si {
721                    continue;
722                }
723                let Some(dst_profile) = profiles.get(*dst_label) else {
724                    continue;
725                };
726                let dst_nodes = &label_nodes[*dst_label];
727
728                for field in all_fields {
729                    let Some(src_fp) = src_profile.get(field) else {
730                        continue;
731                    };
732                    let Some(dst_fp) = dst_profile.get(field) else {
733                        continue;
734                    };
735                    if src_fp.numeric_vals.is_empty() || dst_fp.numeric_vals.is_empty() {
736                        continue;
737                    }
738
739                    let src_min = src_fp
740                        .numeric_vals
741                        .iter()
742                        .cloned()
743                        .fold(f64::INFINITY, f64::min);
744                    let src_max = src_fp
745                        .numeric_vals
746                        .iter()
747                        .cloned()
748                        .fold(f64::NEG_INFINITY, f64::max);
749                    let dst_min = dst_fp
750                        .numeric_vals
751                        .iter()
752                        .cloned()
753                        .fold(f64::INFINITY, f64::min);
754                    let dst_max = dst_fp
755                        .numeric_vals
756                        .iter()
757                        .cloned()
758                        .fold(f64::NEG_INFINITY, f64::max);
759
760                    // Check range overlap.
761                    if src_max < dst_min || dst_max < src_min {
762                        continue;
763                    }
764
765                    let combined_min = src_min.min(dst_min);
766                    let combined_max = src_max.max(dst_max);
767                    let spread = combined_max - combined_min;
768                    if !spread.is_finite() || spread <= 0.0 {
769                        continue;
770                    }
771                    // Tolerance = spread / 4, minimum 1.0 so exact-match rules are avoided.
772                    let tolerance = (spread / 4.0).max(1.0);
773
774                    let pred = Predicate::NumericWithin {
775                        field: field.clone(),
776                        tolerance,
777                    };
778                    if is_covered(existing, src_label, dst_label, &pred) {
779                        continue;
780                    }
781
782                    // Global budget check before each preview.
783                    if Instant::now() >= global_deadline {
784                        truncated = true;
785                        break 'detect;
786                    }
787
788                    let name = format!(
789                        "suggest_nw_{}_{}_{field}",
790                        src_label.to_lowercase(),
791                        dst_label.to_lowercase(),
792                    );
793                    let max_edges = Some(default_max_edges(&pred));
794                    let def = RuleDef {
795                        name,
796                        src_label: src_label.to_string(),
797                        dst_label: dst_label.to_string(),
798                        predicate: pred,
799                        edge_type: format!("NEAR_{}", field.to_uppercase()),
800                        weight_prop: Some("score".into()),
801                        max_edges,
802                        approximate: false,
803                        via_label: None,
804                        via_edge: None,
805                        via_dir: None,
806                    };
807                    let rationale = format!(
808                        "Field '{field}' is numeric in {src_label} (range [{src_min:.2}, {src_max:.2}]) \
809                         and {dst_label} (range [{dst_min:.2}, {dst_max:.2}]); ranges overlap. \
810                         Tolerance {tolerance:.2} derived from combined spread {spread:.2}."
811                    );
812                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
813                    results.push(RuleSuggestion {
814                        def,
815                        est_edges: preview.est_edges,
816                        examples: preview.examples,
817                        rationale,
818                    });
819                }
820            }
821        }
822
823        // -----------------------------------------------------------------------
824        // (e) VectorSimilar: equal-dim float arrays → cosine similarity
825        // -----------------------------------------------------------------------
826        for (si, src_label) in labels.iter().enumerate() {
827            let Some(src_profile) = profiles.get(*src_label) else {
828                continue;
829            };
830            let src_nodes = &label_nodes[*src_label];
831
832            for (di, dst_label) in labels.iter().enumerate() {
833                if di < si {
834                    continue;
835                }
836                let Some(dst_profile) = profiles.get(*dst_label) else {
837                    continue;
838                };
839                let dst_nodes = &label_nodes[*dst_label];
840
841                for field in all_fields {
842                    let Some(src_fp) = src_profile.get(field) else {
843                        continue;
844                    };
845                    let Some(dst_fp) = dst_profile.get(field) else {
846                        continue;
847                    };
848                    if src_fp.vec_entries.is_empty() || dst_fp.vec_entries.is_empty() {
849                        continue;
850                    }
851
852                    let src_dim = dominant_dim(&src_fp.vec_entries);
853                    let dst_dim = dominant_dim(&dst_fp.vec_entries);
854                    let (Some(sdim), Some(ddim)) = (src_dim, dst_dim) else {
855                        continue;
856                    };
857                    if sdim != ddim || sdim == 0 {
858                        continue;
859                    }
860
861                    let approximate = dst_nodes.len() > VECTOR_APPROX_THRESHOLD;
862                    let pred = Predicate::VectorSimilar {
863                        field: field.clone(),
864                        min: VECTOR_SIMILAR_MIN,
865                    };
866                    if is_covered(existing, src_label, dst_label, &pred) {
867                        continue;
868                    }
869
870                    // Global budget check before each preview.
871                    if Instant::now() >= global_deadline {
872                        truncated = true;
873                        break 'detect;
874                    }
875
876                    let name = format!(
877                        "suggest_vs_{}_{}_{field}",
878                        src_label.to_lowercase(),
879                        dst_label.to_lowercase(),
880                    );
881                    let max_edges = Some(default_max_edges(&pred));
882                    let def = RuleDef {
883                        name,
884                        src_label: src_label.to_string(),
885                        dst_label: dst_label.to_string(),
886                        predicate: pred,
887                        edge_type: format!("SIMILAR_{}", field.to_uppercase()),
888                        weight_prop: Some("score".into()),
889                        max_edges,
890                        approximate,
891                        via_label: None,
892                        via_edge: None,
893                        via_dir: None,
894                    };
895                    let rationale = format!(
896                        "Field '{field}' is a float-array of dim {sdim} in both {src_label} \
897                         and {dst_label}. Suggests embedding-based similarity (min={VECTOR_SIMILAR_MIN}){}.",
898                        if approximate {
899                            ", approximate=true suggested (n>2000)"
900                        } else {
901                            ""
902                        }
903                    );
904                    let preview = run_preview(&def, src_nodes, dst_nodes, get_prop, config);
905                    results.push(RuleSuggestion {
906                        def,
907                        est_edges: preview.est_edges,
908                        examples: preview.examples,
909                        rationale,
910                    });
911                }
912            }
913        }
914    } // end 'detect block
915
916    // Sort by estimated edge count descending so the highest-value suggestions come first.
917    results.sort_by(|a, b| {
918        b.est_edges
919            .cmp(&a.est_edges)
920            .then(a.def.name.cmp(&b.def.name))
921    });
922    SuggestReport {
923        suggestions: results,
924        truncated: truncated || profiling_truncated,
925    }
926}