Skip to main content

scemadex_sdk/
mesh.rs

1use std::sync::Mutex;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Result, ScemaDexError};
7use crate::intent::Intent;
8use crate::policy::Solution;
9use crate::primitives::Usdc;
10
11/// A bonded solution offered by a peer agent, with the price it charges to
12/// reveal it. Buying it means paying the fee *and* inheriting the seller's bond:
13/// trust is enforced, not assumed.
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct InferenceOffer {
16    pub solution: Solution,
17    pub price: Usdc,
18    pub peer_id: String,
19}
20
21/// A batch of reinforcement-learning transitions a peer will sell so others can
22/// bootstrap their own agents — a market for **experience**, not just answers.
23/// The payload is a model-agnostic encoding of `(state, action, reward,
24/// next_state)` tuples.
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct ExperienceBatch {
27    pub peer_id: String,
28    pub transitions: u32,
29    pub price: Usdc,
30    pub payload: Vec<u8>,
31}
32
33impl ExperienceBatch {
34    /// Stable content digest identifying this batch in training lineage
35    /// ([`crate::lineage`]) and royalty attribution.
36    ///
37    /// FNV-1a over the canonical JSON encoding, mirroring
38    /// [`crate::intent::Intent::digest`] so the lean core needs no crypto-hash
39    /// dependency; on-chain settlement can swap in a stronger hash.
40    pub fn digest(&self) -> String {
41        let s = serde_json::to_string(self).unwrap_or_default();
42        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
43        for b in s.bytes() {
44            h ^= b as u64;
45            h = h.wrapping_mul(0x0000_0100_0000_01b3);
46        }
47        format!("{h:016x}")
48    }
49}
50
51/// The headline primitive: a mesh where autonomous agents **trade intelligence**.
52///
53/// Each node can sell its bonded inferences and its learned experience, and buy
54/// better ones from peers — all metered and settled in USDC over x402. An agent
55/// earns from what it knows and spends to learn faster, turning ScemaDEX from a
56/// swap widget into an *economy of machine intelligence*.
57#[async_trait]
58pub trait PeerMarket: Send + Sync {
59    /// Buy the best available bonded inference for an intent from the mesh.
60    async fn buy_inference(&self, intent: &Intent) -> Result<InferenceOffer>;
61    /// Publish a bonded inference for sale.
62    async fn sell_inference(&self, offer: InferenceOffer) -> Result<()>;
63    /// Buy a batch of experience to accelerate local training.
64    async fn buy_experience(&self, max_price: Usdc) -> Result<ExperienceBatch>;
65    /// Publish a batch of experience for sale.
66    async fn sell_experience(&self, batch: ExperienceBatch) -> Result<()>;
67}
68
69/// An in-process [`PeerMarket`]: a local order book of inference offers and
70/// experience batches. Fully functional and testable without a network; a
71/// networked implementation (gossip + x402-settled) slots in later behind the
72/// same trait, so callers and the dashboard never change.
73#[derive(Default)]
74pub struct LocalPeerMarket {
75    offers: Mutex<Vec<InferenceOffer>>,
76    experience: Mutex<Vec<ExperienceBatch>>,
77}
78
79impl LocalPeerMarket {
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Number of inference offers currently listed.
85    pub fn offer_count(&self) -> usize {
86        self.offers.lock().map(|v| v.len()).unwrap_or(0)
87    }
88
89    /// Number of experience batches currently listed.
90    pub fn experience_count(&self) -> usize {
91        self.experience.lock().map(|v| v.len()).unwrap_or(0)
92    }
93}
94
95#[async_trait]
96impl PeerMarket for LocalPeerMarket {
97    async fn buy_inference(&self, intent: &Intent) -> Result<InferenceOffer> {
98        let digest = intent.digest();
99        let mut offers = self
100            .offers
101            .lock()
102            .map_err(|_| ScemaDexError::Mesh("offers lock poisoned".into()))?;
103        // Cheapest matching offer wins; it is consumed (removed) on purchase.
104        let idx = offers
105            .iter()
106            .enumerate()
107            .filter(|(_, o)| o.solution.intent_digest == digest)
108            .min_by_key(|(_, o)| o.price.0)
109            .map(|(i, _)| i);
110        match idx {
111            Some(i) => Ok(offers.remove(i)),
112            None => Err(ScemaDexError::Mesh(format!(
113                "no inference offer for intent {digest}"
114            ))),
115        }
116    }
117
118    async fn sell_inference(&self, offer: InferenceOffer) -> Result<()> {
119        self.offers
120            .lock()
121            .map_err(|_| ScemaDexError::Mesh("offers lock poisoned".into()))?
122            .push(offer);
123        Ok(())
124    }
125
126    async fn buy_experience(&self, max_price: Usdc) -> Result<ExperienceBatch> {
127        let mut exp = self
128            .experience
129            .lock()
130            .map_err(|_| ScemaDexError::Mesh("experience lock poisoned".into()))?;
131        let idx = exp
132            .iter()
133            .enumerate()
134            .filter(|(_, b)| b.price <= max_price)
135            .min_by_key(|(_, b)| b.price.0)
136            .map(|(i, _)| i);
137        match idx {
138            Some(i) => Ok(exp.remove(i)),
139            None => Err(ScemaDexError::Mesh(
140                "no experience batch under max_price".into(),
141            )),
142        }
143    }
144
145    async fn sell_experience(&self, batch: ExperienceBatch) -> Result<()> {
146        self.experience
147            .lock()
148            .map_err(|_| ScemaDexError::Mesh("experience lock poisoned".into()))?
149            .push(batch);
150        Ok(())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::policy::{Conviction, Solution};
158    use crate::primitives::Amount;
159    use crate::route::{Route, RouteLeg, Venue};
160
161    fn offer_for(intent: &Intent, price: f64, peer: &str) -> InferenceOffer {
162        let leg = RouteLeg {
163            venue: Venue::Jupiter,
164            input_mint: intent.input_mint.clone(),
165            output_mint: intent.output_mint.clone(),
166            split_bps: 10_000,
167            expected_out: Amount::new(1_000, 6),
168        };
169        InferenceOffer {
170            solution: Solution {
171                intent_digest: intent.digest(),
172                route: Route {
173                    legs: vec![leg],
174                    expected_out: Amount::new(1_000, 6),
175                },
176                conviction: Conviction::clamped(0.7),
177                rationale: "peer".into(),
178            },
179            price: Usdc::from_usdc(price),
180            peer_id: peer.into(),
181        }
182    }
183
184    #[tokio::test]
185    async fn buys_cheapest_matching_inference() {
186        let market = LocalPeerMarket::new();
187        let intent = crate::demo_intent();
188        market
189            .sell_inference(offer_for(&intent, 0.10, "expensive"))
190            .await
191            .unwrap();
192        market
193            .sell_inference(offer_for(&intent, 0.02, "cheap"))
194            .await
195            .unwrap();
196        assert_eq!(market.offer_count(), 2);
197        let bought = market.buy_inference(&intent).await.unwrap();
198        assert_eq!(bought.peer_id, "cheap");
199        assert_eq!(market.offer_count(), 1); // consumed
200    }
201
202    #[tokio::test]
203    async fn experience_respects_price_cap() {
204        let market = LocalPeerMarket::new();
205        market
206            .sell_experience(ExperienceBatch {
207                peer_id: "p".into(),
208                transitions: 100,
209                price: Usdc::from_usdc(1.0),
210                payload: vec![],
211            })
212            .await
213            .unwrap();
214        assert!(market
215            .buy_experience(Usdc::from_usdc(0.5))
216            .await
217            .is_err());
218        assert!(market
219            .buy_experience(Usdc::from_usdc(2.0))
220            .await
221            .is_ok());
222    }
223}