1use car_ir::ActionProposal;
22use car_verify::VerifyResult;
23use serde::Serialize;
24use serde_json::Value;
25use std::collections::{HashMap, HashSet};
26
27#[derive(Debug, Clone, Default)]
30pub struct ToolFeedback {
31 pub tool_success_rates: HashMap<String, f64>,
33 pub tool_dispatch_counts: HashMap<String, (u64, u64)>,
42}
43
44impl ToolFeedback {
45 pub fn dispatched_from_trajectories(trajectories: &[car_memgine::Trajectory]) -> Self {
77 let mut counts: HashMap<String, (u64, u64)> = HashMap::new();
80 for traj in trajectories {
81 for event in &traj.events {
82 let Some(ref tool) = event.tool else { continue };
83 let succeeded = match event.kind.as_str() {
84 "action_succeeded" => true,
85 "action_failed" => false,
86 _ => continue,
89 };
90 let entry = counts.entry(tool.clone()).or_default();
91 entry.1 += 1;
92 if succeeded {
93 entry.0 += 1;
94 }
95 }
96 }
97
98 let tool_success_rates = counts
99 .iter()
100 .map(|(tool, &(succeeded, dispatched))| {
101 let smoothed = (succeeded as f64 + 1.0) / (dispatched as f64 + 2.0);
102 (tool.clone(), smoothed)
103 })
104 .collect();
105
106 Self {
107 tool_success_rates,
108 tool_dispatch_counts: counts,
109 }
110 }
111
112 pub fn from_trajectories(trajectories: &[car_memgine::Trajectory]) -> Self {
114 let mut tool_outcomes: HashMap<String, (u64, u64)> = HashMap::new(); for traj in trajectories {
116 for event in &traj.events {
117 if let Some(ref tool) = event.tool {
118 let entry = tool_outcomes.entry(tool.clone()).or_default();
119 entry.1 += 1; if event.kind == "action_succeeded" {
121 entry.0 += 1; }
123 }
124 }
125 }
126
127 let tool_success_rates = tool_outcomes
128 .into_iter()
129 .map(|(tool, (success, total))| {
130 (
131 tool,
132 if total > 0 {
133 success as f64 / total as f64
134 } else {
135 0.5
136 },
137 )
138 })
139 .collect();
140
141 Self {
142 tool_success_rates,
143 tool_dispatch_counts: HashMap::new(),
144 }
145 }
146
147 pub fn rate(&self, tool: &str) -> f64 {
149 self.tool_success_rates.get(tool).copied().unwrap_or(0.5)
150 }
151
152 pub fn proposal_tool_confidence(&self, proposal: &ActionProposal) -> f64 {
154 let tool_calls: Vec<&str> = proposal
155 .actions
156 .iter()
157 .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
158 .filter_map(|a| a.tool.as_deref())
159 .collect();
160
161 if tool_calls.is_empty() {
162 return 1.0; }
164
165 let sum: f64 = tool_calls.iter().map(|t| self.rate(t)).sum();
166 sum / tool_calls.len() as f64
167 }
168}
169
170pub fn estimate_proposal_tokens(proposal: &ActionProposal) -> usize {
175 serde_json::to_string(proposal)
176 .map(|s| s.len() / 4)
177 .unwrap_or_else(|_| proposal.actions.len() * 32)
178}
179
180#[derive(Debug, Clone)]
182pub struct PlannerConfig {
183 pub cost_weight: f64,
186 pub action_budget: usize,
188 pub tool_call_budget: usize,
190 pub conflict_penalty: f64,
192 pub feedback_weight: f64,
196 pub token_budget: usize,
201 pub token_weight: f64,
204 pub outcome_weight: f64,
213}
214
215impl Default for PlannerConfig {
216 fn default() -> Self {
217 Self {
218 cost_weight: 0.2,
219 action_budget: 20,
220 tool_call_budget: 10,
221 conflict_penalty: 0.15,
222 feedback_weight: 0.3,
223 token_budget: 4000,
224 token_weight: 0.25,
225 outcome_weight: 0.0,
226 }
227 }
228}
229
230impl PlannerConfig {
231 pub fn from_cost_target(target: &car_ir::CostTarget) -> Self {
237 Self {
238 cost_weight: target.cost_weight.clamp(0.0, 1.0),
239 action_budget: target.target_actions as usize,
240 tool_call_budget: target.target_tool_calls as usize,
241 ..Default::default()
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize)]
248pub struct ScoredProposal {
249 pub index: usize,
251 pub score: f64,
253 pub validity: f64,
255 pub cost_efficiency: f64,
257 pub error_count: usize,
259 pub warning_count: usize,
261 pub action_count: usize,
263 pub tool_call_count: usize,
265 pub parallelism_levels: usize,
267 pub valid: bool,
269 pub state_keys_written: usize,
271 pub has_write_conflicts: bool,
273 pub historical_confidence: f64,
276 pub token_estimate: usize,
280 pub quality_per_token: f64,
283 pub outcome_value: f64,
288}
289
290pub struct Planner {
292 config: PlannerConfig,
293}
294
295impl Planner {
296 pub fn new(config: PlannerConfig) -> Self {
297 Self { config }
298 }
299
300 pub fn score(
302 &self,
303 proposal: &ActionProposal,
304 initial_state: Option<&HashMap<String, Value>>,
305 registered_tools: Option<&HashSet<String>>,
306 ) -> ScoredProposal {
307 self.score_indexed(0, proposal, initial_state, registered_tools, None)
308 }
309
310 fn score_indexed(
312 &self,
313 index: usize,
314 proposal: &ActionProposal,
315 initial_state: Option<&HashMap<String, Value>>,
316 registered_tools: Option<&HashSet<String>>,
317 feedback: Option<&ToolFeedback>,
318 ) -> ScoredProposal {
319 let vr = car_verify::verify(proposal, initial_state, registered_tools, 100);
320 self.score_from_verify(index, proposal, &vr, feedback)
321 }
322
323 fn score_from_verify(
326 &self,
327 index: usize,
328 proposal: &ActionProposal,
329 vr: &VerifyResult,
330 feedback: Option<&ToolFeedback>,
331 ) -> ScoredProposal {
332 let error_count = vr.issues.iter().filter(|i| i.severity == "error").count();
333 let warning_count = vr.issues.iter().filter(|i| i.severity == "warning").count();
334
335 let action_count = proposal.actions.len();
336 let tool_call_count = proposal
337 .actions
338 .iter()
339 .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
340 .count();
341
342 let state_keys_written = vr.simulated_state.len();
344 let has_write_conflicts = !vr.conflicts.is_empty();
345
346 let validity = if error_count > 0 {
348 0.0
349 } else {
350 let mut v = 1.0;
351 v -= warning_count as f64 * 0.1;
352 if has_write_conflicts {
354 v -= vr.conflicts.len() as f64 * self.config.conflict_penalty;
355 }
356 v.max(0.1)
357 };
358
359 let action_ratio = if self.config.action_budget > 0 {
361 1.0 - (action_count as f64 / self.config.action_budget as f64).min(1.0)
362 } else {
363 1.0
364 };
365 let tool_ratio = if self.config.tool_call_budget > 0 {
366 1.0 - (tool_call_count as f64 / self.config.tool_call_budget as f64).min(1.0)
367 } else {
368 1.0
369 };
370 let parallelism_levels = vr.execution_levels.len();
372 let parallelism_bonus = if action_count > 1 && parallelism_levels > 0 {
373 1.0 - (parallelism_levels as f64 / action_count as f64).min(1.0)
374 } else {
375 0.0
376 };
377 let token_estimate = estimate_proposal_tokens(proposal);
380 let token_ratio = if self.config.token_budget > 0 {
381 1.0 - (token_estimate as f64 / self.config.token_budget as f64).min(1.0)
382 } else {
383 1.0
384 };
385
386 let tw = self.config.token_weight.clamp(0.0, 1.0);
389 let rest = 1.0 - tw;
390 let cost_efficiency = (action_ratio * (0.4 * rest)
391 + tool_ratio * (0.4 * rest)
392 + parallelism_bonus * (0.2 * rest)
393 + token_ratio * tw)
394 .clamp(0.0, 1.0);
395
396 let historical_confidence = feedback
398 .map(|f| f.proposal_tool_confidence(proposal))
399 .unwrap_or(1.0); let score = if error_count > 0 {
404 0.0
405 } else {
406 let cw = self.config.cost_weight.clamp(0.0, 1.0);
407 let base = validity * (1.0 - cw) + cost_efficiency * cw;
409 let fw = self.config.feedback_weight.clamp(0.0, 1.0);
411 base * (1.0 - fw + fw * historical_confidence)
412 };
413
414 let quality_per_token = if token_estimate > 0 {
415 score / token_estimate as f64
416 } else {
417 score
418 };
419
420 ScoredProposal {
421 index,
422 score,
423 validity,
424 cost_efficiency,
425 error_count,
426 warning_count,
427 action_count,
428 tool_call_count,
429 parallelism_levels,
430 valid: vr.valid,
431 state_keys_written,
432 has_write_conflicts,
433 historical_confidence,
434 token_estimate,
435 quality_per_token,
436 outcome_value: 0.0,
437 }
438 }
439
440 pub fn rank(
443 &self,
444 candidates: &[ActionProposal],
445 initial_state: Option<&HashMap<String, Value>>,
446 registered_tools: Option<&HashSet<String>>,
447 ) -> Vec<ScoredProposal> {
448 self.rank_with_feedback(candidates, initial_state, registered_tools, None)
449 }
450
451 pub fn rank_with_feedback(
453 &self,
454 candidates: &[ActionProposal],
455 initial_state: Option<&HashMap<String, Value>>,
456 registered_tools: Option<&HashSet<String>>,
457 feedback: Option<&ToolFeedback>,
458 ) -> Vec<ScoredProposal> {
459 let mut scored: Vec<ScoredProposal> = candidates
460 .iter()
461 .enumerate()
462 .map(|(i, p)| self.score_indexed(i, p, initial_state, registered_tools, feedback))
463 .collect();
464
465 scored.sort_by(|a, b| {
467 b.score
468 .partial_cmp(&a.score)
469 .unwrap_or(std::cmp::Ordering::Equal)
470 .then(a.action_count.cmp(&b.action_count))
471 .then(a.index.cmp(&b.index))
475 });
476
477 scored
478 }
479
480 pub fn pick_best(
483 &self,
484 candidates: &[ActionProposal],
485 initial_state: Option<&HashMap<String, Value>>,
486 registered_tools: Option<&HashSet<String>>,
487 ) -> Option<(usize, ScoredProposal)> {
488 let ranked = self.rank_with_feedback(candidates, initial_state, registered_tools, None);
489 ranked.into_iter().find(|s| s.valid).map(|s| (s.index, s))
490 }
491
492 pub fn rank_with_outcome(
512 &self,
513 candidates: &[ActionProposal],
514 initial_state: Option<&HashMap<String, Value>>,
515 registered_tools: Option<&HashSet<String>>,
516 feedback: Option<&ToolFeedback>,
517 model: &dyn car_verify::cwm::EffectModel,
518 value: &dyn ValueFunction,
519 ) -> Vec<ScoredProposal> {
520 let ow = self.config.outcome_weight.clamp(0.0, 1.0);
521 let mut scored: Vec<ScoredProposal> = candidates
522 .iter()
523 .enumerate()
524 .map(|(i, p)| {
525 let mut sp = self.score_indexed(i, p, initial_state, registered_tools, feedback);
526 let predicted = car_verify::simulate_with_model(p, initial_state, model);
528 sp.outcome_value = value.value(&predicted).clamp(0.0, 1.0);
529 if sp.error_count == 0 && ow > 0.0 {
532 sp.score = sp.score * (1.0 - ow) + sp.outcome_value * ow;
533 sp.quality_per_token = if sp.token_estimate > 0 {
534 sp.score / sp.token_estimate as f64
535 } else {
536 sp.score
537 };
538 }
539 sp
540 })
541 .collect();
542
543 scored.sort_by(|a, b| {
544 b.score
545 .partial_cmp(&a.score)
546 .unwrap_or(std::cmp::Ordering::Equal)
547 .then(a.action_count.cmp(&b.action_count))
548 .then(a.index.cmp(&b.index))
552 });
553 scored
554 }
555}
556
557pub trait ValueFunction {
562 fn value(&self, final_state: &HashMap<String, Value>) -> f64;
563}
564
565pub struct GoalKeysValue {
571 pub goals: HashMap<String, Value>,
572}
573
574impl GoalKeysValue {
575 pub fn new(goals: HashMap<String, Value>) -> Self {
576 Self { goals }
577 }
578}
579
580fn values_equal(a: &Value, b: &Value) -> bool {
583 match (a, b) {
584 (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
585 (Some(fx), Some(fy)) => fx == fy,
586 _ => x == y,
587 },
588 (Value::Array(xs), Value::Array(ys)) => {
589 xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| values_equal(x, y))
590 }
591 (Value::Object(xs), Value::Object(ys)) => {
592 xs.len() == ys.len()
593 && xs
594 .iter()
595 .all(|(k, x)| ys.get(k).is_some_and(|y| values_equal(x, y)))
596 }
597 _ => a == b,
598 }
599}
600
601impl ValueFunction for GoalKeysValue {
602 fn value(&self, final_state: &HashMap<String, Value>) -> f64 {
603 if self.goals.is_empty() {
604 return 1.0;
605 }
606 let satisfied = self
607 .goals
608 .iter()
609 .filter(|(k, v)| final_state.get(*k).is_some_and(|fv| values_equal(fv, v)))
610 .count();
611 satisfied as f64 / self.goals.len() as f64
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use car_ir::*;
619
620 fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
621 {
622 let mut a = Action::new(ActionType::ToolCall);
623 a.id = format!("a-{}", tool);
624 a.tool = Some(tool.to_string());
625 a.parameters = params;
626 a
627 }
628 }
629
630 fn state_write(key: &str, value: Value) -> Action {
631 {
632 let mut a = Action::new(ActionType::StateWrite);
633 a.id = format!("sw-{}", key);
634 a.parameters = [
635 ("key".to_string(), Value::from(key)),
636 ("value".to_string(), value),
637 ]
638 .into();
639 a.max_retries = 0;
640 a
641 }
642 }
643
644 fn proposal(id: &str, actions: Vec<Action>) -> ActionProposal {
645 ActionProposal {
646 id: id.to_string(),
647 source: "test".to_string(),
648 actions,
649 timestamp: chrono::DateTime::from_timestamp(0, 0).expect("epoch is a valid timestamp"),
660 context: HashMap::new(),
661 }
662 }
663
664 #[test]
665 fn score_clean_proposal() {
666 let planner = Planner::new(PlannerConfig::default());
667 let tools: HashSet<String> = ["search".into()].into();
668 let p = proposal(
669 "p1",
670 vec![tool_call(
671 "search",
672 [("q".into(), Value::from("rust"))].into(),
673 )],
674 );
675
676 let scored = planner.score(&p, None, Some(&tools));
677 assert!(scored.valid);
678 assert!(scored.score > 0.5);
679 assert_eq!(scored.error_count, 0);
680 assert_eq!(scored.action_count, 1);
681 assert_eq!(scored.tool_call_count, 1);
682 }
683
684 #[test]
685 fn score_invalid_proposal_unregistered_tool() {
686 let planner = Planner::new(PlannerConfig::default());
687 let tools: HashSet<String> = ["search".into()].into();
688 let p = proposal("p1", vec![tool_call("nonexistent", HashMap::new())]);
689
690 let scored = planner.score(&p, None, Some(&tools));
691 assert!(!scored.valid);
692 assert_eq!(scored.validity, 0.0);
693 assert!(scored.error_count > 0);
694 }
695
696 #[test]
697 fn rank_prefers_valid_over_invalid() {
698 let planner = Planner::new(PlannerConfig::default());
699 let tools: HashSet<String> = ["search".into()].into();
700
701 let valid = proposal(
702 "valid",
703 vec![tool_call(
704 "search",
705 [("q".into(), Value::from("test"))].into(),
706 )],
707 );
708 let invalid = proposal("invalid", vec![tool_call("nonexistent", HashMap::new())]);
709
710 let ranked = planner.rank(&[invalid, valid], None, Some(&tools));
711 assert!(ranked[0].valid);
712 assert!(!ranked[1].valid);
713 assert_eq!(ranked[0].index, 1); }
715
716 #[test]
717 fn rank_prefers_cheaper_among_valid() {
718 let planner = Planner::new(PlannerConfig {
719 cost_weight: 0.5, action_budget: 10,
721 tool_call_budget: 5,
722 ..Default::default()
723 });
724 let tools: HashSet<String> = ["a".into(), "b".into(), "c".into()].into();
725
726 let cheap = proposal("cheap", vec![tool_call("a", HashMap::new())]);
727 let expensive = proposal(
728 "expensive",
729 vec![
730 tool_call("a", HashMap::new()),
731 tool_call("b", HashMap::new()),
732 tool_call("c", HashMap::new()),
733 ],
734 );
735
736 let ranked = planner.rank(&[expensive, cheap], None, Some(&tools));
737 assert_eq!(ranked[0].index, 1); assert!(ranked[0].cost_efficiency > ranked[1].cost_efficiency);
740 }
741
742 #[test]
743 fn pick_best_skips_invalid() {
744 let planner = Planner::new(PlannerConfig::default());
745 let tools: HashSet<String> = ["ok".into()].into();
746
747 let bad = proposal("bad", vec![tool_call("nonexistent", HashMap::new())]);
748 let good = proposal("good", vec![tool_call("ok", HashMap::new())]);
749
750 let result = planner.pick_best(&[bad, good], None, Some(&tools));
751 assert!(result.is_some());
752 let (idx, scored) = result.unwrap();
753 assert_eq!(idx, 1);
754 assert!(scored.valid);
755 }
756
757 #[test]
758 fn pick_best_returns_none_when_all_invalid() {
759 let planner = Planner::new(PlannerConfig::default());
760 let tools: HashSet<String> = HashSet::new();
761
762 let bad1 = proposal("bad1", vec![tool_call("x", HashMap::new())]);
763 let bad2 = proposal("bad2", vec![tool_call("y", HashMap::new())]);
764
765 let result = planner.pick_best(&[bad1, bad2], None, Some(&tools));
766 assert!(result.is_none());
767 }
768
769 #[test]
770 fn score_state_write_only() {
771 let planner = Planner::new(PlannerConfig::default());
772 let p = proposal("sw", vec![state_write("key", Value::from("value"))]);
773
774 let scored = planner.score(&p, None, None);
775 assert!(scored.valid);
776 assert_eq!(scored.tool_call_count, 0);
777 assert_eq!(scored.action_count, 1);
778 }
779
780 #[test]
781 fn parallelism_bonus_rewards_independent_actions() {
782 let planner = Planner::new(PlannerConfig {
783 cost_weight: 0.5,
784 action_budget: 10,
785 tool_call_budget: 5,
786 ..Default::default()
787 });
788 let tools: HashSet<String> = ["a".into(), "b".into()].into();
789
790 let parallel = proposal(
792 "par",
793 vec![
794 tool_call("a", HashMap::new()),
795 tool_call("b", HashMap::new()),
796 ],
797 );
798
799 let mut seq_actions = vec![
801 tool_call("a", HashMap::new()),
802 tool_call("b", HashMap::new()),
803 ];
804 seq_actions[1].state_dependencies.push("key".into());
805 seq_actions[0]
807 .expected_effects
808 .insert("key".into(), Value::from("v"));
809 let sequential = proposal("seq", seq_actions);
810
811 let par_score = planner.score(¶llel, None, Some(&tools));
812 let seq_score = planner.score(&sequential, None, Some(&tools));
813
814 assert!(
816 par_score.cost_efficiency >= seq_score.cost_efficiency,
817 "parallel={:.3} should >= sequential={:.3}",
818 par_score.cost_efficiency,
819 seq_score.cost_efficiency
820 );
821 }
822
823 #[test]
824 fn state_write_tracks_keys() {
825 let planner = Planner::new(PlannerConfig::default());
826 let p = proposal(
827 "sw",
828 vec![
829 state_write("key_a", Value::from("val_a")),
830 state_write("key_b", Value::from("val_b")),
831 ],
832 );
833
834 let scored = planner.score(&p, None, None);
835 assert!(scored.valid);
836 assert_eq!(scored.state_keys_written, 2);
837 assert!(!scored.has_write_conflicts);
838 }
839
840 #[test]
841 fn write_conflict_penalizes_score() {
842 let planner = Planner::new(PlannerConfig::default());
843 let p = proposal(
845 "conflict",
846 vec![
847 state_write("shared_key", Value::from("v1")),
848 state_write("shared_key", Value::from("v2")),
849 ],
850 );
851
852 let scored = planner.score(&p, None, None);
853 assert!(scored.has_write_conflicts);
854 assert!(
856 scored.validity < 1.0,
857 "expected conflict penalty, got validity={:.3}",
858 scored.validity
859 );
860 }
861
862 #[test]
863 fn feedback_penalizes_tools_that_fail_often() {
864 let planner = Planner::new(PlannerConfig::default());
865 let tools: HashSet<String> = ["reliable".into(), "flaky".into()].into();
866
867 let reliable_plan = proposal("reliable", vec![tool_call("reliable", HashMap::new())]);
868 let flaky_plan = proposal("flaky", vec![tool_call("flaky", HashMap::new())]);
869
870 let feedback = ToolFeedback {
871 tool_success_rates: [("reliable".into(), 0.95), ("flaky".into(), 0.2)].into(),
872 ..Default::default()
873 };
874
875 let ranked = planner.rank_with_feedback(
876 &[flaky_plan, reliable_plan],
877 None,
878 Some(&tools),
879 Some(&feedback),
880 );
881
882 assert_eq!(ranked[0].index, 1, "reliable plan should rank first");
884 assert!(ranked[0].historical_confidence > ranked[1].historical_confidence);
885 assert!(
886 ranked[0].score > ranked[1].score,
887 "reliable={:.3} should > flaky={:.3}",
888 ranked[0].score,
889 ranked[1].score
890 );
891 }
892
893 #[test]
894 fn feedback_from_trajectories() {
895 use car_memgine::{TraceEvent, Trajectory, TrajectoryOutcome};
896
897 let trajectories = vec![
898 Trajectory {
899 proposal_id: "t1".into(),
900 source: "test".into(),
901 action_count: 1,
902 events: vec![TraceEvent {
903 kind: "action_succeeded".into(),
904 action_id: Some("a1".into()),
905 tool: Some("good_tool".into()),
906 data: serde_json::json!({}),
907 ..Default::default()
908 }],
909 outcome: TrajectoryOutcome::Success,
910 timestamp: chrono::Utc::now(),
911 duration_ms: 100.0,
912 replan_attempts: 0,
913 },
914 Trajectory {
915 proposal_id: "t2".into(),
916 source: "test".into(),
917 action_count: 1,
918 events: vec![TraceEvent {
919 kind: "action_failed".into(),
920 action_id: Some("a2".into()),
921 tool: Some("bad_tool".into()),
922 data: serde_json::json!({}),
923 ..Default::default()
924 }],
925 outcome: TrajectoryOutcome::Failed,
926 timestamp: chrono::Utc::now(),
927 duration_ms: 50.0,
928 replan_attempts: 0,
929 },
930 ];
931
932 let feedback = ToolFeedback::from_trajectories(&trajectories);
933 assert!((feedback.rate("good_tool") - 1.0).abs() < 0.01);
934 assert!((feedback.rate("bad_tool") - 0.0).abs() < 0.01);
935 assert!((feedback.rate("unknown") - 0.5).abs() < 0.01); }
937
938 #[test]
939 fn token_estimate_surfaced_and_nonzero() {
940 let planner = Planner::new(PlannerConfig::default());
941 let tools: HashSet<String> = ["a".into()].into();
942 let p = proposal("p1", vec![tool_call("a", HashMap::new())]);
943
944 let scored = planner.score(&p, None, Some(&tools));
945 assert!(scored.token_estimate > 0);
946 assert!(scored.quality_per_token > 0.0);
947 assert!(
948 (scored.quality_per_token - scored.score / scored.token_estimate as f64).abs() < 1e-9
949 );
950 }
951
952 #[test]
953 fn tiny_token_budget_penalizes_proposals() {
954 let planner = Planner::new(PlannerConfig {
957 token_budget: 10, token_weight: 0.8,
959 cost_weight: 0.5,
960 ..Default::default()
961 });
962 let tools: HashSet<String> = ["a".into()].into();
963 let p = proposal("p1", vec![tool_call("a", HashMap::new())]);
964 let scored = planner.score(&p, None, Some(&tools));
965 assert!(scored.valid);
966 assert!(
967 scored.cost_efficiency < 0.5,
968 "small token budget should tank cost_efficiency, got {:.3}",
969 scored.cost_efficiency
970 );
971 }
972
973 #[test]
974 fn token_weight_zero_matches_legacy_blend() {
975 let planner = Planner::new(PlannerConfig {
978 token_weight: 0.0,
979 ..Default::default()
980 });
981 let tools: HashSet<String> = ["a".into()].into();
982 let p = proposal("p1", vec![tool_call("a", HashMap::new())]);
983 let scored = planner.score(&p, None, Some(&tools));
984 let action_ratio = 1.0 - (1.0 / 20.0); let tool_ratio = 1.0 - (1.0 / 10.0); let expected = action_ratio * 0.4 + tool_ratio * 0.4 + 0.0 * 0.2;
987 assert!(
988 (scored.cost_efficiency - expected).abs() < 1e-6,
989 "cost_efficiency={:.6} expected={:.6}",
990 scored.cost_efficiency,
991 expected
992 );
993 }
994
995 #[test]
996 fn no_feedback_means_full_confidence() {
997 let planner = Planner::new(PlannerConfig::default());
998 let tools: HashSet<String> = ["a".into()].into();
999 let p = proposal("p1", vec![tool_call("a", HashMap::new())]);
1000
1001 let scored = planner.score(&p, None, Some(&tools));
1002 assert!((scored.historical_confidence - 1.0).abs() < 0.01);
1003 }
1004
1005 use car_verify::cwm::{GatedEffectModel, GatedPrediction};
1008
1009 #[test]
1010 fn goal_keys_value_is_fraction_satisfied() {
1011 let goals: HashMap<String, Value> = [
1012 ("x".to_string(), Value::from(1)),
1013 ("y".to_string(), Value::from(2)),
1014 ]
1015 .into();
1016 let v = GoalKeysValue::new(goals);
1017
1018 let mut state: HashMap<String, Value> = HashMap::new();
1019 state.insert("x".to_string(), Value::from(1.0)); assert!((v.value(&state) - 0.5).abs() < 1e-9);
1021 state.insert("y".to_string(), Value::from(2));
1022 assert!((v.value(&state) - 1.0).abs() < 1e-9);
1023 }
1024
1025 #[test]
1026 fn empty_goal_is_vacuously_satisfied() {
1027 let v = GoalKeysValue::new(HashMap::new());
1028 assert_eq!(v.value(&HashMap::new()), 1.0);
1029 }
1030
1031 #[test]
1032 fn outcome_weight_zero_is_a_noop() {
1033 let planner = Planner::new(PlannerConfig::default());
1036 let tools: HashSet<String> = ["search".into()].into();
1037 let p = proposal(
1038 "p1",
1039 vec![tool_call("search", [("q".into(), Value::from("x"))].into())],
1040 );
1041 let base = planner.rank(std::slice::from_ref(&p), None, Some(&tools));
1042
1043 let mut preds = HashMap::new();
1044 preds.insert(
1045 "a-search".to_string(),
1046 GatedPrediction {
1047 effects: [("goal".to_string(), Value::Bool(true))].into(),
1048 accuracy: 1.0,
1049 },
1050 );
1051 let model = GatedEffectModel {
1052 predictions: preds,
1053 min_accuracy: 0.5,
1054 };
1055 let val = GoalKeysValue::new([("goal".to_string(), Value::Bool(true))].into());
1056
1057 let out = planner.rank_with_outcome(&[p], None, Some(&tools), None, &model, &val);
1058 assert!((out[0].outcome_value - 1.0).abs() < 1e-9);
1060 assert!((out[0].score - base[0].score).abs() < 1e-9);
1062 }
1063
1064 #[test]
1065 fn outcome_weight_decides_between_equal_cost_plans() {
1066 let tools: HashSet<String> = ["fetch".into()].into();
1070 let mut aa = tool_call("fetch", [("q".into(), Value::from("x"))].into());
1071 aa.id = "a-act".to_string();
1072 let mut bb = tool_call("fetch", [("q".into(), Value::from("x"))].into());
1073 bb.id = "b-act".to_string();
1074 let cands = vec![proposal("A", vec![aa]), proposal("B", vec![bb])];
1075
1076 let mut preds = HashMap::new();
1078 preds.insert(
1079 "b-act".to_string(),
1080 GatedPrediction {
1081 effects: [("goal".to_string(), Value::Bool(true))].into(),
1082 accuracy: 0.99,
1083 },
1084 );
1085 let model = GatedEffectModel {
1086 predictions: preds,
1087 min_accuracy: 0.9,
1088 };
1089 let val = GoalKeysValue::new([("goal".to_string(), Value::Bool(true))].into());
1090
1091 let planner0 = Planner::new(PlannerConfig::default());
1093 let r0 = planner0.rank_with_outcome(&cands, None, Some(&tools), None, &model, &val);
1094 assert_eq!(r0[0].index, 0);
1095
1096 let planner1 = Planner::new(PlannerConfig {
1098 outcome_weight: 0.8,
1099 ..Default::default()
1100 });
1101 let r1 = planner1.rank_with_outcome(&cands, None, Some(&tools), None, &model, &val);
1102 assert_eq!(
1103 r1[0].index, 1,
1104 "outcome weight lifts the goal-reaching plan first"
1105 );
1106
1107 let b_sp = r1.iter().find(|s| s.index == 1).unwrap();
1108 let a_sp = r1.iter().find(|s| s.index == 0).unwrap();
1109 assert!((b_sp.outcome_value - 1.0).abs() < 1e-9);
1110 assert!((a_sp.outcome_value - 0.0).abs() < 1e-9);
1111 }
1112
1113 fn trace(kind: &str, tool: &str) -> car_memgine::TraceEvent {
1116 car_memgine::TraceEvent {
1117 kind: kind.to_string(),
1118 action_id: Some(format!("a-{tool}")),
1119 tool: Some(tool.to_string()),
1120 data: Value::Null,
1121 duration_ms: None,
1122 state_before: None,
1123 state_after: None,
1124 reward: None,
1125 }
1126 }
1127
1128 fn traj(events: Vec<car_memgine::TraceEvent>) -> car_memgine::Trajectory {
1129 car_memgine::Trajectory {
1130 proposal_id: "p".into(),
1131 source: "test".into(),
1132 action_count: events.len(),
1133 events,
1134 outcome: car_memgine::TrajectoryOutcome::Success,
1135 timestamp: chrono::Utc::now(),
1136 duration_ms: 0.0,
1137 replan_attempts: 0,
1138 }
1139 }
1140
1141 #[test]
1142 fn rejections_are_not_evidence_about_the_tool() {
1143 let mut events = vec![
1147 trace("action_succeeded", "api"),
1148 trace("action_succeeded", "api"),
1149 ];
1150 for _ in 0..8 {
1151 events.push(trace("action_rejected", "api"));
1152 }
1153 let ts = vec![traj(events)];
1154
1155 let scoring = ToolFeedback::from_trajectories(&ts);
1158 assert!((scoring.rate("api") - 0.2).abs() < 1e-9);
1159
1160 let dispatched = ToolFeedback::dispatched_from_trajectories(&ts);
1163 assert!(
1164 (dispatched.rate("api") - 0.75).abs() < 1e-9,
1165 "got {}",
1166 dispatched.rate("api")
1167 );
1168 assert_eq!(dispatched.tool_dispatch_counts["api"], (2, 2));
1169 }
1170
1171 #[test]
1172 fn skipped_actions_are_excluded_too() {
1173 let ts = vec![traj(vec![
1174 trace("action_succeeded", "t"),
1175 trace("action_failed", "t"),
1176 trace("action_skipped", "t"),
1177 trace("action_skipped", "t"),
1178 ])];
1179 let f = ToolFeedback::dispatched_from_trajectories(&ts);
1180 assert_eq!(f.tool_dispatch_counts["t"], (1, 2));
1182 assert!((f.rate("t") - 0.5).abs() < 1e-9);
1183 }
1184
1185 #[test]
1186 fn smoothing_tempers_small_samples_without_a_cliff() {
1187 let one_success = ToolFeedback::dispatched_from_trajectories(&[traj(vec![trace(
1188 "action_succeeded",
1189 "t",
1190 )])]);
1191 assert!((one_success.rate("t") - 2.0 / 3.0).abs() < 1e-9);
1193
1194 let many = ToolFeedback::dispatched_from_trajectories(&[traj(
1195 (0..100).map(|_| trace("action_succeeded", "t")).collect(),
1196 )]);
1197 assert!(many.rate("t") > 0.99);
1200 assert!(many.rate("t") > one_success.rate("t"));
1201 assert_eq!(many.tool_dispatch_counts["t"], (100, 100));
1202 }
1203
1204 #[test]
1205 fn a_tool_that_never_ran_is_absent_not_a_coin_flip() {
1206 let f = ToolFeedback::dispatched_from_trajectories(&[traj(vec![trace(
1207 "action_rejected",
1208 "never_ran",
1209 )])]);
1210 assert!(!f.tool_success_rates.contains_key("never_ran"));
1213 assert!(!f.tool_dispatch_counts.contains_key("never_ran"));
1214 assert!((f.rate("never_ran") - 0.5).abs() < 1e-9);
1216 }
1217
1218 #[test]
1219 fn a_consistently_failing_tool_reads_low() {
1220 let f = ToolFeedback::dispatched_from_trajectories(&[traj(
1221 (0..50).map(|_| trace("action_failed", "broken")).collect(),
1222 )]);
1223 assert!(f.rate("broken") < 0.02, "got {}", f.rate("broken"));
1224 assert_eq!(f.tool_dispatch_counts["broken"], (0, 50));
1225 }
1226}