Skip to main content

autonomic_core/
economic.rs

1//! Economic homeostasis types.
2//!
3//! Economics is a core concern from crate zero. Agents need survival pressure:
4//! budget accountability, cost-aware model selection, and identity-based payments.
5
6use serde::{Deserialize, Serialize};
7
8use crate::hysteresis::HysteresisGate;
9
10/// The agent's economic operating mode, determined by balance-to-burn ratio.
11///
12/// Transitions use hysteresis to prevent flapping.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum EconomicMode {
16    /// Balance > 2x monthly burn. Full autonomy.
17    #[default]
18    Sovereign,
19    /// 1-2x monthly burn. Prefer cheaper models, limit expensive tools.
20    Conserving,
21    /// 0-1x monthly burn. Cheapest model only, no expensive tools.
22    Hustle,
23    /// Balance <= 0. Skip LLM calls, heartbeats only.
24    Hibernate,
25}
26
27/// LLM model tier for cost-aware selection.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ModelTier {
31    /// Flagship models (Claude Opus, GPT-4).
32    Flagship,
33    /// Mid-tier models (Claude Sonnet, GPT-4o).
34    #[default]
35    Standard,
36    /// Budget models (Claude Haiku, GPT-4o-mini).
37    Budget,
38}
39
40/// The agent's economic state, accumulated from cost events.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct EconomicState {
43    /// Agent's economic identity address (if registered).
44    pub identity_address: Option<String>,
45    /// Current balance in micro-credits (1 credit = `1_000_000` micro-credits).
46    pub balance_micro_credits: i64,
47    /// Total revenue earned over agent lifetime.
48    pub lifetime_revenue: i64,
49    /// Total costs incurred over agent lifetime.
50    pub lifetime_costs: i64,
51    /// Estimated monthly burn rate in micro-credits.
52    pub monthly_burn_estimate: i64,
53    /// Current economic mode.
54    pub mode: EconomicMode,
55    /// Cost accumulated in the last 5 minutes (micro-credits).
56    pub cost_last_5min: i64,
57    /// Timestamp of the last cost event (ms since epoch).
58    pub last_cost_event_ms: u64,
59    /// Hysteresis gate for economic mode transitions — prevents flapping.
60    pub mode_gate: HysteresisGate,
61}
62
63impl Default for EconomicState {
64    fn default() -> Self {
65        Self {
66            identity_address: None,
67            balance_micro_credits: 10_000_000, // 10 credits initial
68            lifetime_revenue: 0,
69            lifetime_costs: 0,
70            monthly_burn_estimate: 0,
71            mode: EconomicMode::Sovereign,
72            cost_last_5min: 0,
73            last_cost_event_ms: 0,
74            // Mode gate: activates (escalates) when severity metric ≥ 0.7,
75            // deactivates (relaxes) when ≤ 0.3, with 30s min-hold.
76            mode_gate: HysteresisGate::new(0.7, 0.3, 30_000),
77        }
78    }
79}
80
81impl EconomicState {
82    /// Compute the balance-to-burn ratio. Returns `f64::INFINITY` if burn is zero.
83    pub fn balance_to_burn_ratio(&self) -> f64 {
84        if self.monthly_burn_estimate <= 0 {
85            return f64::INFINITY;
86        }
87        self.balance_micro_credits as f64 / self.monthly_burn_estimate as f64
88    }
89}
90
91/// Per-model cost rates in micro-credits per token.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct ModelCostRates {
94    pub input_per_token: i64,
95    pub output_per_token: i64,
96}
97
98impl Default for ModelCostRates {
99    fn default() -> Self {
100        // Default to roughly Sonnet-class pricing
101        Self {
102            input_per_token: 3,   // ~$3/M tokens
103            output_per_token: 15, // ~$15/M tokens
104        }
105    }
106}
107
108/// Reason for a cost charge.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum CostReason {
112    /// LLM inference cost.
113    ModelInference {
114        model: String,
115        prompt_tokens: u32,
116        completion_tokens: u32,
117    },
118    /// Tool execution cost.
119    ToolExecution { tool_name: String },
120    /// Storage cost.
121    Storage { bytes: u64 },
122    /// Manual adjustment.
123    Adjustment { description: String },
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn economic_mode_serde_roundtrip() {
132        for mode in [
133            EconomicMode::Sovereign,
134            EconomicMode::Conserving,
135            EconomicMode::Hustle,
136            EconomicMode::Hibernate,
137        ] {
138            let json = serde_json::to_string(&mode).unwrap();
139            let back: EconomicMode = serde_json::from_str(&json).unwrap();
140            assert_eq!(mode, back);
141        }
142    }
143
144    #[test]
145    fn model_tier_serde_roundtrip() {
146        for tier in [ModelTier::Flagship, ModelTier::Standard, ModelTier::Budget] {
147            let json = serde_json::to_string(&tier).unwrap();
148            let back: ModelTier = serde_json::from_str(&json).unwrap();
149            assert_eq!(tier, back);
150        }
151    }
152
153    #[test]
154    fn economic_state_default() {
155        let state = EconomicState::default();
156        assert_eq!(state.balance_micro_credits, 10_000_000);
157        assert_eq!(state.mode, EconomicMode::Sovereign);
158        assert_eq!(state.lifetime_costs, 0);
159    }
160
161    #[test]
162    fn balance_to_burn_ratio_zero_burn() {
163        let state = EconomicState::default();
164        assert!(state.balance_to_burn_ratio().is_infinite());
165    }
166
167    #[test]
168    fn balance_to_burn_ratio_normal() {
169        let state = EconomicState {
170            balance_micro_credits: 2_000_000,
171            monthly_burn_estimate: 1_000_000,
172            ..Default::default()
173        };
174        assert!((state.balance_to_burn_ratio() - 2.0).abs() < f64::EPSILON);
175    }
176
177    #[test]
178    fn cost_reason_serde_roundtrip() {
179        let reason = CostReason::ModelInference {
180            model: "claude-sonnet".into(),
181            prompt_tokens: 100,
182            completion_tokens: 50,
183        };
184        let json = serde_json::to_string(&reason).unwrap();
185        let back: CostReason = serde_json::from_str(&json).unwrap();
186        assert!(matches!(back, CostReason::ModelInference { .. }));
187    }
188}