use crate::ir_nodes::{IRFlowNode, IRProgram};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct PricingModel {
pub name: String,
pub input_per_million: f64,
pub output_per_million: f64,
}
impl PricingModel {
pub fn default_sonnet() -> Self {
PricingModel {
name: "claude-sonnet-4".to_string(),
input_per_million: 3.0,
output_per_million: 15.0,
}
}
pub fn opus() -> Self {
PricingModel {
name: "claude-opus-4".to_string(),
input_per_million: 15.0,
output_per_million: 75.0,
}
}
pub fn haiku() -> Self {
PricingModel {
name: "claude-haiku-3.5".to_string(),
input_per_million: 0.80,
output_per_million: 4.0,
}
}
pub fn compute_cost(&self, input_tokens: u64, output_tokens: u64) -> f64 {
let input_cost = (input_tokens as f64 / 1_000_000.0) * self.input_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * self.output_per_million;
input_cost + output_cost
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StepKind {
Ask,
ToolCall,
Reason,
Probe,
Validate,
Refine,
Weave,
Memory,
Control,
Parallel,
MultiAgent,
Cognitive,
}
#[derive(Debug, Clone, Serialize)]
pub struct StepEstimate {
pub kind: StepKind,
pub input_tokens: u64,
pub output_tokens: u64,
}
fn default_estimate(kind: StepKind) -> StepEstimate {
let (input, output) = match kind {
StepKind::Ask => (800, 400),
StepKind::ToolCall => (1000, 300),
StepKind::Reason => (1200, 800),
StepKind::Probe => (600, 200),
StepKind::Validate => (700, 300),
StepKind::Refine => (900, 600),
StepKind::Weave => (1500, 600),
StepKind::Memory => (100, 50),
StepKind::Control => (0, 0),
StepKind::Parallel => (0, 0),
StepKind::MultiAgent => (2000, 1000),
StepKind::Cognitive => (800, 400),
};
StepEstimate { kind, input_tokens: input, output_tokens: output }
}
fn classify_node(node: &IRFlowNode) -> StepKind {
match node {
IRFlowNode::Step(_) => StepKind::Ask,
IRFlowNode::UseTool(_) => StepKind::ToolCall,
IRFlowNode::Reason(_) => StepKind::Reason,
IRFlowNode::Probe(_) => StepKind::Probe,
IRFlowNode::Validate(_) => StepKind::Validate,
IRFlowNode::Refine(_) => StepKind::Refine,
IRFlowNode::Weave(_) => StepKind::Weave,
IRFlowNode::Remember(_) | IRFlowNode::Recall(_)
| IRFlowNode::Persist(_) | IRFlowNode::Retrieve(_)
| IRFlowNode::Mutate(_) | IRFlowNode::Purge(_) => StepKind::Memory,
IRFlowNode::Conditional(_) | IRFlowNode::ForIn(_)
| IRFlowNode::Let(_) | IRFlowNode::Return(_)
| IRFlowNode::Break(_) | IRFlowNode::Continue(_) => StepKind::Control,
IRFlowNode::Par(_) | IRFlowNode::Stream(_) => StepKind::Parallel,
IRFlowNode::Deliberate(_) | IRFlowNode::Consensus(_)
| IRFlowNode::Forge(_) => StepKind::MultiAgent,
IRFlowNode::Focus(_) | IRFlowNode::Associate(_)
| IRFlowNode::Aggregate(_) | IRFlowNode::Explore(_)
| IRFlowNode::Ingest(_) | IRFlowNode::Navigate(_)
| IRFlowNode::Drill(_) | IRFlowNode::Trail(_)
| IRFlowNode::Corroborate(_) | IRFlowNode::Listen(_)
| IRFlowNode::DaemonStep(_) | IRFlowNode::Hibernate(_) => StepKind::Cognitive,
IRFlowNode::ShieldApply(_) | IRFlowNode::OtsApply(_)
| IRFlowNode::MandateApply(_) | IRFlowNode::ComputeApply(_)
| IRFlowNode::LambdaDataApply(_) | IRFlowNode::Transact(_) => StepKind::Control,
IRFlowNode::Emit(_) | IRFlowNode::Publish(_) | IRFlowNode::Discover(_) => StepKind::Cognitive,
}
}
fn count_steps(nodes: &[IRFlowNode]) -> Vec<(StepKind, u32)> {
let mut counts = std::collections::HashMap::new();
fn walk(nodes: &[IRFlowNode], counts: &mut std::collections::HashMap<StepKind, u32>) {
for node in nodes {
let kind = classify_node(node);
*counts.entry(kind).or_insert(0) += 1;
match node {
IRFlowNode::Conditional(c) => {
walk(&c.then_body, counts);
walk(&c.else_body, counts);
}
IRFlowNode::ForIn(f) => walk(&f.body, counts),
_ => {}
}
}
}
walk(nodes, &mut counts);
let mut result: Vec<_> = counts.into_iter().collect();
result.sort_by_key(|(k, _)| format!("{:?}", k));
result
}
#[derive(Debug, Clone, Serialize)]
pub struct FlowCostEstimate {
pub flow_name: String,
pub step_counts: Vec<StepCountEntry>,
pub total_steps: u32,
pub estimated_input_tokens: u64,
pub estimated_output_tokens: u64,
pub estimated_total_tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StepCountEntry {
pub kind: StepKind,
pub count: u32,
pub input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct CostReport {
pub pricing: PricingModel,
pub flows: Vec<FlowCostEstimate>,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_tokens: u64,
pub estimated_cost_usd: f64,
}
pub fn estimate_program(ir: &IRProgram, pricing: &PricingModel) -> CostReport {
let mut flows = Vec::new();
let mut total_input: u64 = 0;
let mut total_output: u64 = 0;
for flow in &ir.flows {
let step_counts = count_steps(&flow.steps);
let mut flow_input: u64 = 0;
let mut flow_output: u64 = 0;
let mut total_steps: u32 = 0;
let entries: Vec<StepCountEntry> = step_counts
.iter()
.map(|(kind, count)| {
let est = default_estimate(*kind);
let input = est.input_tokens * (*count as u64);
let output = est.output_tokens * (*count as u64);
flow_input += input;
flow_output += output;
total_steps += count;
StepCountEntry {
kind: *kind,
count: *count,
input_tokens: input,
output_tokens: output,
}
})
.collect();
total_input += flow_input;
total_output += flow_output;
flows.push(FlowCostEstimate {
flow_name: flow.name.clone(),
step_counts: entries,
total_steps,
estimated_input_tokens: flow_input,
estimated_output_tokens: flow_output,
estimated_total_tokens: flow_input + flow_output,
});
}
let cost = pricing.compute_cost(total_input, total_output);
CostReport {
pricing: pricing.clone(),
flows,
total_input_tokens: total_input,
total_output_tokens: total_output,
total_tokens: total_input + total_output,
estimated_cost_usd: cost,
}
}
pub fn format_text(report: &CostReport) -> String {
let mut out = String::new();
out.push_str(&format!("AXON Execution Cost Estimate ({})\n", report.pricing.name));
out.push_str(&format!("Pricing: ${}/M input, ${}/M output\n",
report.pricing.input_per_million, report.pricing.output_per_million));
out.push_str(&"─".repeat(60));
out.push('\n');
for flow in &report.flows {
out.push_str(&format!("\nFlow: {}\n", flow.flow_name));
out.push_str(&format!(" Steps: {}\n", flow.total_steps));
for entry in &flow.step_counts {
if entry.count > 0 {
out.push_str(&format!(" {:12} x{:<3} ~{} input + {} output tokens\n",
format!("{:?}", entry.kind),
entry.count,
entry.input_tokens,
entry.output_tokens,
));
}
}
out.push_str(&format!(" Subtotal: ~{} tokens ({} in + {} out)\n",
flow.estimated_total_tokens,
flow.estimated_input_tokens,
flow.estimated_output_tokens,
));
}
out.push_str(&format!("\n{}\n", "─".repeat(60)));
out.push_str(&format!("Total: ~{} tokens ({} in + {} out)\n",
report.total_tokens, report.total_input_tokens, report.total_output_tokens));
out.push_str(&format!("Estimated cost: ${:.6} USD\n", report.estimated_cost_usd));
out
}
pub fn run_estimate(file: &str, format: &str, model: &str) -> i32 {
let source = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", file, e);
return 1;
}
};
let tokens = match crate::lexer::Lexer::new(&source, file).tokenize() {
Ok(t) => t,
Err(e) => {
eprintln!("Lexer error: {:?}", e);
return 1;
}
};
let ast = match crate::parser::Parser::new(tokens).parse() {
Ok(ast) => ast,
Err(e) => {
eprintln!("Parse error: {:?}", e);
return 1;
}
};
let ir = crate::ir_generator::IRGenerator::new().generate(&ast);
let pricing = match model {
"opus" => PricingModel::opus(),
"haiku" => PricingModel::haiku(),
_ => PricingModel::default_sonnet(),
};
let report = estimate_program(&ir, &pricing);
match format {
"json" => {
println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default());
}
_ => {
print!("{}", format_text(&report));
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pricing_sonnet_defaults() {
let p = PricingModel::default_sonnet();
assert_eq!(p.input_per_million, 3.0);
assert_eq!(p.output_per_million, 15.0);
}
#[test]
fn pricing_compute_cost() {
let p = PricingModel::default_sonnet();
let cost = p.compute_cost(1_000_000, 1_000_000);
assert!((cost - 18.0).abs() < 0.001);
}
#[test]
fn pricing_zero_tokens() {
let p = PricingModel::default_sonnet();
assert_eq!(p.compute_cost(0, 0), 0.0);
}
#[test]
fn pricing_opus_rates() {
let p = PricingModel::opus();
assert_eq!(p.input_per_million, 15.0);
assert_eq!(p.output_per_million, 75.0);
let cost = p.compute_cost(1_000_000, 1_000_000);
assert!((cost - 90.0).abs() < 0.001);
}
#[test]
fn pricing_haiku_rates() {
let p = PricingModel::haiku();
assert_eq!(p.input_per_million, 0.80);
assert_eq!(p.output_per_million, 4.0);
}
#[test]
fn classify_step_kinds() {
use crate::ir_nodes::*;
let step = IRFlowNode::Step(IRStep {
node_type: "Step",
source_line: 1, source_column: 1,
name: "s1".into(), persona_ref: "".into(),
given: "".into(), ask: "do something".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
});
assert_eq!(classify_node(&step), StepKind::Ask);
let tool = IRFlowNode::UseTool(IRUseToolStep {
node_type: "UseTool",
source_line: 1, source_column: 1,
tool_name: "search".into(), argument: "q".into(),
});
assert_eq!(classify_node(&tool), StepKind::ToolCall);
let reason = IRFlowNode::Reason(IRReasonStep {
node_type: "Reason",
source_line: 1, source_column: 1,
strategy: "deductive".into(), target: "t".into(),
});
assert_eq!(classify_node(&reason), StepKind::Reason);
}
#[test]
fn count_steps_flat() {
use crate::ir_nodes::*;
let nodes = vec![
IRFlowNode::Step(IRStep {
node_type: "Step", source_line: 1, source_column: 1,
name: "s1".into(), persona_ref: "".into(),
given: "".into(), ask: "a".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
}),
IRFlowNode::Step(IRStep {
node_type: "Step", source_line: 2, source_column: 1,
name: "s2".into(), persona_ref: "".into(),
given: "".into(), ask: "b".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
}),
IRFlowNode::UseTool(IRUseToolStep {
node_type: "UseTool", source_line: 3, source_column: 1,
tool_name: "t".into(), argument: "a".into(),
}),
];
let counts = count_steps(&nodes);
let ask_count = counts.iter().find(|(k, _)| *k == StepKind::Ask).map(|(_, c)| *c).unwrap_or(0);
let tool_count = counts.iter().find(|(k, _)| *k == StepKind::ToolCall).map(|(_, c)| *c).unwrap_or(0);
assert_eq!(ask_count, 2);
assert_eq!(tool_count, 1);
}
#[test]
fn count_steps_nested_conditional() {
use crate::ir_nodes::*;
let inner_step = IRFlowNode::Step(IRStep {
node_type: "Step", source_line: 1, source_column: 1,
name: "inner".into(), persona_ref: "".into(),
given: "".into(), ask: "x".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
});
let cond = IRFlowNode::Conditional(IRConditional {
node_type: "Conditional", source_line: 1, source_column: 1,
condition: "c".into(), comparison_op: "==".into(),
comparison_value: "true".into(),
then_body: vec![inner_step],
else_body: vec![],
conditions: vec![],
conjunctor: "".into(),
});
let counts = count_steps(&[cond]);
let control = counts.iter().find(|(k, _)| *k == StepKind::Control).map(|(_, c)| *c).unwrap_or(0);
let ask = counts.iter().find(|(k, _)| *k == StepKind::Ask).map(|(_, c)| *c).unwrap_or(0);
assert_eq!(control, 1); assert_eq!(ask, 1); }
#[test]
fn estimate_program_empty() {
let ir = IRProgram {
node_type: "Program",
source_line: 0, source_column: 0,
personas: vec![], contexts: vec![], anchors: vec![],
tools: vec![], memories: vec![], types: vec![],
flows: vec![], runs: vec![], imports: vec![],
agents: vec![], shields: vec![], daemons: vec![],
ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
psyche_specs: vec![], mandate_specs: vec![],
lambda_data_specs: vec![], compute_specs: vec![],
axonstore_specs: vec![], endpoints: vec![],
dataspace_specs: vec![],
resources: vec![],
fabrics: vec![],
manifests: vec![],
observations: vec![],
intention_tree: None,
reconciles: vec![],
leases: vec![],
ensembles: vec![],
sessions: vec![],
topologies: vec![],
immunes: vec![],
reflexes: vec![],
heals: vec![],
components: vec![],
views: vec![],
channels: vec![],
effects: vec![],
};
let pricing = PricingModel::default_sonnet();
let report = estimate_program(&ir, &pricing);
assert_eq!(report.total_tokens, 0);
assert_eq!(report.estimated_cost_usd, 0.0);
assert!(report.flows.is_empty());
}
#[test]
fn estimate_program_single_flow() {
use crate::ir_nodes::*;
let flow = IRFlow {
node_type: "Flow", source_line: 1, source_column: 1,
name: "Analyze".into(),
parameters: vec![], return_type_name: "".into(),
return_type_generic: "".into(), return_type_optional: false,
steps: vec![
IRFlowNode::Step(IRStep {
node_type: "Step", source_line: 2, source_column: 1,
name: "gather".into(), persona_ref: "".into(),
given: "".into(), ask: "gather data".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
}),
IRFlowNode::Reason(IRReasonStep {
node_type: "Reason", source_line: 3, source_column: 1,
strategy: "deductive".into(), target: "conclusion".into(),
}),
],
edges: vec![],
execution_levels: vec![],
};
let ir = IRProgram {
node_type: "Program",
source_line: 0, source_column: 0,
personas: vec![], contexts: vec![], anchors: vec![],
tools: vec![], memories: vec![], types: vec![],
flows: vec![flow], runs: vec![], imports: vec![],
agents: vec![], shields: vec![], daemons: vec![],
ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
psyche_specs: vec![], mandate_specs: vec![],
lambda_data_specs: vec![], compute_specs: vec![],
axonstore_specs: vec![], endpoints: vec![],
dataspace_specs: vec![],
resources: vec![],
fabrics: vec![],
manifests: vec![],
observations: vec![],
intention_tree: None,
reconciles: vec![],
leases: vec![],
ensembles: vec![],
sessions: vec![],
topologies: vec![],
immunes: vec![],
reflexes: vec![],
heals: vec![],
components: vec![],
views: vec![],
channels: vec![],
effects: vec![],
};
let pricing = PricingModel::default_sonnet();
let report = estimate_program(&ir, &pricing);
assert_eq!(report.flows.len(), 1);
assert_eq!(report.flows[0].flow_name, "Analyze");
assert_eq!(report.flows[0].total_steps, 2);
assert_eq!(report.total_input_tokens, 800 + 1200);
assert_eq!(report.total_output_tokens, 400 + 800);
assert_eq!(report.total_tokens, 3200);
assert!(report.estimated_cost_usd > 0.0);
}
#[test]
fn format_text_contains_flow_name() {
use crate::ir_nodes::*;
let flow = IRFlow {
node_type: "Flow", source_line: 1, source_column: 1,
name: "TestFlow".into(),
parameters: vec![], return_type_name: "".into(),
return_type_generic: "".into(), return_type_optional: false,
steps: vec![
IRFlowNode::Step(IRStep {
node_type: "Step", source_line: 2, source_column: 1,
name: "s1".into(), persona_ref: "".into(),
given: "".into(), ask: "do".into(),
use_tool: None, probe: None, reason: None, weave: None,
output_type: "".into(), confidence_floor: None,
navigate_ref: "".into(), apply_ref: "".into(),
body: vec![],
}),
],
edges: vec![],
execution_levels: vec![],
};
let ir = IRProgram {
node_type: "Program", source_line: 0, source_column: 0,
personas: vec![], contexts: vec![], anchors: vec![],
tools: vec![], memories: vec![], types: vec![],
flows: vec![flow], runs: vec![], imports: vec![],
agents: vec![], shields: vec![], daemons: vec![],
ots_specs: vec![], pix_specs: vec![], corpus_specs: vec![],
psyche_specs: vec![], mandate_specs: vec![],
lambda_data_specs: vec![], compute_specs: vec![],
axonstore_specs: vec![], endpoints: vec![],
dataspace_specs: vec![],
resources: vec![],
fabrics: vec![],
manifests: vec![],
observations: vec![],
intention_tree: None,
reconciles: vec![],
leases: vec![],
ensembles: vec![],
sessions: vec![],
topologies: vec![],
immunes: vec![],
reflexes: vec![],
heals: vec![],
components: vec![],
views: vec![],
channels: vec![],
effects: vec![],
};
let pricing = PricingModel::default_sonnet();
let report = estimate_program(&ir, &pricing);
let text = format_text(&report);
assert!(text.contains("TestFlow"));
assert!(text.contains("claude-sonnet-4"));
assert!(text.contains("Estimated cost:"));
assert!(text.contains("$"));
}
#[test]
fn report_serializes_to_json() {
let pricing = PricingModel::default_sonnet();
let report = CostReport {
pricing: pricing.clone(),
flows: vec![],
total_input_tokens: 1000,
total_output_tokens: 500,
total_tokens: 1500,
estimated_cost_usd: pricing.compute_cost(1000, 500),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"total_tokens\":1500"));
assert!(json.contains("\"claude-sonnet-4\""));
}
#[test]
fn default_estimates_nonzero_for_llm_steps() {
for kind in &[StepKind::Ask, StepKind::ToolCall, StepKind::Reason,
StepKind::Probe, StepKind::Validate, StepKind::Refine,
StepKind::Weave, StepKind::MultiAgent, StepKind::Cognitive] {
let est = default_estimate(*kind);
assert!(est.input_tokens > 0, "{:?} should have nonzero input", kind);
assert!(est.output_tokens > 0, "{:?} should have nonzero output", kind);
}
}
#[test]
fn control_and_parallel_zero_cost() {
let est_ctrl = default_estimate(StepKind::Control);
assert_eq!(est_ctrl.input_tokens, 0);
assert_eq!(est_ctrl.output_tokens, 0);
let est_par = default_estimate(StepKind::Parallel);
assert_eq!(est_par.input_tokens, 0);
assert_eq!(est_par.output_tokens, 0);
}
#[test]
fn memory_steps_low_cost() {
let est = default_estimate(StepKind::Memory);
assert!(est.input_tokens <= 200);
assert!(est.output_tokens <= 100);
}
}