1use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14use crate::reverse_coverage::{PubFn, ReverseCoverageReport, reverse_coverage};
15use crate::schema::Contract;
16
17#[derive(Debug, Clone)]
19pub struct InferredBinding {
20 pub function: PubFn,
22 pub contract_stem: String,
24 pub equation: String,
26 pub confidence: f64,
28 pub strategy: MatchStrategy,
30}
31
32#[derive(Debug, Clone)]
34pub struct ContractSuggestion {
35 pub function: PubFn,
37 pub suggested_name: String,
39 pub suggested_tier: u8,
41 pub reason: String,
43}
44
45#[derive(Debug, Clone, Copy)]
47pub enum MatchStrategy {
48 NameMatch,
50 ModuleMatch,
52 SignatureMatch,
54}
55
56impl std::fmt::Display for MatchStrategy {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::NameMatch => write!(f, "name"),
60 Self::ModuleMatch => write!(f, "module"),
61 Self::SignatureMatch => write!(f, "signature"),
62 }
63 }
64}
65
66#[derive(Debug)]
68pub struct InferResult {
69 pub matched: Vec<InferredBinding>,
71 pub suggestions: Vec<ContractSuggestion>,
73 pub coverage: ReverseCoverageReport,
75}
76
77pub fn infer(
79 crate_dir: &Path,
80 binding_path: &Path,
81 contracts: &[(String, &Contract)],
82) -> InferResult {
83 let coverage = reverse_coverage(crate_dir, binding_path);
84
85 let mut eq_index: HashMap<String, (String, String)> = HashMap::new();
87 let mut eq_keywords: Vec<(String, String, Vec<String>)> = Vec::new();
88
89 for (stem, contract) in contracts {
90 for (eq_name, eq) in &contract.equations {
91 let normalized = normalize_name(eq_name);
92 eq_index.insert(normalized.clone(), (stem.clone(), eq_name.clone()));
93
94 let mut keywords = Vec::new();
96 keywords.extend(tokenize(&eq.formula));
97 if let Some(ref dom) = eq.domain {
98 keywords.extend(tokenize(dom));
99 }
100 keywords.push(normalized);
101 eq_keywords.push((stem.clone(), eq_name.clone(), keywords));
102 }
103 }
104
105 let bound_names: HashSet<String> = extract_bound_fn_names(binding_path);
107
108 let mut matched = Vec::new();
109 let mut suggestions = Vec::new();
110
111 for func in &coverage.unbound {
112 let fn_name = normalize_name(&func.path);
113
114 if is_trivial(&func.path) {
116 continue;
117 }
118
119 if let Some((stem, eq)) = eq_index.get(&fn_name) {
121 if !bound_names.contains(&fn_name) {
122 matched.push(InferredBinding {
123 function: func.clone(),
124 contract_stem: stem.clone(),
125 equation: eq.clone(),
126 confidence: 0.95,
127 strategy: MatchStrategy::NameMatch,
128 });
129 continue;
130 }
131 }
132
133 if let Some(best) = best_fuzzy_match(&fn_name, &eq_keywords) {
135 matched.push(InferredBinding {
136 function: func.clone(),
137 contract_stem: best.0,
138 equation: best.1,
139 confidence: best.2,
140 strategy: MatchStrategy::NameMatch,
141 });
142 continue;
143 }
144
145 let tier = infer_tier_from_path(&func.file);
147 let suggested_name = suggest_contract_name(&func.path);
148
149 if !suggested_name.is_empty() {
150 suggestions.push(ContractSuggestion {
151 function: func.clone(),
152 suggested_name,
153 suggested_tier: tier,
154 reason: format!("Tier {tier} function with no matching contract"),
155 });
156 }
157 }
158
159 matched.sort_by(|a, b| {
161 b.confidence
162 .partial_cmp(&a.confidence)
163 .unwrap_or(std::cmp::Ordering::Equal)
164 });
165
166 InferResult {
167 matched,
168 suggestions,
169 coverage,
170 }
171}
172
173fn normalize_name(name: &str) -> String {
175 name.to_lowercase()
176 .replace("::", "_")
177 .replace('-', "_")
178 .replace("_v1", "")
179 .replace("_kernel", "")
180}
181
182fn tokenize(s: &str) -> Vec<String> {
184 s.to_lowercase()
185 .split(|c: char| !c.is_alphanumeric() && c != '_')
186 .filter(|w| w.len() > 2)
187 .map(String::from)
188 .collect()
189}
190
191fn is_trivial(name: &str) -> bool {
193 let trivial = [
194 "new",
195 "default",
196 "from",
197 "into",
198 "as_ref",
199 "as_mut",
200 "len",
201 "is_empty",
202 "clone",
203 "fmt",
204 "display",
205 "debug",
206 "eq",
207 "ne",
208 "hash",
209 "cmp",
210 "partial_cmp",
211 "drop",
212 "deref",
213 "deref_mut",
214 "index",
215 "index_mut",
216 "with_",
217 "set_",
218 "get_",
219 ];
220 let lower = name.to_lowercase();
221 trivial.iter().any(|t| lower == *t || lower.starts_with(t))
222}
223
224fn best_fuzzy_match(
226 fn_name: &str,
227 eq_keywords: &[(String, String, Vec<String>)],
228) -> Option<(String, String, f64)> {
229 let mut best: Option<(String, String, f64)> = None;
230
231 for (stem, eq_name, keywords) in eq_keywords {
232 let eq_norm = normalize_name(eq_name);
233
234 let score = if fn_name.contains(&eq_norm) || eq_norm.contains(fn_name) {
236 0.85
237 } else {
238 let fn_tokens: HashSet<String> = fn_name
240 .split('_')
241 .filter(|w| w.len() > 2)
242 .map(str::to_lowercase)
243 .collect();
244 let keyword_set: HashSet<&str> = keywords.iter().map(String::as_str).collect();
245
246 let overlap = fn_tokens
247 .iter()
248 .filter(|t| keyword_set.contains(t.as_str()))
249 .count();
250
251 if overlap > 0 && !fn_tokens.is_empty() {
252 #[allow(clippy::cast_precision_loss)]
253 {
254 0.5 + 0.3 * (overlap as f64 / fn_tokens.len() as f64)
255 }
256 } else {
257 0.0
258 }
259 };
260
261 if score > 0.5 && !best.as_ref().is_some_and(|b| b.2 >= score) {
262 best = Some((stem.clone(), eq_name.clone(), score));
263 }
264 }
265
266 best
267}
268
269fn extract_bound_fn_names(binding_path: &Path) -> HashSet<String> {
271 let mut names = HashSet::new();
272 if let Ok(content) = std::fs::read_to_string(binding_path) {
273 for line in content.lines() {
274 let trimmed = line.trim();
275 let func_line = trimmed.strip_prefix("- ").unwrap_or(trimmed);
276 if let Some(rest) = func_line.strip_prefix("function:") {
277 let fname = rest.trim().trim_matches('"').trim();
278 let short = fname.rsplit("::").next().unwrap_or(fname);
279 names.insert(normalize_name(short));
280 }
281 }
282 }
283 names
284}
285
286fn infer_tier_from_path(path: &str) -> u8 {
288 let p = path.to_lowercase();
289 if p.contains("kernel") || p.contains("simd") || p.contains("avx") || p.contains("neon") {
290 1 } else if p.contains("attention") || p.contains("transformer") {
292 2 } else if p.contains("cache") || p.contains("scheduler") || p.contains("dispatch") {
294 3 } else if p.contains("train") || p.contains("optim") || p.contains("grad") {
296 4 } else if p.contains("ml") || p.contains("cluster") || p.contains("classify") {
298 5 } else {
300 3 }
302}
303
304fn suggest_contract_name(fn_name: &str) -> String {
306 let clean = fn_name.to_lowercase().replace("::", "-").replace('_', "-");
307 if clean.len() < 3 || is_trivial(fn_name) {
308 return String::new();
309 }
310 format!("{clean}-v1")
311}
312
313pub fn format_binding_entry(inferred: &InferredBinding) -> String {
315 format!(
316 " - contract: {}.yaml\n equation: {}\n module_path: ~\n function: \"{}\"\n status: not_implemented\n notes: \"Auto-inferred ({}, confidence {:.0}%)\"",
317 inferred.contract_stem,
318 inferred.equation,
319 inferred.function.path,
320 inferred.strategy,
321 inferred.confidence * 100.0,
322 )
323}
324
325pub fn format_contract_stub(suggestion: &ContractSuggestion) -> String {
327 format!(
328 r#"metadata:
329 version: "1.0.0"
330 description: "Auto-suggested contract for {fn_name}"
331 references:
332 - "pv infer — auto-generated"
333
334equations:
335 {eq_name}:
336 formula: "TODO: define equation"
337 domain: "TODO: define domain"
338
339proof_obligations:
340 - type: invariant
341 property: "TODO: define invariant"
342
343falsification_tests:
344 - id: FALSIFY-{prefix}-001
345 rule: "TODO"
346 prediction: "TODO"
347 if_fails: "TODO"
348
349kani_harnesses:
350 - id: KANI-{prefix}-001
351 obligation: "TODO"
352 bound: 16
353 strategy: bounded_int
354 solver: cadical
355"#,
356 fn_name = suggestion.function.path,
357 eq_name = suggestion.function.path.to_lowercase().replace("::", "_"),
358 prefix = suggestion
359 .function
360 .path
361 .to_uppercase()
362 .chars()
363 .take(3)
364 .collect::<String>(),
365 )
366}
367
368#[cfg(test)]
369mod tests {
370 #![allow(clippy::all)]
371 use super::*;
372
373 #[test]
374 fn test_normalize_name() {
375 assert_eq!(normalize_name("softmax_kernel_v1"), "softmax");
376 assert_eq!(normalize_name("RMSNorm"), "rmsnorm");
377 assert_eq!(normalize_name("ssm-scan"), "ssm_scan");
378 }
379
380 #[test]
381 fn test_is_trivial() {
382 assert!(is_trivial("new"));
383 assert!(is_trivial("default"));
384 assert!(is_trivial("with_capacity"));
385 assert!(is_trivial("set_threshold"));
386 assert!(!is_trivial("softmax"));
387 assert!(!is_trivial("rmsnorm"));
388 }
389
390 #[test]
391 fn test_infer_tier() {
392 assert_eq!(infer_tier_from_path("src/kernels/softmax.rs"), 1);
393 assert_eq!(infer_tier_from_path("src/nn/transformer/attention.rs"), 2);
394 assert_eq!(infer_tier_from_path("src/scheduler/mod.rs"), 3);
395 assert_eq!(infer_tier_from_path("src/train/optimizer.rs"), 4);
396 }
397
398 #[test]
399 fn test_suggest_contract_name() {
400 assert_eq!(suggest_contract_name("softmax"), "softmax-v1");
401 assert_eq!(suggest_contract_name("batch_norm"), "batch-norm-v1");
402 assert_eq!(suggest_contract_name("new"), ""); }
404
405 #[test]
406 fn test_tokenize() {
407 let tokens = tokenize("exp(x_i - max(x)) / sum");
408 assert!(tokens.contains(&"exp".to_string()));
409 assert!(tokens.contains(&"max".to_string()));
410 assert!(tokens.contains(&"sum".to_string()));
411 }
412
413 #[test]
414 fn test_fuzzy_match() {
415 let eq_keywords = vec![
416 (
417 "softmax-kernel-v1".into(),
418 "softmax".into(),
419 vec!["softmax".into(), "exp".into(), "sum".into()],
420 ),
421 (
422 "rmsnorm-kernel-v1".into(),
423 "rmsnorm".into(),
424 vec!["rmsnorm".into(), "sqrt".into(), "mean".into()],
425 ),
426 ];
427
428 let result = best_fuzzy_match("softmax_avx2", &eq_keywords);
430 assert!(result.is_some());
431 let (stem, _, conf) = result.unwrap();
432 assert_eq!(stem, "softmax-kernel-v1");
433 assert!(conf > 0.8);
434
435 let result = best_fuzzy_match("parse_config", &eq_keywords);
437 assert!(result.is_none());
438 }
439
440 #[test]
443 fn test_match_strategy_display() {
444 assert_eq!(MatchStrategy::NameMatch.to_string(), "name");
445 assert_eq!(MatchStrategy::ModuleMatch.to_string(), "module");
446 assert_eq!(MatchStrategy::SignatureMatch.to_string(), "signature");
447 }
448
449 #[test]
450 fn test_normalize_name_chained_replacements() {
451 assert_eq!(
453 normalize_name("aprender::nn::softmax"),
454 "aprender_nn_softmax"
455 );
456 assert_eq!(normalize_name("silu-kernel-v1"), "silu");
458 assert_eq!(normalize_name("gelu_kernel_v1"), "gelu");
460 assert_eq!(normalize_name("layernorm"), "layernorm");
462 assert_eq!(normalize_name("model_v1_extra_v1"), "model_extra");
464 }
465
466 #[test]
467 fn test_tokenize_filters_short_words() {
468 let tokens = tokenize("a + bb + ccc");
470 assert!(!tokens.contains(&"a".to_string()));
471 assert!(!tokens.contains(&"bb".to_string()));
472 assert!(tokens.contains(&"ccc".to_string()));
473 }
474
475 #[test]
476 fn test_tokenize_empty() {
477 let tokens = tokenize("");
478 assert!(tokens.is_empty());
479 }
480
481 #[test]
482 fn test_tokenize_all_short() {
483 let tokens = tokenize("x + y = z");
484 assert!(tokens.is_empty());
485 }
486
487 #[test]
488 fn test_is_trivial_prefix_matches() {
489 assert!(is_trivial("get_value"));
490 assert!(is_trivial("with_config"));
491 assert!(is_trivial("set_mode"));
492 assert!(is_trivial("as_ref"));
493 assert!(is_trivial("as_mut"));
494 assert!(is_trivial("len"));
495 assert!(is_trivial("is_empty"));
496 assert!(is_trivial("clone"));
497 assert!(is_trivial("fmt"));
498 assert!(is_trivial("display"));
499 assert!(is_trivial("debug"));
500 assert!(is_trivial("eq"));
501 assert!(is_trivial("ne"));
502 assert!(is_trivial("hash"));
503 assert!(is_trivial("cmp"));
504 assert!(is_trivial("partial_cmp"));
505 assert!(is_trivial("drop"));
506 assert!(is_trivial("deref"));
507 assert!(is_trivial("deref_mut"));
508 assert!(is_trivial("index"));
509 assert!(is_trivial("index_mut"));
510 assert!(is_trivial("from"));
511 assert!(is_trivial("into"));
512 }
513
514 #[test]
515 fn test_is_trivial_case_insensitive() {
516 assert!(is_trivial("New"));
517 assert!(is_trivial("DEFAULT"));
518 assert!(is_trivial("Clone"));
519 }
520
521 #[test]
522 fn test_is_trivial_non_trivial_names() {
523 assert!(!is_trivial("forward"));
524 assert!(!is_trivial("compute_attention"));
525 assert!(!is_trivial("matmul"));
526 assert!(!is_trivial("encode"));
527 }
528
529 #[test]
530 fn test_infer_tier_simd_paths() {
531 assert_eq!(infer_tier_from_path("src/simd/avx2.rs"), 1);
532 assert_eq!(infer_tier_from_path("src/kernels/neon_impl.rs"), 1);
533 assert_eq!(infer_tier_from_path("src/avx512/softmax.rs"), 1);
534 }
535
536 #[test]
537 fn test_infer_tier_transformer() {
538 assert_eq!(infer_tier_from_path("src/transformer/multi_head.rs"), 2);
539 }
540
541 #[test]
542 fn test_infer_tier_cache_and_dispatch() {
543 assert_eq!(infer_tier_from_path("src/cache/kv_store.rs"), 3);
544 assert_eq!(infer_tier_from_path("src/dispatch/router.rs"), 3);
545 }
546
547 #[test]
548 fn test_infer_tier_training() {
549 assert_eq!(infer_tier_from_path("src/optimizer/adam.rs"), 4);
550 assert_eq!(infer_tier_from_path("src/grad/backward.rs"), 4);
551 }
552
553 #[test]
554 fn test_infer_tier_classical_ml() {
555 assert_eq!(infer_tier_from_path("src/ml/kmeans.rs"), 5);
556 assert_eq!(infer_tier_from_path("src/cluster/dbscan.rs"), 5);
557 assert_eq!(infer_tier_from_path("src/classify/svm.rs"), 5);
558 }
559
560 #[test]
561 fn test_infer_tier_default() {
562 assert_eq!(infer_tier_from_path("src/utils/helpers.rs"), 3);
564 assert_eq!(infer_tier_from_path("src/lib.rs"), 3);
565 }
566
567 #[test]
568 fn test_suggest_contract_name_short() {
569 assert_eq!(suggest_contract_name("ab"), "");
571 }
572
573 #[test]
574 fn test_suggest_contract_name_with_colons() {
575 assert_eq!(
576 suggest_contract_name("aprender::nn::softmax"),
577 "aprender-nn-softmax-v1"
578 );
579 }
580
581 #[test]
582 fn test_suggest_contract_name_with_underscores() {
583 assert_eq!(suggest_contract_name("layer_norm"), "layer-norm-v1");
584 }
585
586 #[test]
587 fn test_extract_bound_fn_names_empty_file() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("binding.yaml");
590 std::fs::write(&path, "").unwrap();
591 let names = extract_bound_fn_names(&path);
592 assert!(names.is_empty());
593 }
594
595 #[test]
596 fn test_extract_bound_fn_names_nonexistent_file() {
597 let names = extract_bound_fn_names(Path::new("/nonexistent/binding.yaml"));
598 assert!(names.is_empty());
599 }
600
601 #[test]
602 fn test_extract_bound_fn_names_parses_functions() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = dir.path().join("binding.yaml");
605 let content = r#"bindings:
606 - function: "aprender::nn::softmax"
607 contract: softmax-kernel-v1.yaml
608 equation: softmax
609 status: implemented
610 - function: "aprender::nn::rmsnorm"
611 contract: rmsnorm-kernel-v1.yaml
612 equation: rmsnorm
613 status: not_implemented
614"#;
615 std::fs::write(&path, content).unwrap();
616 let names = extract_bound_fn_names(&path);
617 assert!(names.contains(&normalize_name("softmax")));
618 assert!(names.contains(&normalize_name("rmsnorm")));
619 }
620
621 #[test]
622 fn test_extract_bound_fn_names_with_quotes() {
623 let dir = tempfile::tempdir().unwrap();
624 let path = dir.path().join("binding.yaml");
625 let content = r#" - function: "crate::module::func_name"
626 - function: another_func
627"#;
628 std::fs::write(&path, content).unwrap();
629 let names = extract_bound_fn_names(&path);
630 assert!(names.contains(&normalize_name("func_name")));
631 assert!(names.contains(&normalize_name("another_func")));
632 }
633
634 #[test]
635 fn test_format_binding_entry() {
636 let binding = InferredBinding {
637 function: PubFn {
638 path: "aprender::nn::softmax".to_string(),
639 file: "src/nn/softmax.rs".to_string(),
640 line: 42,
641 has_contract_macro: false,
642 feature_gate: None,
643 },
644 contract_stem: "softmax-kernel-v1".to_string(),
645 equation: "softmax".to_string(),
646 confidence: 0.95,
647 strategy: MatchStrategy::NameMatch,
648 };
649 let entry = format_binding_entry(&binding);
650 assert!(entry.contains("softmax-kernel-v1.yaml"));
651 assert!(entry.contains("equation: softmax"));
652 assert!(entry.contains("aprender::nn::softmax"));
653 assert!(entry.contains("not_implemented"));
654 assert!(entry.contains("Auto-inferred (name, confidence 95%)"));
655 }
656
657 #[test]
658 fn test_format_binding_entry_module_match() {
659 let binding = InferredBinding {
660 function: PubFn {
661 path: "crate::attention::mha".to_string(),
662 file: "src/attention/mha.rs".to_string(),
663 line: 10,
664 has_contract_macro: false,
665 feature_gate: None,
666 },
667 contract_stem: "attention-v1".to_string(),
668 equation: "multi_head_attention".to_string(),
669 confidence: 0.70,
670 strategy: MatchStrategy::ModuleMatch,
671 };
672 let entry = format_binding_entry(&binding);
673 assert!(entry.contains("Auto-inferred (module, confidence 70%)"));
674 }
675
676 #[test]
677 fn test_format_binding_entry_signature_match() {
678 let binding = InferredBinding {
679 function: PubFn {
680 path: "crate::ops::elementwise".to_string(),
681 file: "src/ops.rs".to_string(),
682 line: 5,
683 has_contract_macro: false,
684 feature_gate: None,
685 },
686 contract_stem: "elementwise-v1".to_string(),
687 equation: "elementwise_op".to_string(),
688 confidence: 0.60,
689 strategy: MatchStrategy::SignatureMatch,
690 };
691 let entry = format_binding_entry(&binding);
692 assert!(entry.contains("Auto-inferred (signature, confidence 60%)"));
693 }
694
695 #[test]
696 fn test_format_contract_stub() {
697 let suggestion = ContractSuggestion {
698 function: PubFn {
699 path: "aprender::nn::gelu".to_string(),
700 file: "src/nn/gelu.rs".to_string(),
701 line: 1,
702 has_contract_macro: false,
703 feature_gate: None,
704 },
705 suggested_name: "gelu-v1".to_string(),
706 suggested_tier: 1,
707 reason: "Tier 1 function with no matching contract".to_string(),
708 };
709 let stub = format_contract_stub(&suggestion);
710 assert!(stub.contains("aprender::nn::gelu"));
711 assert!(stub.contains("aprender_nn_gelu"));
712 assert!(stub.contains("FALSIFY-APR-001"));
713 assert!(stub.contains("KANI-APR-001"));
714 assert!(stub.contains("metadata:"));
715 assert!(stub.contains("equations:"));
716 assert!(stub.contains("proof_obligations:"));
717 assert!(stub.contains("falsification_tests:"));
718 assert!(stub.contains("kani_harnesses:"));
719 assert!(stub.contains("pv infer"));
720 }
721
722 #[test]
723 fn test_format_contract_stub_short_prefix() {
724 let suggestion = ContractSuggestion {
725 function: PubFn {
726 path: "ab".to_string(),
727 file: "src/ab.rs".to_string(),
728 line: 1,
729 has_contract_macro: false,
730 feature_gate: None,
731 },
732 suggested_name: "ab-v1".to_string(),
733 suggested_tier: 3,
734 reason: "test".to_string(),
735 };
736 let stub = format_contract_stub(&suggestion);
737 assert!(stub.contains("FALSIFY-AB-001"));
739 assert!(stub.contains("KANI-AB-001"));
740 }
741
742 #[test]
743 fn test_fuzzy_match_keyword_overlap() {
744 let eq_keywords = vec![(
745 "attention-v1".to_string(),
746 "attention_score".to_string(),
747 vec![
748 "attention".to_string(),
749 "score".to_string(),
750 "softmax".to_string(),
751 ],
752 )];
753
754 let result = best_fuzzy_match("compute_attention_heads", &eq_keywords);
756 assert!(result.is_some());
757 let (stem, eq, conf) = result.unwrap();
758 assert_eq!(stem, "attention-v1");
759 assert_eq!(eq, "attention_score");
760 assert!(conf > 0.5);
762 assert!(conf <= 0.85);
763 }
764
765 #[test]
766 fn test_fuzzy_match_eq_contains_fn() {
767 let eq_keywords = vec![(
768 "layernorm-v1".to_string(),
769 "layer_normalization".to_string(),
770 vec!["layer".to_string(), "normalization".to_string()],
771 )];
772
773 let result = best_fuzzy_match("norm", &eq_keywords);
777 assert!(result.is_some());
778 let (_, _, conf) = result.unwrap();
779 assert!((conf - 0.85).abs() < f64::EPSILON);
780 }
781
782 #[test]
783 fn test_fuzzy_match_no_overlap_returns_none() {
784 let eq_keywords = vec![(
785 "softmax-v1".to_string(),
786 "softmax".to_string(),
787 vec!["softmax".to_string(), "exp".to_string()],
788 )];
789
790 let result = best_fuzzy_match("completely_unrelated_function", &eq_keywords);
791 assert!(result.is_none());
792 }
793
794 #[test]
795 fn test_fuzzy_match_picks_highest_confidence() {
796 let eq_keywords = vec![
797 (
798 "stem-a".to_string(),
799 "alpha".to_string(),
800 vec!["alpha".to_string(), "beta".to_string()],
801 ),
802 (
803 "stem-b".to_string(),
804 "alpha_beta".to_string(),
805 vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
806 ),
807 ];
808
809 let result = best_fuzzy_match("alpha", &eq_keywords);
813 assert!(result.is_some());
814 let (stem, _, conf) = result.unwrap();
815 assert!((conf - 0.85).abs() < f64::EPSILON);
816 assert_eq!(stem, "stem-a");
818 }
819
820 #[test]
821 fn test_fuzzy_match_empty_keywords() {
822 let eq_keywords: Vec<(String, String, Vec<String>)> = vec![];
823 let result = best_fuzzy_match("anything", &eq_keywords);
824 assert!(result.is_none());
825 }
826
827 #[test]
828 fn test_infer_with_temp_crate() {
829 use crate::schema::parse_contract_str;
830
831 let dir = tempfile::tempdir().unwrap();
833 let src = dir.path().join("src");
834 std::fs::create_dir_all(&src).unwrap();
835 std::fs::write(
836 src.join("lib.rs"),
837 "pub fn my_softmax(x: &[f32]) -> Vec<f32> { vec![] }\npub fn new() -> () { () }\n",
838 )
839 .unwrap();
840
841 let binding_path = dir.path().join("binding.yaml");
843 std::fs::write(
844 &binding_path,
845 "version: \"1.0.0\"\ntarget_crate: test\nbindings: []\n",
846 )
847 .unwrap();
848
849 let contract = parse_contract_str(
851 r#"
852metadata:
853 version: "1.0.0"
854 description: "Softmax kernel"
855equations:
856 softmax:
857 formula: "σ(x)_i = exp(x_i) / Σ exp(x_j)"
858 domain: "x ∈ ℝ^n"
859falsification_tests: []
860"#,
861 )
862 .unwrap();
863
864 let contracts = vec![("softmax-kernel-v1".to_string(), &contract)];
865 let contracts_ref: Vec<(String, &crate::schema::Contract)> =
866 contracts.iter().map(|(s, c)| (s.clone(), *c)).collect();
867
868 let result = infer(dir.path(), &binding_path, &contracts_ref);
869
870 let _ = result.coverage.total_pub_fns;
873 }
876
877 #[test]
878 fn test_infer_result_sorting() {
879 let result = InferResult {
881 matched: vec![
882 InferredBinding {
883 function: PubFn {
884 path: "low_conf".to_string(),
885 file: "a.rs".to_string(),
886 line: 1,
887 has_contract_macro: false,
888 feature_gate: None,
889 },
890 contract_stem: "a-v1".to_string(),
891 equation: "a".to_string(),
892 confidence: 0.5,
893 strategy: MatchStrategy::NameMatch,
894 },
895 InferredBinding {
896 function: PubFn {
897 path: "high_conf".to_string(),
898 file: "b.rs".to_string(),
899 line: 1,
900 has_contract_macro: false,
901 feature_gate: None,
902 },
903 contract_stem: "b-v1".to_string(),
904 equation: "b".to_string(),
905 confidence: 0.95,
906 strategy: MatchStrategy::NameMatch,
907 },
908 ],
909 suggestions: vec![],
910 coverage: ReverseCoverageReport {
911 total_pub_fns: 0,
912 bound_fns: 0,
913 annotated_fns: 0,
914 exempt_fns: 0,
915 unbound: vec![],
916 coverage_pct: 0.0,
917 },
918 };
919
920 assert_eq!(result.matched.len(), 2);
922 assert_eq!(result.suggestions.len(), 0);
923 }
924
925 #[test]
926 fn test_inferred_binding_clone_and_debug() {
927 let binding = InferredBinding {
928 function: PubFn {
929 path: "test".to_string(),
930 file: "test.rs".to_string(),
931 line: 1,
932 has_contract_macro: false,
933 feature_gate: None,
934 },
935 contract_stem: "test-v1".to_string(),
936 equation: "test_eq".to_string(),
937 confidence: 0.9,
938 strategy: MatchStrategy::NameMatch,
939 };
940 let cloned = binding.clone();
941 assert!((cloned.confidence - 0.9_f64).abs() < f64::EPSILON);
942 let debug = format!("{cloned:?}");
944 assert!(!debug.is_empty());
945 }
946
947 #[test]
948 fn test_contract_suggestion_clone_and_debug() {
949 let suggestion = ContractSuggestion {
950 function: PubFn {
951 path: "my_func".to_string(),
952 file: "src/lib.rs".to_string(),
953 line: 42,
954 has_contract_macro: false,
955 feature_gate: None,
956 },
957 suggested_name: "my-func-v1".to_string(),
958 suggested_tier: 2,
959 reason: "test reason".to_string(),
960 };
961 let cloned = suggestion.clone();
962 assert_eq!(cloned.suggested_tier, 2);
963 let debug = format!("{cloned:?}");
964 assert!(debug.contains("my_func"));
965 }
966
967 #[test]
968 fn test_match_strategy_copy() {
969 let s = MatchStrategy::SignatureMatch;
970 let copied = s;
971 assert_eq!(format!("{copied}"), "signature");
972 assert_eq!(format!("{s}"), "signature");
974 }
975}