Skip to main content

axon/
cost_estimator.rs

1//! Execution Cost Estimator — estimate token usage and USD cost before running a flow.
2//!
3//! Analyzes IR to count steps by type and estimate:
4//!   - Input tokens per step (prompt construction)
5//!   - Output tokens per step (model generation)
6//!   - Tool call overhead
7//!   - Total estimated cost in USD
8//!
9//! Pricing model: configurable per-token rates (default: Claude Sonnet).
10//! Output formats: text (human), json (machine).
11
12use crate::ir_nodes::{IRFlowNode, IRProgram};
13use serde::Serialize;
14
15// ── Pricing ──────────────────────────────────────────────────────────────
16
17/// Token pricing configuration (per million tokens).
18#[derive(Debug, Clone, Serialize)]
19pub struct PricingModel {
20    pub name: String,
21    pub input_per_million: f64,
22    pub output_per_million: f64,
23}
24
25impl PricingModel {
26    /// Default pricing: Claude Sonnet 4 ($3/$15 per million).
27    pub fn default_sonnet() -> Self {
28        PricingModel {
29            name: "claude-sonnet-4".to_string(),
30            input_per_million: 3.0,
31            output_per_million: 15.0,
32        }
33    }
34
35    /// Claude Opus 4 ($15/$75 per million).
36    pub fn opus() -> Self {
37        PricingModel {
38            name: "claude-opus-4".to_string(),
39            input_per_million: 15.0,
40            output_per_million: 75.0,
41        }
42    }
43
44    /// Claude Haiku 3.5 ($0.80/$4 per million).
45    pub fn haiku() -> Self {
46        PricingModel {
47            name: "claude-haiku-3.5".to_string(),
48            input_per_million: 0.80,
49            output_per_million: 4.0,
50        }
51    }
52
53    /// Compute cost from token counts.
54    pub fn compute_cost(&self, input_tokens: u64, output_tokens: u64) -> f64 {
55        let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_per_million;
56        let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_per_million;
57        input_cost + output_cost
58    }
59}
60
61// ── Step classification ──────────────────────────────────────────────────
62
63/// Classification of a step for cost estimation purposes.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
65#[serde(rename_all = "snake_case")]
66pub enum StepKind {
67    /// Standard ask step — single prompt/response.
68    Ask,
69    /// Tool use — prompt + tool call overhead.
70    ToolCall,
71    /// Reasoning step — deeper inference, higher output.
72    Reason,
73    /// Probe — targeted inspection.
74    Probe,
75    /// Validate — verification pass.
76    Validate,
77    /// Refine — iterative improvement.
78    Refine,
79    /// Weave — multi-source synthesis.
80    Weave,
81    /// Memory ops — remember/recall/persist/retrieve (minimal LLM cost).
82    Memory,
83    /// Control flow — conditional/loop (no direct LLM cost).
84    Control,
85    /// Parallel — container for parallel execution.
86    Parallel,
87    /// Deliberate/Consensus/Forge — multi-agent blocks.
88    MultiAgent,
89    /// Other cognitive steps (focus, associate, aggregate, explore, etc.).
90    Cognitive,
91}
92
93/// Token estimate for a single step kind.
94#[derive(Debug, Clone, Serialize)]
95pub struct StepEstimate {
96    pub kind: StepKind,
97    pub input_tokens: u64,
98    pub output_tokens: u64,
99}
100
101/// Default token estimates per step kind.
102fn default_estimate(kind: StepKind) -> StepEstimate {
103    let (input, output) = match kind {
104        StepKind::Ask =>        (800, 400),
105        StepKind::ToolCall =>   (1000, 300),
106        StepKind::Reason =>     (1200, 800),
107        StepKind::Probe =>      (600, 200),
108        StepKind::Validate =>   (700, 300),
109        StepKind::Refine =>     (900, 600),
110        StepKind::Weave =>      (1500, 600),
111        StepKind::Memory =>     (100, 50),
112        StepKind::Control =>    (0, 0),
113        StepKind::Parallel =>   (0, 0),
114        StepKind::MultiAgent => (2000, 1000),
115        StepKind::Cognitive =>  (800, 400),
116    };
117    StepEstimate { kind, input_tokens: input, output_tokens: output }
118}
119
120// ── IR analysis ──────────────────────────────────────────────────────────
121
122/// Classify an IR flow node into a step kind.
123fn classify_node(node: &IRFlowNode) -> StepKind {
124    match node {
125        IRFlowNode::Step(_) => StepKind::Ask,
126        IRFlowNode::UseTool(_) => StepKind::ToolCall,
127        IRFlowNode::Reason(_) => StepKind::Reason,
128        IRFlowNode::Probe(_) => StepKind::Probe,
129        IRFlowNode::Validate(_) => StepKind::Validate,
130        IRFlowNode::Refine(_) => StepKind::Refine,
131        IRFlowNode::Weave(_) => StepKind::Weave,
132        IRFlowNode::Remember(_) | IRFlowNode::Recall(_)
133        | IRFlowNode::Persist(_) | IRFlowNode::Retrieve(_)
134        | IRFlowNode::Mutate(_) | IRFlowNode::Purge(_) => StepKind::Memory,
135        IRFlowNode::Conditional(_) | IRFlowNode::ForIn(_)
136        | IRFlowNode::Let(_) | IRFlowNode::Return(_)
137        // Fase 19.e — break/continue are pure control transfers, no
138        // model call, no I/O. Classify as Control alongside Return.
139        | IRFlowNode::Break(_) | IRFlowNode::Continue(_) => StepKind::Control,
140        IRFlowNode::Par(_) | IRFlowNode::Stream(_) => StepKind::Parallel,
141        IRFlowNode::Deliberate(_) | IRFlowNode::Consensus(_)
142        | IRFlowNode::Forge(_) => StepKind::MultiAgent,
143        IRFlowNode::Focus(_) | IRFlowNode::Associate(_)
144        | IRFlowNode::Aggregate(_) | IRFlowNode::Explore(_)
145        | IRFlowNode::Ingest(_) | IRFlowNode::Navigate(_)
146        | IRFlowNode::Drill(_) | IRFlowNode::Trail(_)
147        | IRFlowNode::Corroborate(_) | IRFlowNode::Listen(_)
148        | IRFlowNode::DaemonStep(_) | IRFlowNode::Hibernate(_) => StepKind::Cognitive,
149        IRFlowNode::ShieldApply(_) | IRFlowNode::OtsApply(_)
150        | IRFlowNode::MandateApply(_) | IRFlowNode::ComputeApply(_)
151        | IRFlowNode::LambdaDataApply(_) | IRFlowNode::Transact(_) => StepKind::Control,
152        // §λ-L-E Fase 13 — Mobile typed channel reductions are π-calc
153        // prefixes, classified as Cognitive alongside Listen/DaemonStep.
154        IRFlowNode::Emit(_) | IRFlowNode::Publish(_) | IRFlowNode::Discover(_) => StepKind::Cognitive,
155    }
156}
157
158/// Count steps by kind, recursively walking nested blocks.
159fn count_steps(nodes: &[IRFlowNode]) -> Vec<(StepKind, u32)> {
160    let mut counts = std::collections::HashMap::new();
161
162    fn walk(nodes: &[IRFlowNode], counts: &mut std::collections::HashMap<StepKind, u32>) {
163        for node in nodes {
164            let kind = classify_node(node);
165            *counts.entry(kind).or_insert(0) += 1;
166
167            // Recurse into nested blocks
168            match node {
169                IRFlowNode::Conditional(c) => {
170                    walk(&c.then_body, counts);
171                    walk(&c.else_body, counts);
172                }
173                IRFlowNode::ForIn(f) => walk(&f.body, counts),
174                // Par, Deliberate, Consensus, Forge, Stream, Transact are stub
175                // structs without body fields in current IR — no recursion needed.
176                _ => {}
177            }
178        }
179    }
180
181    walk(nodes, &mut counts);
182    let mut result: Vec<_> = counts.into_iter().collect();
183    result.sort_by_key(|(k, _)| format!("{:?}", k));
184    result
185}
186
187// ── Cost report ──────────────────────────────────────────────────────────
188
189/// Per-flow cost breakdown.
190#[derive(Debug, Clone, Serialize)]
191pub struct FlowCostEstimate {
192    pub flow_name: String,
193    pub step_counts: Vec<StepCountEntry>,
194    pub total_steps: u32,
195    pub estimated_input_tokens: u64,
196    pub estimated_output_tokens: u64,
197    pub estimated_total_tokens: u64,
198}
199
200/// Step count entry for a specific kind.
201#[derive(Debug, Clone, Serialize)]
202pub struct StepCountEntry {
203    pub kind: StepKind,
204    pub count: u32,
205    pub input_tokens: u64,
206    pub output_tokens: u64,
207}
208
209/// Full cost report for an AXON program.
210#[derive(Debug, Clone, Serialize)]
211pub struct CostReport {
212    pub pricing: PricingModel,
213    pub flows: Vec<FlowCostEstimate>,
214    pub total_input_tokens: u64,
215    pub total_output_tokens: u64,
216    pub total_tokens: u64,
217    pub estimated_cost_usd: f64,
218}
219
220/// Estimate cost for an entire IR program.
221pub fn estimate_program(ir: &IRProgram, pricing: &PricingModel) -> CostReport {
222    let mut flows = Vec::new();
223    let mut total_input: u64 = 0;
224    let mut total_output: u64 = 0;
225
226    for flow in &ir.flows {
227        let step_counts = count_steps(&flow.steps);
228        let mut flow_input: u64 = 0;
229        let mut flow_output: u64 = 0;
230        let mut total_steps: u32 = 0;
231
232        let entries: Vec<StepCountEntry> = step_counts
233            .iter()
234            .map(|(kind, count)| {
235                let est = default_estimate(*kind);
236                let input = est.input_tokens * (*count as u64);
237                let output = est.output_tokens * (*count as u64);
238                flow_input += input;
239                flow_output += output;
240                total_steps += count;
241                StepCountEntry {
242                    kind: *kind,
243                    count: *count,
244                    input_tokens: input,
245                    output_tokens: output,
246                }
247            })
248            .collect();
249
250        total_input += flow_input;
251        total_output += flow_output;
252
253        flows.push(FlowCostEstimate {
254            flow_name: flow.name.clone(),
255            step_counts: entries,
256            total_steps,
257            estimated_input_tokens: flow_input,
258            estimated_output_tokens: flow_output,
259            estimated_total_tokens: flow_input + flow_output,
260        });
261    }
262
263    let cost = pricing.compute_cost(total_input, total_output);
264
265    CostReport {
266        pricing: pricing.clone(),
267        flows,
268        total_input_tokens: total_input,
269        total_output_tokens: total_output,
270        total_tokens: total_input + total_output,
271        estimated_cost_usd: cost,
272    }
273}
274
275// ── Output formatting ────────────────────────────────────────────────────
276
277/// Format cost report as human-readable text.
278pub fn format_text(report: &CostReport) -> String {
279    let mut out = String::new();
280
281    out.push_str(&format!("AXON Execution Cost Estimate ({})\n", report.pricing.name));
282    out.push_str(&format!("Pricing: ${}/M input, ${}/M output\n",
283        report.pricing.input_per_million, report.pricing.output_per_million));
284    out.push_str(&"─".repeat(60));
285    out.push('\n');
286
287    for flow in &report.flows {
288        out.push_str(&format!("\nFlow: {}\n", flow.flow_name));
289        out.push_str(&format!("  Steps: {}\n", flow.total_steps));
290
291        for entry in &flow.step_counts {
292            if entry.count > 0 {
293                out.push_str(&format!("    {:12} x{:<3}  ~{} input + {} output tokens\n",
294                    format!("{:?}", entry.kind),
295                    entry.count,
296                    entry.input_tokens,
297                    entry.output_tokens,
298                ));
299            }
300        }
301
302        out.push_str(&format!("  Subtotal: ~{} tokens ({} in + {} out)\n",
303            flow.estimated_total_tokens,
304            flow.estimated_input_tokens,
305            flow.estimated_output_tokens,
306        ));
307    }
308
309    out.push_str(&format!("\n{}\n", "─".repeat(60)));
310    out.push_str(&format!("Total: ~{} tokens ({} in + {} out)\n",
311        report.total_tokens, report.total_input_tokens, report.total_output_tokens));
312    out.push_str(&format!("Estimated cost: ${:.6} USD\n", report.estimated_cost_usd));
313
314    out
315}
316
317/// CLI entry point: estimate cost for an .axon file.
318pub fn run_estimate(file: &str, format: &str, model: &str) -> i32 {
319    let source = match std::fs::read_to_string(file) {
320        Ok(s) => s,
321        Err(e) => {
322            eprintln!("Error reading {}: {}", file, e);
323            return 1;
324        }
325    };
326
327    let tokens = match crate::lexer::Lexer::new(&source, file).tokenize() {
328        Ok(t) => t,
329        Err(e) => {
330            eprintln!("Lexer error: {:?}", e);
331            return 1;
332        }
333    };
334    let ast = match crate::parser::Parser::new(tokens).parse() {
335        Ok(ast) => ast,
336        Err(e) => {
337            eprintln!("Parse error: {:?}", e);
338            return 1;
339        }
340    };
341
342    let ir = crate::ir_generator::IRGenerator::new().generate(&ast);
343
344    let pricing = match model {
345        "opus" => PricingModel::opus(),
346        "haiku" => PricingModel::haiku(),
347        _ => PricingModel::default_sonnet(),
348    };
349
350    let report = estimate_program(&ir, &pricing);
351
352    match format {
353        "json" => {
354            println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default());
355        }
356        _ => {
357            print!("{}", format_text(&report));
358        }
359    }
360
361    0
362}
363
364// ── Tests ────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn pricing_sonnet_defaults() {
372        let p = PricingModel::default_sonnet();
373        assert_eq!(p.input_per_million, 3.0);
374        assert_eq!(p.output_per_million, 15.0);
375    }
376
377    #[test]
378    fn pricing_compute_cost() {
379        let p = PricingModel::default_sonnet();
380        // 1M input + 1M output = $3 + $15 = $18
381        let cost = p.compute_cost(1_000_000, 1_000_000);
382        assert!((cost - 18.0).abs() < 0.001);
383    }
384
385    #[test]
386    fn pricing_zero_tokens() {
387        let p = PricingModel::default_sonnet();
388        assert_eq!(p.compute_cost(0, 0), 0.0);
389    }
390
391    #[test]
392    fn pricing_opus_rates() {
393        let p = PricingModel::opus();
394        assert_eq!(p.input_per_million, 15.0);
395        assert_eq!(p.output_per_million, 75.0);
396        let cost = p.compute_cost(1_000_000, 1_000_000);
397        assert!((cost - 90.0).abs() < 0.001);
398    }
399
400    #[test]
401    fn pricing_haiku_rates() {
402        let p = PricingModel::haiku();
403        assert_eq!(p.input_per_million, 0.80);
404        assert_eq!(p.output_per_million, 4.0);
405    }
406
407    #[test]
408    fn classify_step_kinds() {
409        use crate::ir_nodes::*;
410
411        let step = IRFlowNode::Step(IRStep {
412            node_type: "Step",
413            source_line: 1, source_column: 1,
414            name: "s1".into(), persona_ref: "".into(),
415            given: "".into(), ask: "do something".into(),
416            use_tool: None, probe: None, reason: None, weave: None,
417            output_type: "".into(), confidence_floor: None,
418            navigate_ref: "".into(), apply_ref: "".into(),
419            body: vec![],
420        });
421        assert_eq!(classify_node(&step), StepKind::Ask);
422
423        let tool = IRFlowNode::UseTool(IRUseToolStep {
424            node_type: "UseTool",
425            source_line: 1, source_column: 1,
426            tool_name: "search".into(), argument: "q".into(),
427            named_args: Vec::new(),
428        });
429        assert_eq!(classify_node(&tool), StepKind::ToolCall);
430
431        let reason = IRFlowNode::Reason(IRReasonStep {
432            node_type: "Reason",
433            source_line: 1, source_column: 1,
434            strategy: "deductive".into(), target: "t".into(),
435        });
436        assert_eq!(classify_node(&reason), StepKind::Reason);
437    }
438
439    #[test]
440    fn count_steps_flat() {
441        use crate::ir_nodes::*;
442
443        let nodes = vec![
444            IRFlowNode::Step(IRStep {
445                node_type: "Step", source_line: 1, source_column: 1,
446                name: "s1".into(), persona_ref: "".into(),
447                given: "".into(), ask: "a".into(),
448                use_tool: None, probe: None, reason: None, weave: None,
449                output_type: "".into(), confidence_floor: None,
450                navigate_ref: "".into(), apply_ref: "".into(),
451                body: vec![],
452            }),
453            IRFlowNode::Step(IRStep {
454                node_type: "Step", source_line: 2, source_column: 1,
455                name: "s2".into(), persona_ref: "".into(),
456                given: "".into(), ask: "b".into(),
457                use_tool: None, probe: None, reason: None, weave: None,
458                output_type: "".into(), confidence_floor: None,
459                navigate_ref: "".into(), apply_ref: "".into(),
460                body: vec![],
461            }),
462            IRFlowNode::UseTool(IRUseToolStep {
463                node_type: "UseTool", source_line: 3, source_column: 1,
464                tool_name: "t".into(), argument: "a".into(),
465                named_args: Vec::new(),
466            }),
467        ];
468
469        let counts = count_steps(&nodes);
470        let ask_count = counts.iter().find(|(k, _)| *k == StepKind::Ask).map(|(_, c)| *c).unwrap_or(0);
471        let tool_count = counts.iter().find(|(k, _)| *k == StepKind::ToolCall).map(|(_, c)| *c).unwrap_or(0);
472        assert_eq!(ask_count, 2);
473        assert_eq!(tool_count, 1);
474    }
475
476    #[test]
477    fn count_steps_nested_conditional() {
478        use crate::ir_nodes::*;
479
480        let inner_step = IRFlowNode::Step(IRStep {
481            node_type: "Step", source_line: 1, source_column: 1,
482            name: "inner".into(), persona_ref: "".into(),
483            given: "".into(), ask: "x".into(),
484            use_tool: None, probe: None, reason: None, weave: None,
485            output_type: "".into(), confidence_floor: None,
486            navigate_ref: "".into(), apply_ref: "".into(),
487            body: vec![],
488        });
489
490        let cond = IRFlowNode::Conditional(IRConditional {
491            node_type: "Conditional", source_line: 1, source_column: 1,
492            condition: "c".into(), comparison_op: "==".into(),
493            comparison_value: "true".into(),
494            then_body: vec![inner_step],
495            else_body: vec![],
496            conditions: vec![],
497            conjunctor: "".into(),
498        });
499
500        let counts = count_steps(&[cond]);
501        let control = counts.iter().find(|(k, _)| *k == StepKind::Control).map(|(_, c)| *c).unwrap_or(0);
502        let ask = counts.iter().find(|(k, _)| *k == StepKind::Ask).map(|(_, c)| *c).unwrap_or(0);
503        assert_eq!(control, 1); // the conditional itself
504        assert_eq!(ask, 1); // the nested step
505    }
506
507    #[test]
508    fn estimate_program_empty() {
509        let ir = IRProgram {
510            node_type: "Program",
511            source_line: 0, source_column: 0,
512            personas: vec![], contexts: vec![], anchors: vec![],
513            tools: vec![], memories: vec![], types: vec![],
514            flows: vec![], runs: vec![], imports: vec![],
515            agents: vec![], shields: vec![], daemons: vec![],
516            ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
517            psyche_specs: vec![], mandate_specs: vec![],
518            lambda_data_specs: vec![], compute_specs: vec![],
519            axonstore_specs: vec![], endpoints: vec![],
520            extensions: vec![],
521            dataspace_specs: vec![],
522            resources: vec![],
523            fabrics: vec![],
524            manifests: vec![],
525            observations: vec![],
526            intention_tree: None,
527            reconciles: vec![],
528            leases: vec![],
529            ensembles: vec![],
530            sessions: vec![],
531            topologies: vec![],
532            immunes: vec![],
533            reflexes: vec![],
534            heals: vec![],
535            components: vec![],
536            views: vec![],
537            channels: vec![],
538            sockets: vec![],
539            effects: vec![],
540        };
541
542        let pricing = PricingModel::default_sonnet();
543        let report = estimate_program(&ir, &pricing);
544        assert_eq!(report.total_tokens, 0);
545        assert_eq!(report.estimated_cost_usd, 0.0);
546        assert!(report.flows.is_empty());
547    }
548
549    #[test]
550    fn estimate_program_single_flow() {
551        use crate::ir_nodes::*;
552
553        let flow = IRFlow {
554            node_type: "Flow", source_line: 1, source_column: 1,
555            name: "Analyze".into(),
556            parameters: vec![], return_type_name: "".into(),
557            return_type_generic: "".into(), return_type_optional: false,
558            steps: vec![
559                IRFlowNode::Step(IRStep {
560                    node_type: "Step", source_line: 2, source_column: 1,
561                    name: "gather".into(), persona_ref: "".into(),
562                    given: "".into(), ask: "gather data".into(),
563                    use_tool: None, probe: None, reason: None, weave: None,
564                    output_type: "".into(), confidence_floor: None,
565                    navigate_ref: "".into(), apply_ref: "".into(),
566                    body: vec![],
567                }),
568                IRFlowNode::Reason(IRReasonStep {
569                    node_type: "Reason", source_line: 3, source_column: 1,
570                    strategy: "deductive".into(), target: "conclusion".into(),
571                }),
572            ],
573            edges: vec![],
574            execution_levels: vec![],
575        };
576
577        let ir = IRProgram {
578            node_type: "Program",
579            source_line: 0, source_column: 0,
580            personas: vec![], contexts: vec![], anchors: vec![],
581            tools: vec![], memories: vec![], types: vec![],
582            flows: vec![flow], runs: vec![], imports: vec![],
583            agents: vec![], shields: vec![], daemons: vec![],
584            ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
585            psyche_specs: vec![], mandate_specs: vec![],
586            lambda_data_specs: vec![], compute_specs: vec![],
587            axonstore_specs: vec![], endpoints: vec![],
588            extensions: vec![],
589            dataspace_specs: vec![],
590            resources: vec![],
591            fabrics: vec![],
592            manifests: vec![],
593            observations: vec![],
594            intention_tree: None,
595            reconciles: vec![],
596            leases: vec![],
597            ensembles: vec![],
598            sessions: vec![],
599            topologies: vec![],
600            immunes: vec![],
601            reflexes: vec![],
602            heals: vec![],
603            components: vec![],
604            views: vec![],
605            channels: vec![],
606            sockets: vec![],
607            effects: vec![],
608        };
609
610        let pricing = PricingModel::default_sonnet();
611        let report = estimate_program(&ir, &pricing);
612
613        assert_eq!(report.flows.len(), 1);
614        assert_eq!(report.flows[0].flow_name, "Analyze");
615        assert_eq!(report.flows[0].total_steps, 2);
616
617        // Ask: 800 input + 400 output, Reason: 1200 input + 800 output
618        assert_eq!(report.total_input_tokens, 800 + 1200);
619        assert_eq!(report.total_output_tokens, 400 + 800);
620        assert_eq!(report.total_tokens, 3200);
621        assert!(report.estimated_cost_usd > 0.0);
622    }
623
624    #[test]
625    fn format_text_contains_flow_name() {
626        use crate::ir_nodes::*;
627
628        let flow = IRFlow {
629            node_type: "Flow", source_line: 1, source_column: 1,
630            name: "TestFlow".into(),
631            parameters: vec![], return_type_name: "".into(),
632            return_type_generic: "".into(), return_type_optional: false,
633            steps: vec![
634                IRFlowNode::Step(IRStep {
635                    node_type: "Step", source_line: 2, source_column: 1,
636                    name: "s1".into(), persona_ref: "".into(),
637                    given: "".into(), ask: "do".into(),
638                    use_tool: None, probe: None, reason: None, weave: None,
639                    output_type: "".into(), confidence_floor: None,
640                    navigate_ref: "".into(), apply_ref: "".into(),
641                    body: vec![],
642                }),
643            ],
644            edges: vec![],
645            execution_levels: vec![],
646        };
647
648        let ir = IRProgram {
649            node_type: "Program", source_line: 0, source_column: 0,
650            personas: vec![], contexts: vec![], anchors: vec![],
651            tools: vec![], memories: vec![], types: vec![],
652            flows: vec![flow], runs: vec![], imports: vec![],
653            agents: vec![], shields: vec![], daemons: vec![],
654            ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
655            psyche_specs: vec![], mandate_specs: vec![],
656            lambda_data_specs: vec![], compute_specs: vec![],
657            axonstore_specs: vec![], endpoints: vec![],
658            extensions: vec![],
659            dataspace_specs: vec![],
660            resources: vec![],
661            fabrics: vec![],
662            manifests: vec![],
663            observations: vec![],
664            intention_tree: None,
665            reconciles: vec![],
666            leases: vec![],
667            ensembles: vec![],
668            sessions: vec![],
669            topologies: vec![],
670            immunes: vec![],
671            reflexes: vec![],
672            heals: vec![],
673            components: vec![],
674            views: vec![],
675            channels: vec![],
676            sockets: vec![],
677            effects: vec![],
678        };
679
680        let pricing = PricingModel::default_sonnet();
681        let report = estimate_program(&ir, &pricing);
682        let text = format_text(&report);
683
684        assert!(text.contains("TestFlow"));
685        assert!(text.contains("claude-sonnet-4"));
686        assert!(text.contains("Estimated cost:"));
687        assert!(text.contains("$"));
688    }
689
690    #[test]
691    fn report_serializes_to_json() {
692        let pricing = PricingModel::default_sonnet();
693        let report = CostReport {
694            pricing: pricing.clone(),
695            flows: vec![],
696            total_input_tokens: 1000,
697            total_output_tokens: 500,
698            total_tokens: 1500,
699            estimated_cost_usd: pricing.compute_cost(1000, 500),
700        };
701
702        let json = serde_json::to_string(&report).unwrap();
703        assert!(json.contains("\"total_tokens\":1500"));
704        assert!(json.contains("\"claude-sonnet-4\""));
705    }
706
707    #[test]
708    fn default_estimates_nonzero_for_llm_steps() {
709        for kind in &[StepKind::Ask, StepKind::ToolCall, StepKind::Reason,
710                      StepKind::Probe, StepKind::Validate, StepKind::Refine,
711                      StepKind::Weave, StepKind::MultiAgent, StepKind::Cognitive] {
712            let est = default_estimate(*kind);
713            assert!(est.input_tokens > 0, "{:?} should have nonzero input", kind);
714            assert!(est.output_tokens > 0, "{:?} should have nonzero output", kind);
715        }
716    }
717
718    #[test]
719    fn control_and_parallel_zero_cost() {
720        let est_ctrl = default_estimate(StepKind::Control);
721        assert_eq!(est_ctrl.input_tokens, 0);
722        assert_eq!(est_ctrl.output_tokens, 0);
723
724        let est_par = default_estimate(StepKind::Parallel);
725        assert_eq!(est_par.input_tokens, 0);
726        assert_eq!(est_par.output_tokens, 0);
727    }
728
729    #[test]
730    fn memory_steps_low_cost() {
731        let est = default_estimate(StepKind::Memory);
732        assert!(est.input_tokens <= 200);
733        assert!(est.output_tokens <= 100);
734    }
735}