Skip to main content

web4_core/
atp.rs

1// Copyright (c) 2026 MetaLINXX Inc.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! ATP/ADP — Allocation Transfer / Discharge Packets
5//!
6//! Bio-inspired energy metabolism for Web4 societies. ATP is a unit of
7//! account (NOT currency) — each society reifies its own resources
8//! (compute, attention, hardware, time) into ATP at policies it chooses.
9//!
10//! Key invariants:
11//! - Conservation: sum(initial) == sum(final) + total_fees
12//! - Two-state only: tokens exist as ATP or ADP, never both
13//! - Pool-managed: exists in society pools, not per-entity wallets
14//!
15//! Reference: `web4-standard/core-spec/atp-adp-cycle.md`
16
17use crate::error::{Result, Web4Error};
18use serde::{Deserialize, Serialize};
19
20/// ATP account — tracks available, locked (escrowed), and discharged tokens.
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct ATPAccount {
23    /// Tokens available for transfer or locking
24    pub available: f64,
25    /// Tokens locked (escrowed for pending operations)
26    pub locked: f64,
27    /// Discharged tokens (spent — ADP)
28    pub adp: f64,
29    /// Initial balance at creation (for recharge calculations)
30    pub initial_balance: f64,
31}
32
33impl ATPAccount {
34    pub fn new(initial: f64) -> Self {
35        Self {
36            available: initial,
37            locked: 0.0,
38            adp: 0.0,
39            initial_balance: initial,
40        }
41    }
42
43    /// Total active ATP (available + locked). ADP is separate.
44    pub fn total(&self) -> f64 {
45        self.available + self.locked
46    }
47
48    /// Energy ratio: ATP / (ATP + ADP). High = earning, low = spending.
49    /// Returns 0.5 (neutral) if both are zero.
50    pub fn energy_ratio(&self) -> f64 {
51        let total = self.total() + self.adp;
52        if total == 0.0 {
53            0.5
54        } else {
55            self.total() / total
56        }
57    }
58
59    /// Lock tokens from available → locked (escrow for pending operation).
60    pub fn lock(&mut self, amount: f64) -> Result<()> {
61        if amount < 0.0 {
62            return Err(Web4Error::InvalidInput("Lock amount must be non-negative".into()));
63        }
64        if self.available < amount {
65            return Err(Web4Error::InvalidInput(format!(
66                "Insufficient available ATP: {} < {}",
67                self.available, amount
68            )));
69        }
70        self.available -= amount;
71        self.locked += amount;
72        Ok(())
73    }
74
75    /// Commit locked tokens → ADP (discharge). Called on successful completion.
76    pub fn commit(&mut self, amount: f64) -> Result<f64> {
77        if amount < 0.0 {
78            return Err(Web4Error::InvalidInput("Commit amount must be non-negative".into()));
79        }
80        let actual = amount.min(self.locked);
81        self.locked -= actual;
82        self.adp += actual;
83        Ok(actual)
84    }
85
86    /// Rollback locked tokens → available. Called on failure/cancellation.
87    pub fn rollback(&mut self, amount: f64) -> Result<f64> {
88        if amount < 0.0 {
89            return Err(Web4Error::InvalidInput("Rollback amount must be non-negative".into()));
90        }
91        let actual = amount.min(self.locked);
92        self.locked -= actual;
93        self.available += actual;
94        Ok(actual)
95    }
96
97    /// Recharge: add ATP up to max_multiplier * initial_balance.
98    /// Returns actual amount recharged.
99    pub fn recharge(&mut self, rate: f64, max_multiplier: f64) -> f64 {
100        let max_balance = self.initial_balance * max_multiplier;
101        let raw_recharge = self.initial_balance * rate;
102        let space = (max_balance - self.total()).max(0.0);
103        let actual = raw_recharge.min(space);
104        self.available += actual;
105        actual
106    }
107}
108
109impl Default for ATPAccount {
110    fn default() -> Self {
111        Self::new(100.0)
112    }
113}
114
115/// Result of an ATP transfer between two accounts.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct TransferResult {
118    /// Fee charged (additive to sender, not deducted from amount)
119    pub fee: f64,
120    /// Sender's final available balance
121    pub sender_balance: f64,
122    /// Receiver's final available balance
123    pub receiver_balance: f64,
124    /// Amount actually credited to receiver (may be < amount if capped)
125    pub actual_credit: f64,
126    /// Amount returned to sender if receiver hit max_balance cap
127    pub overflow: f64,
128}
129
130/// Transfer ATP between two accounts.
131///
132/// Fee is additive to sender (sender pays amount + fee).
133/// If max_balance is set, excess beyond receiver's cap overflows back to sender.
134///
135/// Conservation invariant: sender_deducted == actual_credit + fee + overflow
136pub fn transfer(
137    sender: &mut ATPAccount,
138    receiver: &mut ATPAccount,
139    amount: f64,
140    fee_rate: f64,
141    max_balance: Option<f64>,
142) -> Result<TransferResult> {
143    if amount < 0.0 {
144        return Err(Web4Error::InvalidInput("Transfer amount must be non-negative".into()));
145    }
146
147    let fee = amount * fee_rate;
148    let total_deduction = amount + fee;
149
150    if sender.available < total_deduction {
151        return Err(Web4Error::InvalidInput(format!(
152            "Insufficient ATP: {} < {} (amount {} + fee {})",
153            sender.available, total_deduction, amount, fee
154        )));
155    }
156
157    let (actual_credit, overflow) = if let Some(max) = max_balance {
158        let space = (max - receiver.available).max(0.0);
159        let credit = amount.min(space);
160        (credit, amount - credit)
161    } else {
162        (amount, 0.0)
163    };
164
165    sender.available -= total_deduction;
166    sender.available += overflow; // return overflow
167    receiver.available += actual_credit;
168
169    Ok(TransferResult {
170        fee,
171        sender_balance: sender.available,
172        receiver_balance: receiver.available,
173        actual_credit,
174        overflow,
175    })
176}
177
178/// Sliding scale payment based on quality score.
179///
180/// Below zero_threshold: pays 0. Above full_threshold: pays full base_payment.
181/// Between: linear interpolation.
182pub fn sliding_scale(
183    quality: f64,
184    base_payment: f64,
185    zero_threshold: f64,
186    full_threshold: f64,
187) -> f64 {
188    if quality < zero_threshold {
189        0.0
190    } else if quality >= full_threshold {
191        base_payment
192    } else {
193        base_payment * (quality - zero_threshold) / (full_threshold - zero_threshold)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_account_lifecycle() {
203        let mut account = ATPAccount::new(100.0);
204        assert_eq!(account.total(), 100.0);
205        assert_eq!(account.energy_ratio(), 1.0); // all ATP, no ADP
206
207        // Lock 30
208        account.lock(30.0).unwrap();
209        assert_eq!(account.available, 70.0);
210        assert_eq!(account.locked, 30.0);
211        assert_eq!(account.total(), 100.0);
212
213        // Commit 20 (discharge to ADP)
214        account.commit(20.0).unwrap();
215        assert_eq!(account.locked, 10.0);
216        assert_eq!(account.adp, 20.0);
217        assert_eq!(account.total(), 80.0);
218
219        // Energy ratio: 80 / (80 + 20) = 0.8
220        assert!((account.energy_ratio() - 0.8).abs() < 1e-10);
221
222        // Rollback remaining locked
223        account.rollback(10.0).unwrap();
224        assert_eq!(account.available, 80.0);
225        assert_eq!(account.locked, 0.0);
226    }
227
228    #[test]
229    fn test_transfer_conservation() {
230        let mut sender = ATPAccount::new(100.0);
231        let mut receiver = ATPAccount::new(50.0);
232
233        let result = transfer(&mut sender, &mut receiver, 30.0, 0.05, None).unwrap();
234
235        assert_eq!(result.fee, 1.5);
236        assert_eq!(result.actual_credit, 30.0);
237        assert_eq!(result.overflow, 0.0);
238        assert_eq!(sender.available, 68.5); // 100 - 30 - 1.5
239        assert_eq!(receiver.available, 80.0); // 50 + 30
240
241        // Conservation: sender lost 31.5, receiver gained 30, fee = 1.5
242        // 31.5 == 30 + 1.5 ✓
243    }
244
245    #[test]
246    fn test_transfer_with_max_balance() {
247        let mut sender = ATPAccount::new(100.0);
248        let mut receiver = ATPAccount::new(90.0);
249
250        // Receiver can only take 10 more (max_balance = 100)
251        let result = transfer(&mut sender, &mut receiver, 30.0, 0.0, Some(100.0)).unwrap();
252
253        assert_eq!(result.actual_credit, 10.0);
254        assert_eq!(result.overflow, 20.0);
255        assert_eq!(sender.available, 90.0); // 100 - 30 + 20 overflow
256        assert_eq!(receiver.available, 100.0);
257    }
258
259    #[test]
260    fn test_recharge() {
261        let mut account = ATPAccount::new(100.0);
262        account.available = 50.0; // spent some
263
264        let recharged = account.recharge(0.1, 3.0);
265        assert_eq!(recharged, 10.0); // 100 * 0.1 = 10, space = 300 - 50 = 250
266        assert_eq!(account.available, 60.0);
267    }
268
269    #[test]
270    fn test_sliding_scale() {
271        assert_eq!(sliding_scale(0.1, 100.0, 0.3, 0.7), 0.0);
272        assert!((sliding_scale(0.5, 100.0, 0.3, 0.7) - 50.0).abs() < 1e-10);
273        assert_eq!(sliding_scale(0.8, 100.0, 0.3, 0.7), 100.0);
274    }
275
276    #[test]
277    fn test_zero_balance_energy_ratio() {
278        let account = ATPAccount::new(0.0);
279        assert_eq!(account.energy_ratio(), 0.5); // neutral
280    }
281
282    #[test]
283    fn test_insufficient_balance() {
284        let mut sender = ATPAccount::new(10.0);
285        let mut receiver = ATPAccount::new(0.0);
286        let result = transfer(&mut sender, &mut receiver, 20.0, 0.0, None);
287        assert!(result.is_err());
288    }
289}