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#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct InferenceOffer {
16 pub solution: Solution,
17 pub price: Usdc,
18 pub peer_id: String,
19}
20
21#[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 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#[async_trait]
58pub trait PeerMarket: Send + Sync {
59 async fn buy_inference(&self, intent: &Intent) -> Result<InferenceOffer>;
61 async fn sell_inference(&self, offer: InferenceOffer) -> Result<()>;
63 async fn buy_experience(&self, max_price: Usdc) -> Result<ExperienceBatch>;
65 async fn sell_experience(&self, batch: ExperienceBatch) -> Result<()>;
67}
68
69#[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 pub fn offer_count(&self) -> usize {
86 self.offers.lock().map(|v| v.len()).unwrap_or(0)
87 }
88
89 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 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); }
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}