1use 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
12pub const DEFAULT_SEED: u64 = 0x4d75_7368_726f_6f6d;
18
19pub const LOW_CARDINALITY_MAX: usize = 20;
21
22pub const VECTOR_SIMILAR_MIN: f64 = 0.8;
24
25pub const VECTOR_APPROX_THRESHOLD: usize = 2_000;
27
28#[derive(Debug, Clone)]
30pub struct SuggestConfig {
31 pub max_sample_nodes: usize,
33 pub max_sample_sources: usize,
35 pub max_examples: usize,
37 pub budget_ms: u64,
39 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#[derive(Debug, Clone, Serialize)]
64pub struct SuggestReport {
65 pub suggestions: Vec<RuleSuggestion>,
67 pub truncated: bool,
70}
71
72#[derive(Debug, Clone, Serialize)]
76pub struct RuleSuggestion {
77 pub def: RuleDef,
79 pub est_edges: u64,
84 pub examples: Vec<(String, String, f64)>,
87 pub rationale: String,
89}
90
91#[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
103fn 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
120fn 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
132fn 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#[derive(Default)]
148struct FieldProfile {
149 present: usize,
151 str_distinct: BTreeSet<String>,
153 numeric_vals: Vec<f64>,
155 list_tokens: Vec<(u32, BTreeSet<ValueKey>)>,
157 vec_entries: Vec<(u32, usize)>,
159}
160
161fn 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 p.vec_entries.push((*node_id, fvec.len()));
194 } else if let Some(toks) = list_tokens(&val) {
195 p.list_tokens.push((*node_id, toks));
197 }
198 }
199 _ => {}
200 }
201 }
202 }
203
204 profiles
205}
206
207fn 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
223fn 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
252struct 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 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 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; }
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 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 if Instant::now() >= deadline {
328 break 'outer;
329 }
330 }
331
332 let est_edges = if processed == 0 {
333 0
334 } else {
335 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
350pub 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 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 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 'detect: {
427 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 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 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; }
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 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 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 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 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 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 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 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 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 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 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 } 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}