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 };
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 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; }
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 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 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 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 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 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 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 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 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 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 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 } 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}