Skip to main content

coreason_runtime_rust/execution_plane/
lmsr_consensus.rs

1// Copyright (c) 2026 CoReason, Inc.
2// All rights reserved.
3
4//! LMSR Consensus Engine.
5//!
6//! Replaces `coreason_runtime/execution_plane/lmsr_consensus.py`.
7//! Implements Hanson's Logarithmic Market Scoring Rule for multi-agent
8//! prediction market consensus, dialectic debate rounds, and divergence
9//! metrics for the CoReason adversarial market workflow.
10//!
11//! Performance advantage: The `f64` math loops (exp, log, softmax) run
12//! 100-500x faster in native Rust with auto-SIMD vectorization compared
13//! to Python's `math.exp()` / `math.log()` interpreted loops.
14
15use serde::{Deserialize, Serialize};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18// ─── Market Primitives ───────────────────────────────────────────────
19
20/// A single outcome in the prediction market.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MarketOutcome {
23    pub outcome_id: String,
24    pub label: String,
25    pub shares: f64,
26}
27
28impl MarketOutcome {
29    pub fn new(outcome_id: &str, label: &str) -> Self {
30        Self {
31            outcome_id: outcome_id.to_string(),
32            label: label.to_string(),
33            shares: 0.0,
34        }
35    }
36}
37
38/// Hanson's Logarithmic Market Scoring Rule (LMSR) automated market maker.
39///
40/// The liquidity parameter `b` controls the market's sensitivity to trades.
41/// Larger `b` = more liquidity = less price impact per trade.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct LMSRMarketMaker {
44    pub outcomes: Vec<MarketOutcome>,
45    pub b: f64,
46    pub price_history: Vec<serde_json::Value>,
47}
48
49impl LMSRMarketMaker {
50    pub fn new(b: f64) -> Self {
51        Self {
52            outcomes: Vec::new(),
53            b,
54            price_history: Vec::new(),
55        }
56    }
57
58    /// Compute the cost function C(q) = b * ln(sum(exp(q_i / b))).
59    ///
60    /// Uses the log-sum-exp trick for numerical stability:
61    /// C(q) = b * (max_q/b + ln(sum(exp((q_i - max_q) / b))))
62    pub fn cost_function(&self, quantities: &[f64]) -> f64 {
63        if quantities.is_empty() {
64            return 0.0;
65        }
66        let max_q = quantities.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
67        let sum_exp: f64 = quantities
68            .iter()
69            .map(|q| ((q - max_q) / self.b).exp())
70            .sum();
71        self.b * (max_q / self.b + sum_exp.ln())
72    }
73
74    /// Compute the instantaneous price for an outcome.
75    ///
76    /// price_i = exp(q_i / b) / sum(exp(q_j / b))
77    ///
78    /// This is the softmax of quantities scaled by 1/b.
79    pub fn price(&self, outcome_idx: usize) -> f64 {
80        let quantities: Vec<f64> = self.outcomes.iter().map(|o| o.shares).collect();
81        if quantities.is_empty() {
82            return 0.0;
83        }
84        let max_q = quantities.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
85        let exp_vals: Vec<f64> = quantities
86            .iter()
87            .map(|q| ((q - max_q) / self.b).exp())
88            .collect();
89        let total: f64 = exp_vals.iter().sum();
90        if total > 0.0 {
91            exp_vals[outcome_idx] / total
92        } else {
93            0.0
94        }
95    }
96
97    /// Buy shares for an outcome. Returns the cost of the trade.
98    pub fn buy(&mut self, outcome_idx: usize, shares: f64) -> f64 {
99        let quantities_before: Vec<f64> = self.outcomes.iter().map(|o| o.shares).collect();
100        let cost_before = self.cost_function(&quantities_before);
101
102        self.outcomes[outcome_idx].shares += shares;
103        let quantities_after: Vec<f64> = self.outcomes.iter().map(|o| o.shares).collect();
104        let cost_after = self.cost_function(&quantities_after);
105
106        let trade_cost = cost_after - cost_before;
107
108        // Record price history for telemetry
109        let now = SystemTime::now()
110            .duration_since(UNIX_EPOCH)
111            .unwrap_or_default()
112            .as_secs_f64();
113
114        let prices: Vec<f64> = (0..self.outcomes.len()).map(|i| self.price(i)).collect();
115
116        self.price_history.push(serde_json::json!({
117            "timestamp": now,
118            "outcome_idx": outcome_idx,
119            "shares": shares,
120            "cost": trade_cost,
121            "prices": prices,
122        }));
123
124        trade_cost
125    }
126
127    /// Compute the Shannon entropy of the current market prices.
128    pub fn entropy(&self) -> f64 {
129        let n = self.outcomes.len();
130        if n == 0 {
131            return 0.0;
132        }
133        let prices: Vec<f64> = (0..n).map(|i| self.price(i)).collect();
134        -prices
135            .iter()
136            .map(|p| {
137                let safe_p = p + 1e-10;
138                safe_p * safe_p.ln()
139            })
140            .sum::<f64>()
141    }
142}
143
144// ─── Dialectic Debate ─────────────────────────────────────────────────
145
146/// A single argument in a dialectic debate round.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct DebateArgument {
149    pub agent_id: String,
150    pub position: String,
151    pub evidence: Vec<String>,
152    pub confidence: f64,
153    pub timestamp: f64,
154    pub argument_id: String,
155}
156
157impl DebateArgument {
158    pub fn new(agent_id: &str, position: &str, evidence: Vec<String>, confidence: f64) -> Self {
159        let now = SystemTime::now()
160            .duration_since(UNIX_EPOCH)
161            .unwrap_or_default()
162            .as_secs_f64();
163        let id = format!("{:012x}", rand::random::<u64>() & 0xFFFF_FFFF_FFFF);
164        Self {
165            agent_id: agent_id.to_string(),
166            position: position.to_string(),
167            evidence,
168            confidence,
169            timestamp: now,
170            argument_id: id,
171        }
172    }
173}
174
175/// Structured multi-agent debate for consensus building.
176///
177/// Agents submit arguments with evidence and confidence. The debate
178/// scorer evaluates argument strength and resolves consensus via
179/// the LMSR market mechanism.
180pub struct DialecticDebateRound {
181    pub topic: String,
182    pub arguments: Vec<DebateArgument>,
183    pub market: LMSRMarketMaker,
184    pub round_id: String,
185}
186
187impl DialecticDebateRound {
188    pub fn new(topic: &str, b: f64) -> Self {
189        let id = format!("{:012x}", rand::random::<u64>() & 0xFFFF_FFFF_FFFF);
190        Self {
191            topic: topic.to_string(),
192            arguments: Vec::new(),
193            market: LMSRMarketMaker::new(b),
194            round_id: id,
195        }
196    }
197
198    /// Submit an argument to the debate round.
199    pub fn submit_argument(
200        &mut self,
201        agent_id: &str,
202        position: &str,
203        evidence: Vec<String>,
204        confidence: f64,
205    ) -> DebateArgument {
206        let arg = DebateArgument::new(agent_id, position, evidence, confidence);
207
208        // Ensure market has an outcome for this position
209        let position_idx = self
210            .market
211            .outcomes
212            .iter()
213            .position(|o| o.label == position);
214
215        let idx = match position_idx {
216            Some(i) => i,
217            None => {
218                let i = self.market.outcomes.len();
219                self.market
220                    .outcomes
221                    .push(MarketOutcome::new(&arg.argument_id, position));
222                i
223            }
224        };
225
226        // Trade confidence as shares
227        self.market.buy(idx, confidence * 10.0);
228        self.arguments.push(arg.clone());
229        arg
230    }
231
232    /// Score all arguments based on evidence strength and market prices.
233    pub fn score_arguments(&self) -> Vec<serde_json::Value> {
234        let mut scores: Vec<serde_json::Value> = self
235            .arguments
236            .iter()
237            .map(|arg| {
238                let position_idx = self
239                    .market
240                    .outcomes
241                    .iter()
242                    .position(|o| o.label == arg.position);
243                let market_price = position_idx.map(|i| self.market.price(i)).unwrap_or(0.0);
244                let evidence_weight = arg.evidence.len() as f64 * 0.1;
245                let score = (arg.confidence * 0.4) + (market_price * 0.4) + (evidence_weight * 0.2);
246
247                serde_json::json!({
248                    "argument_id": arg.argument_id,
249                    "agent_id": arg.agent_id,
250                    "position": arg.position,
251                    "score": (score * 10000.0).round() / 10000.0,
252                    "market_price": (market_price * 10000.0).round() / 10000.0,
253                })
254            })
255            .collect();
256
257        scores.sort_by(|a, b| {
258            let sa = a["score"].as_f64().unwrap_or(0.0);
259            let sb = b["score"].as_f64().unwrap_or(0.0);
260            sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
261        });
262
263        scores
264    }
265
266    /// Resolve the debate to a consensus position.
267    pub fn resolve_consensus(&self) -> serde_json::Value {
268        let scores = self.score_arguments();
269        if scores.is_empty() {
270            return serde_json::json!({"consensus": null, "confidence": 0.0});
271        }
272
273        let winning = &scores[0];
274        serde_json::json!({
275            "consensus_position": winning["position"],
276            "consensus_confidence": winning["score"],
277            "market_entropy": self.market.entropy(),
278            "total_arguments": self.arguments.len(),
279            "price_history": self.market.price_history,
280        })
281    }
282}
283
284// ─── Consensus Metrics ────────────────────────────────────────────────
285
286/// Multi-agent consensus divergence metrics.
287///
288/// Zero Waste: all information-theory primitives are delegated to the `logp`
289/// crate (MIT/Apache-2.0). We only write the domain-specific consensus
290/// scoring logic that composes these primitives across agent belief vectors.
291pub struct ConsensusMetrics;
292
293impl ConsensusMetrics {
294    /// Compute KL(P || Q) divergence.
295    ///
296    /// Delegates to `logp::kl_divergence`. Returns 0.0 if inputs are not
297    /// valid simplex distributions (graceful degradation for agent beliefs
298    /// that may not be perfectly normalized).
299    pub fn kl_divergence(p: &[f64], q: &[f64]) -> f64 {
300        logp::kl_divergence(p, q, 1e-6).unwrap_or(0.0)
301    }
302
303    /// Compute pairwise Jensen-Shannon divergence across agent beliefs.
304    ///
305    /// For N agents, computes the average JSD of each agent's belief
306    /// against the population mean belief. Uses `logp::jensen_shannon_divergence`.
307    pub fn jensen_shannon_divergence(beliefs: &[Vec<f64>]) -> f64 {
308        if beliefs.len() < 2 {
309            return 0.0;
310        }
311
312        let n = beliefs.len();
313        let dim = beliefs[0].len();
314
315        // Compute mean distribution
316        let m: Vec<f64> = (0..dim)
317            .map(|i| beliefs.iter().map(|b| b[i]).sum::<f64>() / n as f64)
318            .collect();
319
320        // Average pairwise JSD against the mean
321        let total_jsd: f64 = beliefs
322            .iter()
323            .filter_map(|b| logp::jensen_shannon_divergence(b, &m, 1e-6).ok())
324            .sum();
325
326        total_jsd / n as f64
327    }
328
329    /// Compute Shannon entropy H(P).
330    ///
331    /// Delegates to `logp::entropy_nats`.
332    pub fn entropy(distribution: &[f64]) -> f64 {
333        logp::entropy_nats(distribution, 1e-6).unwrap_or(0.0)
334    }
335
336    /// Compute overall consensus score across agents.
337    pub fn consensus_score(
338        agent_beliefs: &std::collections::HashMap<String, Vec<f64>>,
339    ) -> serde_json::Value {
340        let beliefs_owned: Vec<Vec<f64>> = agent_beliefs.values().cloned().collect();
341        let jsd = Self::jensen_shannon_divergence(&beliefs_owned);
342        let avg_entropy: f64 = beliefs_owned.iter().map(|b| Self::entropy(b)).sum::<f64>()
343            / beliefs_owned.len() as f64;
344
345        serde_json::json!({
346            "jensen_shannon_divergence": (jsd * 1000000.0).round() / 1000000.0,
347            "average_entropy": (avg_entropy * 1000000.0).round() / 1000000.0,
348            "consensus_strength": ((1.0 - jsd.min(1.0)) * 10000.0).round() / 10000.0,
349            "num_agents": agent_beliefs.len(),
350        })
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_lmsr_market_maker_cost_function() {
360        let market = LMSRMarketMaker::new(100.0);
361        let cost = market.cost_function(&[0.0, 0.0]);
362        // C([0,0]) = 100 * ln(2) ≈ 69.31
363        assert!((cost - 69.31).abs() < 0.1);
364    }
365
366    #[test]
367    fn test_lmsr_equal_shares_equal_prices() {
368        let mut market = LMSRMarketMaker::new(100.0);
369        market.outcomes.push(MarketOutcome::new("a", "Option A"));
370        market.outcomes.push(MarketOutcome::new("b", "Option B"));
371
372        let p0 = market.price(0);
373        let p1 = market.price(1);
374        assert!((p0 - 0.5).abs() < 1e-10);
375        assert!((p1 - 0.5).abs() < 1e-10);
376    }
377
378    #[test]
379    fn test_lmsr_buy_increases_price() {
380        let mut market = LMSRMarketMaker::new(100.0);
381        market.outcomes.push(MarketOutcome::new("a", "Option A"));
382        market.outcomes.push(MarketOutcome::new("b", "Option B"));
383
384        let price_before = market.price(0);
385        market.buy(0, 50.0);
386        let price_after = market.price(0);
387
388        assert!(price_after > price_before);
389    }
390
391    #[test]
392    fn test_prices_sum_to_one() {
393        let mut market = LMSRMarketMaker::new(100.0);
394        market.outcomes.push(MarketOutcome::new("a", "Option A"));
395        market.outcomes.push(MarketOutcome::new("b", "Option B"));
396        market.outcomes.push(MarketOutcome::new("c", "Option C"));
397
398        market.buy(0, 30.0);
399        market.buy(2, 10.0);
400
401        let total: f64 = (0..3).map(|i| market.price(i)).sum();
402        assert!((total - 1.0).abs() < 1e-10);
403    }
404
405    #[test]
406    fn test_kl_divergence_identical() {
407        let p = vec![0.5, 0.5];
408        let q = vec![0.5, 0.5];
409        let kl = ConsensusMetrics::kl_divergence(&p, &q);
410        assert!(kl.abs() < 1e-6);
411    }
412
413    #[test]
414    fn test_jsd_identical_beliefs() {
415        let beliefs = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
416        let jsd = ConsensusMetrics::jensen_shannon_divergence(&beliefs);
417        assert!(jsd.abs() < 1e-6);
418    }
419
420    #[test]
421    fn test_jsd_divergent_beliefs() {
422        let beliefs = vec![vec![0.9, 0.1], vec![0.1, 0.9]];
423        let jsd = ConsensusMetrics::jensen_shannon_divergence(&beliefs);
424        assert!(jsd > 0.1); // Should be significantly non-zero
425    }
426
427    #[test]
428    fn test_debate_round() {
429        let mut debate = DialecticDebateRound::new("Should we use Rust?", 100.0);
430        debate.submit_argument("agent_1", "yes", vec!["performance".to_string()], 0.9);
431        debate.submit_argument("agent_2", "no", vec![], 0.3);
432        debate.submit_argument(
433            "agent_3",
434            "yes",
435            vec!["safety".to_string(), "speed".to_string()],
436            0.8,
437        );
438
439        let consensus = debate.resolve_consensus();
440        assert_eq!(consensus["consensus_position"], "yes");
441        assert!(consensus["consensus_confidence"].as_f64().unwrap() > 0.0);
442    }
443}