1use std::collections::HashMap;
25use std::sync::Mutex;
26
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29
30use crate::bond::{BondLedger, BondOutcome};
31use crate::error::{Result, ScemaDexError};
32use crate::policy::Conviction;
33use crate::primitives::Usdc;
34
35#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
38pub struct TeachAnswer {
39 pub action: u32,
40 pub conviction: Conviction,
41}
42
43#[async_trait]
46pub trait Teacher: Send + Sync {
47 async fn advise(&self, state: &[f32]) -> Result<TeachAnswer>;
48}
49
50#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct TeachTerms {
53 pub teacher_id: String,
54 pub student_id: String,
55 pub query_fee: Usdc,
57 pub bond: Usdc,
59 pub baseline_eval: f64,
61 pub promised_gain: f64,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
67pub struct TeachReceipt {
68 pub session_id: u64,
69 pub queries: u32,
70 pub tuition: Usdc,
72 pub outcome: BondOutcome,
75 pub refund: Usdc,
77}
78
79struct OpenSession {
80 terms: TeachTerms,
81 queries: u32,
82}
83
84#[derive(Default)]
86pub struct TeachingEngine {
87 sessions: Mutex<HashMap<u64, OpenSession>>,
88 next_id: Mutex<u64>,
89 ledger: Mutex<BondLedger>,
90}
91
92impl TeachingEngine {
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn open(&self, terms: TeachTerms) -> Result<u64> {
100 if terms.bond.0 == 0 {
101 return Err(ScemaDexError::Bond(
102 "a teaching bond must be non-zero — free promises teach nothing".into(),
103 ));
104 }
105 let mut next = self
106 .next_id
107 .lock()
108 .map_err(|_| ScemaDexError::Bond("session id lock poisoned".into()))?;
109 let id = *next;
110 *next += 1;
111 self.sessions
112 .lock()
113 .map_err(|_| ScemaDexError::Bond("session lock poisoned".into()))?
114 .insert(id, OpenSession { terms, queries: 0 });
115 Ok(id)
116 }
117
118 pub async fn ask<T: Teacher + ?Sized>(
121 &self,
122 session_id: u64,
123 teacher: &T,
124 state: &[f32],
125 ) -> Result<TeachAnswer> {
126 let answer = teacher.advise(state).await?;
127 let mut sessions = self
128 .sessions
129 .lock()
130 .map_err(|_| ScemaDexError::Bond("session lock poisoned".into()))?;
131 let session = sessions
132 .get_mut(&session_id)
133 .ok_or_else(|| ScemaDexError::Bond(format!("no open teaching session {session_id}")))?;
134 session.queries += 1;
135 Ok(answer)
136 }
137
138 pub fn tuition(&self, session_id: u64) -> Usdc {
140 self.sessions
141 .lock()
142 .ok()
143 .and_then(|s| {
144 s.get(&session_id)
145 .map(|o| Usdc(o.terms.query_fee.0 * o.queries as u64))
146 })
147 .unwrap_or(Usdc(0))
148 }
149
150 pub fn close(&self, session_id: u64, final_eval: f64) -> Result<TeachReceipt> {
153 let session = self
154 .sessions
155 .lock()
156 .map_err(|_| ScemaDexError::Bond("session lock poisoned".into()))?
157 .remove(&session_id)
158 .ok_or_else(|| ScemaDexError::Bond(format!("no open teaching session {session_id}")))?;
159
160 let tuition = Usdc(session.terms.query_fee.0 * session.queries as u64);
161 let promise_met = final_eval >= session.terms.baseline_eval + session.terms.promised_gain;
162 let (outcome, refund) = if promise_met {
163 (BondOutcome::Honored, Usdc(0))
164 } else {
165 (
166 BondOutcome::Slashed,
167 Usdc(tuition.0.min(session.terms.bond.0)),
168 )
169 };
170
171 let mut ledger = self
172 .ledger
173 .lock()
174 .map_err(|_| ScemaDexError::Bond("teaching ledger lock poisoned".into()))?;
175 match outcome {
176 BondOutcome::Honored => ledger.honored += 1,
177 BondOutcome::Slashed => ledger.slashed += 1,
178 }
179
180 Ok(TeachReceipt {
181 session_id,
182 queries: session.queries,
183 tuition,
184 outcome,
185 refund,
186 })
187 }
188
189 pub fn ledger(&self) -> BondLedger {
192 self.ledger.lock().map(|l| *l).unwrap_or_default()
193 }
194
195 pub fn open_sessions(&self) -> usize {
197 self.sessions.lock().map(|s| s.len()).unwrap_or(0)
198 }
199}
200
201pub struct ReferenceTeacher;
206
207#[async_trait]
208impl Teacher for ReferenceTeacher {
209 async fn advise(&self, state: &[f32]) -> Result<TeachAnswer> {
210 if state.is_empty() {
211 return Err(ScemaDexError::Other("empty state query".into()));
212 }
213 let mut best = (0usize, f32::NEG_INFINITY);
214 let mut second = f32::NEG_INFINITY;
215 for (i, &v) in state.iter().enumerate() {
216 if v > best.1 {
217 second = best.1;
218 best = (i, v);
219 } else if v > second {
220 second = v;
221 }
222 }
223 let margin = if second.is_finite() {
224 (best.1 - second) as f64
225 } else {
226 1.0
227 };
228 Ok(TeachAnswer {
229 action: best.0 as u32,
230 conviction: Conviction::clamped(0.5 + margin / 2.0),
231 })
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn terms(bond: u64, baseline: f64, gain: f64) -> TeachTerms {
240 TeachTerms {
241 teacher_id: "sensei".into(),
242 student_id: "kohai".into(),
243 query_fee: Usdc(10_000),
244 bond: Usdc(bond),
245 baseline_eval: baseline,
246 promised_gain: gain,
247 }
248 }
249
250 #[tokio::test]
251 async fn zero_bond_sessions_are_refused() {
252 let engine = TeachingEngine::new();
253 assert!(engine.open(terms(0, 1.0, 0.5)).is_err());
254 }
255
256 #[tokio::test]
257 async fn queries_meter_tuition() {
258 let engine = TeachingEngine::new();
259 let id = engine.open(terms(1_000_000, 1.0, 0.5)).unwrap();
260 for _ in 0..3 {
261 engine
262 .ask(id, &ReferenceTeacher, &[0.1, 0.9, 0.3])
263 .await
264 .unwrap();
265 }
266 assert_eq!(engine.tuition(id), Usdc(30_000));
267 assert!(engine.ask(99, &ReferenceTeacher, &[0.1]).await.is_err());
268 }
269
270 #[tokio::test]
271 async fn improvement_meets_promise_honors_the_bond() {
272 let engine = TeachingEngine::new();
273 let id = engine.open(terms(1_000_000, 1.0, 0.5)).unwrap();
274 engine.ask(id, &ReferenceTeacher, &[0.2, 0.8]).await.unwrap();
275 let receipt = engine.close(id, 1.6).unwrap();
276 assert_eq!(receipt.outcome, BondOutcome::Honored);
277 assert_eq!(receipt.refund, Usdc(0));
278 assert_eq!(engine.ledger().honored, 1);
279 assert_eq!(engine.open_sessions(), 0);
280 }
281
282 #[tokio::test]
283 async fn missed_promise_slashes_and_refunds_tuition_up_to_bond() {
284 let engine = TeachingEngine::new();
285 let id = engine.open(terms(15_000, 1.0, 0.5)).unwrap();
287 for _ in 0..3 {
288 engine.ask(id, &ReferenceTeacher, &[0.2, 0.8]).await.unwrap();
289 }
290 let receipt = engine.close(id, 1.1).unwrap();
291 assert_eq!(receipt.outcome, BondOutcome::Slashed);
292 assert_eq!(receipt.tuition, Usdc(30_000));
293 assert_eq!(receipt.refund, Usdc(15_000)); assert_eq!(engine.ledger().slashed, 1);
295 }
296
297 #[tokio::test]
298 async fn reference_teacher_answers_argmax_with_margin_conviction() {
299 let confident = ReferenceTeacher.advise(&[0.0, 1.0, 0.1]).await.unwrap();
300 assert_eq!(confident.action, 1);
301 let torn = ReferenceTeacher.advise(&[0.5, 0.5]).await.unwrap();
302 assert!(confident.conviction.0 > torn.conviction.0);
303 assert!(ReferenceTeacher.advise(&[]).await.is_err());
304 }
305}