Skip to main content

car_planner/
lib.rs

1//! Proposal scoring and search for Common Agent Runtime.
2//!
3//! Sits between the model and car-engine: scores N candidate proposals using
4//! static verification + cost estimation, picks the best, and provides fallback
5//! ordering for replan scenarios.
6//!
7//! Inspired by MARS (budget-aware MCTS) — uses verify() + simulate() as the
8//! evaluation function, not LLM calls. Pure Rust, zero inference cost.
9//!
10//! ## Usage
11//!
12//! ```rust,ignore
13//! use car_planner::{Planner, PlannerConfig, ScoredProposal};
14//!
15//! let planner = Planner::new(PlannerConfig::default());
16//! let candidates = vec![proposal_a, proposal_b, proposal_c];
17//! let ranked = planner.rank(&candidates, Some(&state), Some(&tools));
18//! let best = &ranked[0]; // highest score first
19//! ```
20
21use car_ir::ActionProposal;
22use car_verify::VerifyResult;
23use serde::Serialize;
24use serde_json::Value;
25use std::collections::{HashMap, HashSet};
26
27/// Historical tool success rates computed from the trajectory store.
28/// Pass to `rank_with_feedback()` to bias scoring based on past outcomes.
29#[derive(Debug, Clone, Default)]
30pub struct ToolFeedback {
31    /// Per-tool success rate (0.0–1.0). Tools not in the map are assumed 0.5 (no data).
32    pub tool_success_rates: HashMap<String, f64>,
33    /// Per-tool `(succeeded, dispatched)` counts backing each rate, when the
34    /// feedback came from [`ToolFeedback::dispatched_from_trajectories`].
35    ///
36    /// A rate is not self-describing: `1.0` from one observation and `1.0` from
37    /// five hundred are the same number and very different evidence. Consumers
38    /// that price risk on these rates need the sample size to say how much to
39    /// trust them, so it travels alongside. Empty for feedback built by
40    /// [`ToolFeedback::from_trajectories`], which does not track it.
41    pub tool_dispatch_counts: HashMap<String, (u64, u64)>,
42}
43
44impl ToolFeedback {
45    /// Compute **dispatch-conditional** tool feedback — `P(tool succeeds | the
46    /// action was actually dispatched)`.
47    ///
48    /// This differs from [`ToolFeedback::from_trajectories`] in what lands in
49    /// the denominator, and the difference is not cosmetic. That constructor
50    /// counts *every* event carrying a tool name, including `action_rejected`
51    /// (the validator or policy refused the action) and `action_skipped` (an
52    /// upstream failure meant it never ran). Neither is evidence about the
53    /// tool: nothing was dispatched, so the tool had no opportunity to fail.
54    ///
55    /// For scoring that conflation is defensible — a tool whose actions keep
56    /// getting rejected genuinely is a worse bet, and that is the signal
57    /// `rank_with_feedback` wants. For **Monte Carlo rollout** it is a
58    /// double-count: `car_verify::simulate_monte_carlo` already models
59    /// rejection structurally, deriving it from the dependency cascade, so a
60    /// rate that has rejections baked in penalizes the tool twice and reports
61    /// a plan as more fragile than the evidence supports.
62    ///
63    /// Rates are **Laplace-smoothed** — `(succeeded + 1) / (dispatched + 2)`,
64    /// the posterior mean under a uniform prior. One success becomes 0.67
65    /// rather than a categorical 1.0, while 100/100 becomes 0.99; evidence
66    /// still dominates once there is any. This avoids the alternative of a
67    /// hard minimum-sample cliff, where a tool's rate would lurch from the
68    /// 0.5 default to 1.0 on crossing some arbitrary count. Raw counts are
69    /// preserved in [`Self::tool_dispatch_counts`] for callers that want to
70    /// smooth differently or report the underlying evidence.
71    ///
72    /// Tools that were never dispatched in the window are absent from the map
73    /// entirely rather than present at 0.5 — "no evidence" and "evidence of a
74    /// coin flip" are different claims, and the consumer applies its own
75    /// default.
76    pub fn dispatched_from_trajectories(trajectories: &[car_memgine::Trajectory]) -> Self {
77        // (succeeded, dispatched). `dispatched` counts only outcomes where the
78        // tool actually ran: succeeded or failed.
79        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                    // action_rejected / action_skipped / unknown: never
87                    // dispatched, so not evidence about this tool.
88                    _ => 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    /// Compute tool feedback from a trajectory store.
113    pub fn from_trajectories(trajectories: &[car_memgine::Trajectory]) -> Self {
114        let mut tool_outcomes: HashMap<String, (u64, u64)> = HashMap::new(); // (success, total)
115        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; // total
120                    if event.kind == "action_succeeded" {
121                        entry.0 += 1; // success
122                    }
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    /// Get the success rate for a tool, defaulting to 0.5 (no data).
148    pub fn rate(&self, tool: &str) -> f64 {
149        self.tool_success_rates.get(tool).copied().unwrap_or(0.5)
150    }
151
152    /// Average success rate across all tools in a proposal.
153    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; // no tool calls = no tool risk
163        }
164
165        let sum: f64 = tool_calls.iter().map(|t| self.rate(t)).sum();
166        sum / tool_calls.len() as f64
167    }
168}
169
170/// Rough token estimate for a proposal: serialized JSON length ÷ 4.
171/// Matches the heuristic used elsewhere in the codebase
172/// (see `MemNode::token_estimate`). Accurate enough for ranking; an
173/// embedding-tokenizer-backed estimate could replace this later.
174pub 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/// Configuration for proposal scoring.
181#[derive(Debug, Clone)]
182pub struct PlannerConfig {
183    /// Weight for cost efficiency in the score (0.0–1.0).
184    /// Higher = prefer cheaper proposals. Lower = prefer correctness.
185    pub cost_weight: f64,
186    /// Maximum actions before a proposal is penalized.
187    pub action_budget: usize,
188    /// Maximum tool calls before a proposal is penalized.
189    pub tool_call_budget: usize,
190    /// Validity penalty per write conflict detected in simulated state.
191    pub conflict_penalty: f64,
192    /// How much historical tool feedback influences the final score (0.0–1.0).
193    /// Score = base * (1.0 - feedback_weight + feedback_weight * confidence).
194    /// At 0.0 history is ignored; at 1.0 a tool with 0% success zeroes the score.
195    pub feedback_weight: f64,
196    /// Maximum estimated proposal tokens before a proposal is penalized.
197    /// The estimate comes from the serialized proposal length (≈ 4 chars/token).
198    /// Inspired by Meta-Harness (alphaXiv 2603.28052): context-token efficiency
199    /// is a first-class optimization target, not an afterthought.
200    pub token_budget: usize,
201    /// Weight of the token-efficiency term within `cost_efficiency` (0.0–1.0).
202    /// The remaining cost mass is split across actions/tools/parallelism.
203    pub token_weight: f64,
204    /// Weight of the **predicted-outcome** term in the final score (0.0–1.0),
205    /// used only by [`Planner::rank_with_outcome`]. At `0.0` (the default)
206    /// outcome scoring is off and ranking matches the static path exactly — so
207    /// adding an `EffectModel` never changes behavior unless you opt in. Higher
208    /// values let a plan that *reaches the goal* (per a verified Code World
209    /// Model's prediction) outrank a cheaper plan that doesn't. This closes the
210    /// "planning is outcome-blind" gap (Code World Models Slice 3 — see
211    /// `docs/proposals/code-world-models.md`).
212    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    /// Create a PlannerConfig from a CostTarget.
232    /// Maps soft targets into the planner's scoring parameters.
233    /// Note: `target_duration_ms` is not used by the static planner (duration
234    /// requires execution, which the planner avoids). It is reserved for
235    /// future integration with runtime cost tracking.
236    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/// A proposal with its computed score and verification result.
247#[derive(Debug, Clone, Serialize)]
248pub struct ScoredProposal {
249    /// Index into the original candidate list.
250    pub index: usize,
251    /// Overall score (0.0–1.0). Higher is better.
252    pub score: f64,
253    /// Validity score component (0.0–1.0).
254    pub validity: f64,
255    /// Cost efficiency score component (0.0–1.0).
256    pub cost_efficiency: f64,
257    /// Number of verification errors.
258    pub error_count: usize,
259    /// Number of verification warnings.
260    pub warning_count: usize,
261    /// Number of actions in the proposal.
262    pub action_count: usize,
263    /// Number of tool calls in the proposal.
264    pub tool_call_count: usize,
265    /// Number of DAG execution levels (parallelism depth).
266    pub parallelism_levels: usize,
267    /// Whether the proposal passed static verification.
268    pub valid: bool,
269    /// Number of state keys written by the simulated execution.
270    pub state_keys_written: usize,
271    /// Whether the proposal has write conflicts (multiple actions writing the same key).
272    pub has_write_conflicts: bool,
273    /// Historical tool confidence (0.0–1.0) based on trajectory feedback.
274    /// 1.0 = all tools have perfect history, 0.5 = no data, <0.5 = tools frequently fail.
275    pub historical_confidence: f64,
276    /// Estimated prompt tokens required to describe this proposal (from
277    /// serialized JSON length ÷ 4). Surfaces context cost so callers can
278    /// trade quality for token budget.
279    pub token_estimate: usize,
280    /// `score / max(token_estimate, 1)` — quality per token.
281    /// Lets callers rank by efficiency rather than raw quality when budget matters.
282    pub quality_per_token: f64,
283    /// Predicted-outcome value in `[0,1]` from a verified Code World Model +
284    /// [`ValueFunction`], set by [`Planner::rank_with_outcome`]. `0.0` on the
285    /// static paths (no model consulted) — so a value of `0.0` does not by
286    /// itself mean "bad outcome", it means "outcome not scored".
287    pub outcome_value: f64,
288}
289
290/// Proposal scorer and ranker.
291pub struct Planner {
292    config: PlannerConfig,
293}
294
295impl Planner {
296    pub fn new(config: PlannerConfig) -> Self {
297        Self { config }
298    }
299
300    /// Score a single proposal using static verification.
301    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    /// Score a single proposal, tracking its index in a candidate list.
311    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    /// Compute score from an already-computed VerifyResult.
324    /// Uses static verification, simulated state, and optional historical feedback.
325    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        // Simulated state analysis
343        let state_keys_written = vr.simulated_state.len();
344        let has_write_conflicts = !vr.conflicts.is_empty();
345
346        // Validity: 1.0 if clean, penalized by errors, warnings, and conflicts
347        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            // Write conflicts are a strong signal of a bad plan
353            if has_write_conflicts {
354                v -= vr.conflicts.len() as f64 * self.config.conflict_penalty;
355            }
356            v.max(0.1)
357        };
358
359        // Cost efficiency: prefer fewer actions and tool calls within budget
360        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        // Parallelism bonus: fewer levels = more parallelism = faster
371        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        // Token efficiency: prefer proposals that fit within the token budget.
378        // Estimate tokens from the serialized proposal length (≈ 4 chars/token).
379        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        // Blend cost-efficiency terms. token_weight takes share from the other
387        // terms proportionally — when token_weight=0 behaviour matches legacy.
388        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        // Historical tool confidence from trajectory feedback
397        let historical_confidence = feedback
398            .map(|f| f.proposal_tool_confidence(proposal))
399            .unwrap_or(1.0); // no feedback = assume full confidence
400
401        // Combined score — invalid proposals always score 0.0
402        // Formula: blend validity, cost_efficiency, and historical confidence
403        let score = if error_count > 0 {
404            0.0
405        } else {
406            let cw = self.config.cost_weight.clamp(0.0, 1.0);
407            // Base score from validity and cost
408            let base = validity * (1.0 - cw) + cost_efficiency * cw;
409            // Scale by historical confidence (tools that fail often drag score down)
410            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    /// Rank multiple candidate proposals. Returns scored proposals sorted
441    /// by score descending (best first).
442    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    /// Rank with historical tool feedback from trajectory store.
452    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        // Sort by score descending, then by action_count ascending (tiebreaker)
466        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                // Final deterministic tie-break: input order. Stable-sort
472                // already preserved this, but making it explicit keeps
473                // equal-score ranking from depending on sort stability.
474                .then(a.index.cmp(&b.index))
475        });
476
477        scored
478    }
479
480    /// Pick the best valid proposal from candidates.
481    /// Returns None if all candidates have errors.
482    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    /// Rank candidates by **predicted outcome** as well as validity/cost
493    /// (Code World Models Slice 3). For each candidate, the predicted final
494    /// state is computed with [`car_verify::simulate_with_model`] against the
495    /// injected `model` — a verified Code World Model (e.g. a
496    /// [`car_verify::cwm::GatedEffectModel`]) — and scored by `value`. The
497    /// outcome value is blended into the static score with
498    /// `PlannerConfig::outcome_weight`:
499    /// `score = static_score * (1 - outcome_weight) + outcome_value *
500    /// outcome_weight` for valid proposals (invalid ones stay `0.0`).
501    ///
502    /// This is the step that makes planning *outcome-aware*: a plan that reaches
503    /// the goal can outrank a cheaper plan that doesn't. It is library-only by
504    /// construction — like `car_verify::synthesize_cwm`, it takes injected trait
505    /// objects (the model and the value function), which don't cross the FFI
506    /// boundary; the stateless predictive primitive is `simulate_with_predictions`.
507    ///
508    /// With `outcome_weight == 0.0` (the default) the blend is a no-op and the
509    /// ordering matches [`Planner::rank_with_feedback`] exactly — opting in an
510    /// `EffectModel` never silently changes behavior.
511    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                // Predict the final state with the verified model and value it.
527                let predicted = car_verify::simulate_with_model(p, initial_state, model);
528                sp.outcome_value = value.value(&predicted).clamp(0.0, 1.0);
529                // Blend only for valid proposals and only when opted in — an
530                // invalid plan stays 0.0, and ow==0 reproduces the static score.
531                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                // Final deterministic tie-break: input order. Stable-sort
549                // already preserved this, but making it explicit keeps
550                // equal-score ranking from depending on sort stability.
551                .then(a.index.cmp(&b.index))
552        });
553        scored
554    }
555}
556
557/// Scores a predicted final state in `[0,1]` — higher is better. The paper's
558/// generated value heuristic plugs in here; [`GoalKeysValue`] is a concrete,
559/// dependency-free default. Implementations should be pure functions of the
560/// state so ranking stays deterministic.
561pub trait ValueFunction {
562    fn value(&self, final_state: &HashMap<String, Value>) -> f64;
563}
564
565/// Default [`ValueFunction`]: the fraction of desired `(key, value)` goals that
566/// the predicted final state satisfies. Numeric-aware (`1` matches `1.0`) so an
567/// int that round-tripped through a float doesn't read as unmet. An empty goal
568/// set scores `1.0` (vacuously satisfied) — set `outcome_weight` to `0.0` if you
569/// have no goals rather than relying on this.
570pub 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
580/// Numeric-aware value equality (mirrors `car_verify`'s rationale: an int that
581/// floatified must still match), recursing through arrays/objects.
582fn 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            // FIXED, not `Utc::now()`. Scoring feeds `estimate_proposal_tokens`
650            // (= `serde_json::to_string(proposal).len() / 4`) into token
651            // efficiency, and chrono's RFC3339 output TRIMS trailing zeros from
652            // the fractional seconds — so the serialized length depends on *when
653            // the clock happened to tick*. Two structurally identical proposals
654            // built microseconds apart could therefore get different token
655            // estimates, different scores, and flip a supposedly-tied ranking.
656            // That made `outcome_weight_decides_between_equal_cost_plans` flaky
657            // on every platform (~3 in 25 locally); it only got noticed when the
658            // Windows gate started running the whole workspace.
659            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); // the valid one was at index 1
714    }
715
716    #[test]
717    fn rank_prefers_cheaper_among_valid() {
718        let planner = Planner::new(PlannerConfig {
719            cost_weight: 0.5, // strong cost preference
720            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        // Cheap should rank higher due to cost_weight
738        assert_eq!(ranked[0].index, 1); // cheap was at index 1
739        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        // Two independent actions → 1 DAG level (parallel)
791        let parallel = proposal(
792            "par",
793            vec![
794                tool_call("a", HashMap::new()),
795                tool_call("b", HashMap::new()),
796            ],
797        );
798
799        // Two dependent actions → 2 DAG levels (sequential)
800        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        // First action writes "key" so second depends on it
806        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(&parallel, None, Some(&tools));
812        let seq_score = planner.score(&sequential, None, Some(&tools));
813
814        // Parallel should have better cost_efficiency due to parallelism bonus
815        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        // Two actions writing the same key = conflict
844        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        // Conflict penalty should reduce validity
855        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        // Reliable plan should rank higher
883        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); // default
936    }
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        // When the token budget is very small, all proposals exceed it and
955        // token_ratio saturates to 0, dragging cost_efficiency below 1.0.
956        let planner = Planner::new(PlannerConfig {
957            token_budget: 10, // way below any real proposal
958            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        // With token_weight=0, cost_efficiency should match the legacy
976        // (action/tool/parallelism) formula: 0.4a + 0.4t + 0.2p.
977        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); // budget=20
985        let tool_ratio = 1.0 - (1.0 / 10.0); // budget=10
986        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    // --- Slice 3: outcome-aware ranking ---
1006
1007    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)); // 1 == 1.0 (numeric-aware)
1020        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        // Default config has outcome_weight 0.0 → score must equal the static
1034        // path even though the model predicts the goal.
1035        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        // Outcome value is computed and surfaced...
1059        assert!((out[0].outcome_value - 1.0).abs() < 1e-9);
1060        // ...but with outcome_weight 0.0 the score is unchanged.
1061        assert!((out[0].score - base[0].score).abs() < 1e-9);
1062    }
1063
1064    #[test]
1065    fn outcome_weight_decides_between_equal_cost_plans() {
1066        // Two structurally identical, equal-cost candidates (same tool, same
1067        // params) differing only in action id — so the *only* thing that can
1068        // change the ranking is the predicted outcome, not cost/parallelism.
1069        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        // The verified model: B's action reaches the goal; A abstains.
1077        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        // No outcome weight → equal scores, stable order keeps input order (A first).
1092        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        // With outcome weight, the goal-reaching B overtakes A.
1097        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    // --- dispatch-conditional feedback ---
1114
1115    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        // `api` ran twice and succeeded both times. It was also rejected eight
1144        // times — by the validator or policy, before dispatch — so the tool
1145        // never had a chance to fail on those.
1146        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        // The scoring constructor counts all ten: 2/10 = 0.2. That is a
1156        // deliberate signal for ranking, and unchanged here.
1157        let scoring = ToolFeedback::from_trajectories(&ts);
1158        assert!((scoring.rate("api") - 0.2).abs() < 1e-9);
1159
1160        // The dispatch-conditional constructor counts the two that ran.
1161        // Laplace-smoothed: (2+1)/(2+2) = 0.75.
1162        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        // Only the succeeded + failed pair is evidence: (1+1)/(2+2) = 0.5.
1181        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        // A single success is not a categorical guarantee.
1192        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        // With real evidence the prior stops mattering, and the rate rises
1198        // smoothly rather than jumping at a threshold.
1199        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        // No dispatch = no evidence. The key is absent so the consumer applies
1211        // its own default, rather than us asserting 0.5 as an observation.
1212        assert!(!f.tool_success_rates.contains_key("never_ran"));
1213        assert!(!f.tool_dispatch_counts.contains_key("never_ran"));
1214        // `rate()` still answers with the no-data default.
1215        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}