Skip to main content

lex_store/
planner.rs

1//! `lex plan` — cost-aware path planner over the call graph (#307).
2//!
3//! Given a `goal` function and either an explicit `max_cost` or a
4//! session id whose remaining budget caps the spend, enumerate every
5//! linear call chain from `goal` to a leaf (a function that calls no
6//! other user-defined function on the branch head). Each chain is
7//! scored by the sum of `[budget(N)]` declarations along it, and the
8//! result is sorted cheapest-first.
9//!
10//! The planner is **advisory** — it doesn't execute anything. Agents
11//! consult it to pick the cheapest reachable path that fits in their
12//! remaining budget; downstream policy (`#292`'s gate) is what
13//! ultimately admits or refuses an op.
14
15use crate::{Store, StoreError};
16use lex_ast::{CExpr, Stage};
17use serde::{Deserialize, Serialize};
18use std::collections::{BTreeMap, BTreeSet, HashSet};
19
20/// One linear chain from `goal` to a leaf, with its total cost and
21/// the union of effects along it.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct PlanPath {
24    /// Functions in call order — `chain[0]` is always `goal`.
25    pub chain: Vec<String>,
26    /// Sum of declared `[budget(N)]` for every fn in `chain`.
27    /// Recursive self-calls are counted **once** (the cycle is broken
28    /// at the second visit) — see `expand_paths` for the visited-set.
29    pub total_cost: u64,
30    /// `true` iff `total_cost <= effective_cap` (whichever of
31    /// `max_cost` and the session-remaining is smaller). Always
32    /// `true` when no cap applies.
33    pub fits: bool,
34    /// Union of effect names declared on every fn in the chain
35    /// (excluding the `budget` pseudo-effect, which is the cost
36    /// dimension itself).
37    pub effects: BTreeSet<String>,
38}
39
40/// Result envelope returned by [`Store::plan`].
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Plan {
43    pub goal: String,
44    /// `Some(id)` when the planner was called with `--intent`/
45    /// `session_id`; `None` for the bare `--max-cost` flow.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub session_id: Option<String>,
48    /// Budget remaining for `session_id`, when one was supplied
49    /// and a cap is configured. `None` otherwise.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub remaining_budget: Option<i64>,
52    /// Whichever of `max_cost` and `remaining_budget` is smaller.
53    /// `None` when neither cap applies.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub effective_cap: Option<u64>,
56    /// Paths sorted cheapest-first.
57    pub paths: Vec<PlanPath>,
58}
59
60/// Per-function summary extracted once per branch head.
61struct FnInfo {
62    budget_cost: u64,
63    effects: BTreeSet<String>,
64    calls: Vec<String>,
65}
66
67impl Store {
68    /// `lex plan` (#307). See module docs.
69    pub fn plan(
70        &self,
71        branch: &str,
72        goal: &str,
73        max_cost: Option<u64>,
74        session_id: Option<&str>,
75    ) -> Result<Plan, StoreError> {
76        // Build per-fn summaries from the branch head's active set.
77        let head = self
78            .branch_head(branch)
79            .map_err(|e| StoreError::Io(std::io::Error::other(format!("branch_head: {e}"))))?;
80        let mut fns: BTreeMap<String, FnInfo> = BTreeMap::new();
81        for stage_id in head.values() {
82            let Ok(Stage::FnDecl(fd)) = self.get_ast(stage_id) else { continue };
83            let mut effects = BTreeSet::new();
84            let mut budget_cost: u64 = 0;
85            for e in &fd.effects {
86                if e.name == "budget" {
87                    if let Some(lex_ast::EffectArg::Int { value }) = &e.arg {
88                        budget_cost = budget_cost.saturating_add(*value as u64);
89                    }
90                } else {
91                    effects.insert(e.name.clone());
92                }
93            }
94            let mut calls = Vec::new();
95            collect_call_targets(&fd.body, &mut calls);
96            fns.entry(fd.name.clone()).or_insert(FnInfo {
97                budget_cost,
98                effects,
99                calls,
100            });
101        }
102
103        // Resolve the session's remaining budget (if any).
104        let (remaining_budget, session_id_out) = if let Some(sid) = session_id {
105            let sb = self.session_budget(sid)?;
106            (sb.remaining, Some(sid.to_string()))
107        } else {
108            (None, None)
109        };
110
111        // Effective cap = min(max_cost, max(remaining, 0))
112        let effective_cap: Option<u64> = match (max_cost, remaining_budget) {
113            (Some(m), Some(r)) => Some(m.min(r.max(0) as u64)),
114            (Some(m), None) => Some(m),
115            (None, Some(r)) => Some(r.max(0) as u64),
116            (None, None) => None,
117        };
118
119        let mut paths: Vec<PlanPath> = Vec::new();
120        if fns.contains_key(goal) {
121            expand_paths(goal, &fns, &mut Vec::new(), &mut HashSet::new(), &mut paths);
122        }
123        // Cheapest-first, tie-break by chain length then alphabetical.
124        paths.sort_by(|a, b| {
125            a.total_cost
126                .cmp(&b.total_cost)
127                .then_with(|| a.chain.len().cmp(&b.chain.len()))
128                .then_with(|| a.chain.cmp(&b.chain))
129        });
130        // Stamp `fits` against the resolved cap.
131        for p in &mut paths {
132            p.fits = effective_cap.is_none_or(|cap| p.total_cost <= cap);
133        }
134
135        Ok(Plan {
136            goal: goal.to_string(),
137            session_id: session_id_out,
138            remaining_budget,
139            effective_cap,
140            paths,
141        })
142    }
143}
144
145/// Walk `expr` and append every direct call to a top-level fn (a
146/// `Call { callee = Var { name } }` shape) into `out`. Module-method
147/// calls (`io.print`) and closure calls are intentionally skipped —
148/// they don't reference user-defined fns by name in the call graph.
149fn collect_call_targets(expr: &CExpr, out: &mut Vec<String>) {
150    match expr {
151        CExpr::Call { callee, args } => {
152            if let CExpr::Var { name } = callee.as_ref() {
153                if !out.contains(name) {
154                    out.push(name.clone());
155                }
156            }
157            collect_call_targets(callee, out);
158            for a in args {
159                collect_call_targets(a, out);
160            }
161        }
162        CExpr::Let { value, body, .. } => {
163            collect_call_targets(value, out);
164            collect_call_targets(body, out);
165        }
166        CExpr::Match { scrutinee, arms } => {
167            collect_call_targets(scrutinee, out);
168            for arm in arms {
169                collect_call_targets(&arm.body, out);
170            }
171        }
172        CExpr::Block { statements, result } => {
173            for s in statements {
174                collect_call_targets(s, out);
175            }
176            collect_call_targets(result, out);
177        }
178        CExpr::Constructor { args, .. } => {
179            for a in args {
180                collect_call_targets(a, out);
181            }
182        }
183        CExpr::RecordLit { fields } => {
184            for f in fields {
185                collect_call_targets(&f.value, out);
186            }
187        }
188        CExpr::TupleLit { items } | CExpr::ListLit { items } => {
189            for i in items {
190                collect_call_targets(i, out);
191            }
192        }
193        CExpr::FieldAccess { value, .. } => collect_call_targets(value, out),
194        CExpr::Lambda { body, .. } => collect_call_targets(body, out),
195        CExpr::BinOp { lhs, rhs, .. } => {
196            collect_call_targets(lhs, out);
197            collect_call_targets(rhs, out);
198        }
199        CExpr::UnaryOp { expr, .. } => collect_call_targets(expr, out),
200        CExpr::Return { value } => collect_call_targets(value, out),
201        CExpr::Literal { .. } | CExpr::Var { .. } => {}
202    }
203}
204
205/// DFS enumeration of paths from `current` to every reachable leaf,
206/// breaking recursion at the second visit so a `recur` function's
207/// cost is counted once.
208fn expand_paths(
209    current: &str,
210    fns: &BTreeMap<String, FnInfo>,
211    chain: &mut Vec<String>,
212    visited: &mut HashSet<String>,
213    out: &mut Vec<PlanPath>,
214) {
215    chain.push(current.to_string());
216    let newly_inserted = visited.insert(current.to_string());
217
218    let Some(info) = fns.get(current) else {
219        // Unknown callee (stdlib or external) — terminate the chain
220        // here. The leaf itself contributes nothing to cost/effects.
221        emit_path(chain, fns, out);
222        chain.pop();
223        if newly_inserted {
224            visited.remove(current);
225        }
226        return;
227    };
228
229    // Pick the in-scope, not-yet-visited callees so we don't expand
230    // recursive cycles.
231    let next: Vec<&String> = info
232        .calls
233        .iter()
234        .filter(|c| !visited.contains(*c))
235        .collect();
236    if next.is_empty() {
237        emit_path(chain, fns, out);
238    } else {
239        for callee in next {
240            expand_paths(callee, fns, chain, visited, out);
241        }
242    }
243
244    chain.pop();
245    if newly_inserted {
246        visited.remove(current);
247    }
248}
249
250fn emit_path(chain: &[String], fns: &BTreeMap<String, FnInfo>, out: &mut Vec<PlanPath>) {
251    let mut total_cost: u64 = 0;
252    let mut effects: BTreeSet<String> = BTreeSet::new();
253    for name in chain {
254        if let Some(info) = fns.get(name) {
255            total_cost = total_cost.saturating_add(info.budget_cost);
256            for e in &info.effects {
257                effects.insert(e.clone());
258            }
259        }
260    }
261    out.push(PlanPath {
262        chain: chain.to_vec(),
263        total_cost,
264        fits: true, // patched by caller against effective_cap
265        effects,
266    });
267}