Skip to main content

scemadex_sdk/
teach.rs

1//! **Bonded machine teaching** — distillation as a metered, accountable
2//! service (Primitive H).
3//!
4//! The experience market ([`crate::mesh::PeerMarket`]) sells *recordings*: raw
5//! transitions a peer happened to live through. Teaching sells *answers to the
6//! student's actual weaknesses*: the student streams the states it is most
7//! uncertain about (high Q-variance), and a stronger teacher answers each one
8//! with an action and a conviction — metered per query over x402.
9//!
10//! The accountability twist is what makes it new: the teacher posts a bond
11//! against the **outcome of the teaching itself**. A session opens with the
12//! student's measured baseline performance and the teacher's promised gain;
13//! at close, the student is re-evaluated on the same holdout. Improve by at
14//! least the promise → the teacher reclaims its bond and keeps the tuition.
15//! Fall short → the bond slashes to the student, refunding tuition up to the
16//! bond. Active learning becomes a paid service with skin in the game —
17//! "tutoring with a money-back guarantee," between neural networks.
18//!
19//! Honored/slashed teaching history lands in the same ledger shape as
20//! Conviction Routing ([`crate::bond::BondLedger`]), so a teacher's track
21//! record is sellable through the signal oracle like any other reputation.
22//! Run `cargo run -p scemadex-sdk --example bonded_teaching` for the loop.
23
24use 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/// A teacher's answer to one queried state: the action it would take, and how
36/// sure it is. The conviction lets the student weight the distillation target.
37#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
38pub struct TeachAnswer {
39    pub action: u32,
40    pub conviction: Conviction,
41}
42
43/// A policy willing to answer queried states. Implement this over your trained
44/// net to sell tutoring; [`ReferenceTeacher`] is the lean-core stand-in.
45#[async_trait]
46pub trait Teacher: Send + Sync {
47    async fn advise(&self, state: &[f32]) -> Result<TeachAnswer>;
48}
49
50/// The economics of one teaching engagement, fixed at open.
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct TeachTerms {
53    pub teacher_id: String,
54    pub student_id: String,
55    /// Fee metered per answered query (micro-USDC).
56    pub query_fee: Usdc,
57    /// Bond the teacher escrows against the promised improvement (micro-USDC).
58    pub bond: Usdc,
59    /// Student's evaluation score on a holdout episode set, measured at open.
60    pub baseline_eval: f64,
61    /// The teacher's promise: `final_eval >= baseline_eval + promised_gain`.
62    pub promised_gain: f64,
63}
64
65/// The settled result of a teaching session.
66#[derive(Clone, Debug, Serialize, Deserialize)]
67pub struct TeachReceipt {
68    pub session_id: u64,
69    pub queries: u32,
70    /// Total tuition the student paid (`queries × query_fee`).
71    pub tuition: Usdc,
72    /// `Honored` — promise met, teacher reclaims bond and keeps tuition.
73    /// `Slashed` — promise missed, bond slashes to the student.
74    pub outcome: BondOutcome,
75    /// What the student gets back on a slash: tuition refunded up to the bond.
76    pub refund: Usdc,
77}
78
79struct OpenSession {
80    terms: TeachTerms,
81    queries: u32,
82}
83
84/// Opens, meters, and settles bonded teaching sessions.
85#[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    /// Open a session: the teacher's bond is escrowed against its promised
98    /// gain over the student's measured baseline.
99    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    /// Ask the teacher one uncertain state. Meters one `query_fee` of tuition
119    /// and returns the teacher's answer for the student to distill against.
120    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    /// Tuition accrued so far on an open session.
139    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    /// Close the session against the student's re-measured evaluation. The
151    /// promise (`final_eval >= baseline + promised_gain`) decides the bond.
152    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    /// The teacher-side honored/slashed record — same shape the reputation
190    /// oracle sells for routing bonds.
191    pub fn ledger(&self) -> BondLedger {
192        self.ledger.lock().map(|l| *l).unwrap_or_default()
193    }
194
195    /// Sessions currently open (bond escrowed, tuition metering).
196    pub fn open_sessions(&self) -> usize {
197        self.sessions.lock().map(|s| s.len()).unwrap_or(0)
198    }
199}
200
201/// A deterministic lean-core teacher: answers with the argmax feature index,
202/// conviction scaled by the top-two margin. Exists so the full teaching loop
203/// runs offline; a real deployment implements [`Teacher`] over a trained net
204/// (the `scematica` feature's Deep Q* head slots straight in).
205pub 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        // Tiny bond: refund caps at the bond, not the tuition.
286        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)); // min(tuition, bond)
294        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}