Skip to main content

plato_i2i_dcs/
lib.rs

1//! plato-i2i-dcs — Multi-agent DCS (Distributed Constraint Satisfaction)
2//!
3//! Extends plato-i2i with multi-agent coordination: multiple agents run
4//! specialized DCS, share tiles, negotiate consensus, and fuse beliefs.
5
6use std::collections::HashMap;
7
8pub type AgentId = u32;
9pub type Expertise = Vec<String>;
10
11// ── Belief Score ──────────────────────────────────────────────────────
12
13#[derive(Debug, Clone, Copy)]
14pub struct BeliefScore {
15    pub confidence: f32,
16    pub trust: f32,
17    pub relevance: f32,
18}
19
20impl Default for BeliefScore {
21    fn default() -> Self { Self { confidence: 0.5, trust: 0.5, relevance: 0.5 } }
22}
23
24impl BeliefScore {
25    pub fn new(confidence: f32, trust: f32, relevance: f32) -> Self {
26        Self {
27            confidence: confidence.max(0.0).min(1.0),
28            trust: trust.max(0.0).min(1.0),
29            relevance: relevance.max(0.0).min(1.0),
30        }
31    }
32
33    pub fn composite(&self) -> f32 {
34        (self.confidence * self.trust * self.relevance).powf(1.0 / 3.0)
35    }
36
37    pub fn get(&self, dim: BeliefDimension) -> f32 {
38        match dim {
39            BeliefDimension::Confidence => self.confidence,
40            BeliefDimension::Trust => self.trust,
41            BeliefDimension::Relevance => self.relevance,
42        }
43    }
44
45    pub fn set(&mut self, dim: BeliefDimension, value: f32) {
46        let v = value.max(0.0).min(1.0);
47        match dim {
48            BeliefDimension::Confidence => self.confidence = v,
49            BeliefDimension::Trust => self.trust = v,
50            BeliefDimension::Relevance => self.relevance = v,
51        }
52    }
53
54    pub fn reinforce(&mut self, dim: BeliefDimension, strength: f32) {
55        let cur = self.get(dim);
56        self.set(dim, (cur * 4.0 + strength) / 5.0);
57    }
58
59    pub fn undermine(&mut self, dim: BeliefDimension, strength: f32) {
60        let cur = self.get(dim);
61        self.set(dim, (cur * 4.0 - strength).max(0.0) / 4.0);
62    }
63
64    pub fn decay(&mut self, rate: f32) {
65        let pull = |v: f32| (v + (0.5 - v) * rate).max(0.0).min(1.0);
66        self.confidence = pull(self.confidence);
67        self.trust = pull(self.trust);
68        self.relevance = pull(self.relevance);
69    }
70
71    pub fn actionable(&self, min_c: f32, min_t: f32, min_r: f32) -> bool {
72        self.confidence >= min_c && self.trust >= min_t && self.relevance >= min_r
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum BeliefDimension {
78    Confidence,
79    Trust,
80    Relevance,
81}
82
83// ── Belief Store ──────────────────────────────────────────────────────
84
85pub struct BeliefStore {
86    beliefs: HashMap<String, BeliefScore>,
87    decay_per_tick: f32,
88}
89
90impl Default for BeliefStore {
91    fn default() -> Self { Self::new() }
92}
93
94impl BeliefStore {
95    pub fn new() -> Self {
96        Self { beliefs: HashMap::new(), decay_per_tick: 0.02 }
97    }
98
99    pub fn set(&mut self, key: &str, score: BeliefScore) {
100        self.beliefs.insert(key.to_string(), score);
101    }
102
103    pub fn get(&self, key: &str) -> Option<BeliefScore> {
104        self.beliefs.get(key).copied()
105    }
106
107    pub fn reinforce(&mut self, key: &str, dim: BeliefDimension, strength: f32) {
108        let score = self.beliefs.entry(key.to_string()).or_default();
109        score.reinforce(dim, strength);
110    }
111
112    pub fn undermine(&mut self, key: &str, dim: BeliefDimension, strength: f32) {
113        let score = self.beliefs.entry(key.to_string()).or_default();
114        score.undermine(dim, strength);
115    }
116
117    pub fn tick(&mut self) {
118        for score in self.beliefs.values_mut() {
119            score.decay(self.decay_per_tick);
120        }
121    }
122
123    pub fn len(&self) -> usize { self.beliefs.len() }
124    pub fn is_empty(&self) -> bool { self.beliefs.is_empty() }
125}
126
127// ── Constraint Engine ─────────────────────────────────────────────────
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum AuditOutcome { Pass, Fail(String) }
131
132pub struct ConstraintEngine {
133    forbidden: Vec<String>,
134}
135
136impl Default for ConstraintEngine {
137    fn default() -> Self { Self::new() }
138}
139
140impl ConstraintEngine {
141    pub fn new() -> Self {
142        Self { forbidden: vec!["RM -RF /".to_string(), "DELETE FROM USERS".to_string()] }
143    }
144
145    pub fn audit(&self, command: &str) -> AuditOutcome {
146        for pattern in &self.forbidden {
147            if command.to_uppercase().contains(pattern) {
148                return AuditOutcome::Fail(format!("Forbidden: contains {}", pattern));
149            }
150        }
151        AuditOutcome::Pass
152    }
153}
154
155// ── Locks ─────────────────────────────────────────────────────────────
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum LockSource { Inconsistency, Expert, Inferred, Observation }
159
160impl LockSource {
161    pub fn base_trust(&self) -> f32 {
162        match self {
163            LockSource::Expert => 1.0,
164            LockSource::Inconsistency => 0.8,
165            LockSource::Observation => 0.7,
166            LockSource::Inferred => 0.4,
167        }
168    }
169}
170
171#[derive(Debug, Clone)]
172pub struct Lock {
173    pub id: u64,
174    pub description: String,
175    pub trigger_pattern: String,
176    pub enforcement: String,
177    pub source: LockSource,
178    pub strength: f32,
179    pub verifications: u32,
180    pub violations: u32,
181}
182
183impl Lock {
184    pub fn new(description: &str, trigger: &str, enforcement: &str, source: LockSource) -> Self {
185        use std::time::{SystemTime, UNIX_EPOCH};
186        Self {
187            id: SystemTime::now().duration_since(UNIX_EPOCH)
188                .map(|d| d.as_nanos() as u64).unwrap_or(0),
189            description: description.to_string(),
190            trigger_pattern: trigger.to_string(),
191            enforcement: enforcement.to_string(),
192            source,
193            strength: source.base_trust(),
194            verifications: 0,
195            violations: 0,
196        }
197    }
198
199    pub fn verify(&mut self) -> f32 { self.verifications += 1; self.strength = (self.strength + 0.05).min(1.0); self.strength }
200    pub fn violate(&mut self) { self.violations += 1; self.strength = (self.strength - 0.15).max(0.0); }
201    pub fn is_active(&self, min: f32) -> bool { self.strength >= min }
202
203    pub fn confidence(&self) -> f32 {
204        if self.verifications == 0 && self.violations == 0 { return self.source.base_trust(); }
205        let total = (self.verifications + self.violations) as f32;
206        self.source.base_trust() * (self.verifications as f32 / total)
207    }
208
209    pub fn effective_strength(&self) -> f32 { self.strength * self.confidence() }
210}
211
212#[derive(Debug, Clone)]
213pub struct LockCheck {
214    pub lock_id: u64,
215    pub triggered: bool,
216    pub description: String,
217    pub enforcement: String,
218    pub effective_strength: f32,
219}
220
221pub struct LockAccumulator {
222    locks: HashMap<u64, Lock>,
223    min_strength: f32,
224}
225
226impl Default for LockAccumulator {
227    fn default() -> Self { Self::new() }
228}
229
230impl LockAccumulator {
231    pub fn new() -> Self { Self { locks: HashMap::new(), min_strength: 0.1 } }
232
233    pub fn add(&mut self, lock: Lock) -> u64 {
234        let id = lock.id;
235        self.locks.insert(id, lock);
236        id
237    }
238
239    pub fn check(&self, input: &str) -> Vec<LockCheck> {
240        let mut checks = Vec::new();
241        for lock in self.locks.values() {
242            if !lock.is_active(self.min_strength) { continue; }
243            if input.contains(&lock.trigger_pattern) {
244                checks.push(LockCheck {
245                    lock_id: lock.id,
246                    triggered: true,
247                    description: lock.description.clone(),
248                    enforcement: lock.enforcement.clone(),
249                    effective_strength: lock.effective_strength(),
250                });
251            }
252        }
253        checks.sort_by(|a, b| b.effective_strength.partial_cmp(&a.effective_strength).unwrap());
254        checks
255    }
256
257    pub fn len(&self) -> usize { self.locks.len() }
258    pub fn is_empty(&self) -> bool { self.locks.is_empty() }
259}
260
261// ── Agent State ───────────────────────────────────────────────────────
262
263pub struct AgentState {
264    pub agent_id: AgentId,
265    pub beliefs: BeliefStore,
266    pub constraints: ConstraintEngine,
267    pub expertise: Expertise,
268}
269
270// ── Shared State ──────────────────────────────────────────────────────
271
272pub struct SharedState {
273    pub locks: LockAccumulator,
274    pub fused_beliefs: HashMap<AgentId, BeliefScore>,
275}
276
277// ── Multi-Agent DCS Engine ────────────────────────────────────────────
278
279pub struct MultiAgentDCS {
280    agents: HashMap<AgentId, AgentState>,
281    shared: SharedState,
282}
283
284impl Default for MultiAgentDCS {
285    fn default() -> Self { Self::new() }
286}
287
288impl MultiAgentDCS {
289    pub fn new() -> Self {
290        Self {
291            agents: HashMap::new(),
292            shared: SharedState {
293                locks: LockAccumulator::new(),
294                fused_beliefs: HashMap::new(),
295            },
296        }
297    }
298
299    pub fn agent_join(&mut self, agent_id: AgentId, expertise: Expertise) {
300        self.agents.insert(agent_id, AgentState {
301            agent_id,
302            beliefs: BeliefStore::new(),
303            constraints: ConstraintEngine::new(),
304            expertise,
305        });
306    }
307
308    pub fn agent_leave(&mut self, agent_id: AgentId) -> bool {
309        self.agents.remove(&agent_id).is_some()
310    }
311
312    pub fn agent_count(&self) -> usize { self.agents.len() }
313
314    /// Each agent proposes best tiles from local beliefs, fused by trust weight.
315    pub fn dcs_query(&self, query: &str) -> Vec<(AgentId, BeliefScore)> {
316        let mut scored = Vec::new();
317        for (&aid, state) in &self.agents {
318            if let Some(belief) = state.beliefs.get(query) {
319                let fused = self.shared.fused_beliefs.get(&aid)
320                    .copied()
321                    .unwrap_or_default();
322                let weight = belief.composite() * fused.composite();
323                scored.push((aid, belief));
324                let _ = weight; // used for ranking in full impl
325            }
326        }
327        scored.sort_by(|a, b| b.1.composite().partial_cmp(&a.1.composite()).unwrap());
328        scored
329    }
330
331    pub fn check_locks(&self, _agent_id: AgentId, command: &str) -> Vec<LockCheck> {
332        self.shared.locks.check(command)
333    }
334
335    pub fn update_belief(&mut self, agent_id: AgentId, key: &str, dim: BeliefDimension, strength: f32) {
336        if let Some(state) = self.agents.get_mut(&agent_id) {
337            if strength >= 0.0 {
338                state.beliefs.reinforce(key, dim, strength);
339            } else {
340                state.beliefs.undermine(key, dim, strength.abs());
341            }
342            if let Some(fused) = state.beliefs.get(key) {
343                self.shared.fused_beliefs.insert(agent_id, fused);
344            }
345        }
346    }
347
348    pub fn consensus_round(&mut self, agent_ids: &[AgentId]) -> ConsensusResult {
349        let active: Vec<_> = agent_ids.iter().filter(|id| self.agents.contains_key(id)).copied().collect();
350        let disagreement = agent_ids.len().saturating_sub(active.len());
351
352        ConsensusResult {
353            active_agents: active.len(),
354            disagreement_count: disagreement,
355            disagreement_rate: if agent_ids.is_empty() { 0.0 } else { disagreement as f64 / agent_ids.len() as f64 },
356        }
357    }
358
359    pub fn add_shared_lock(&mut self, lock: Lock) -> u64 {
360        self.shared.locks.add(lock)
361    }
362
363    pub fn constraint_audit(&self, agent_id: AgentId, command: &str) -> AuditOutcome {
364        if let Some(state) = self.agents.get(&agent_id) {
365            state.constraints.audit(command)
366        } else {
367            AuditOutcome::Fail("Agent not found".to_string())
368        }
369    }
370}
371
372#[derive(Debug, Clone)]
373pub struct ConsensusResult {
374    pub active_agents: usize,
375    pub disagreement_count: usize,
376    pub disagreement_rate: f64,
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_dcs_new() {
385        let dcs = MultiAgentDCS::new();
386        assert!(dcs.agents.is_empty());
387        assert!(dcs.shared.locks.is_empty());
388    }
389
390    #[test]
391    fn test_agent_join_leave() {
392        let mut dcs = MultiAgentDCS::new();
393        dcs.agent_join(1, vec!["math".to_string(), "geometry".to_string()]);
394        assert_eq!(dcs.agent_count(), 1);
395        assert!(dcs.agent_leave(1));
396        assert_eq!(dcs.agent_count(), 0);
397        assert!(!dcs.agent_leave(99)); // already gone
398    }
399
400    #[test]
401    fn test_agent_expertise() {
402        let mut dcs = MultiAgentDCS::new();
403        dcs.agent_join(1, vec!["math".to_string(), "geometry".to_string()]);
404        let state = dcs.agents.get(&1).unwrap();
405        assert_eq!(state.expertise, vec!["math", "geometry"]);
406    }
407
408    #[test]
409    fn test_belief_store_set_get() {
410        let mut store = BeliefStore::new();
411        let b = BeliefScore::new(0.9, 0.8, 0.7);
412        store.set("pythagorean", b);
413        let got = store.get("pythagorean").unwrap();
414        assert!((got.confidence - 0.9).abs() < 0.001);
415        assert!(store.get("nonexistent").is_none());
416    }
417
418    #[test]
419    fn test_belief_reinforce_undermine() {
420        let mut store = BeliefStore::new();
421        store.set("test", BeliefScore::new(0.5, 0.5, 0.5));
422        store.reinforce("test", BeliefDimension::Confidence, 1.0);
423        let got = store.get("test").unwrap();
424        assert!(got.confidence > 0.5, "reinforce should increase");
425        store.undermine("test", BeliefDimension::Trust, 1.0);
426        let got2 = store.get("test").unwrap();
427        assert!(got2.trust < 0.5, "undermine should decrease");
428    }
429
430    #[test]
431    fn test_belief_decay() {
432        let mut store = BeliefStore::new();
433        store.set("test", BeliefScore::new(1.0, 1.0, 1.0));
434        store.tick();
435        let got = store.get("test").unwrap();
436        assert!(got.confidence < 1.0, "decay should pull toward 0.5");
437    }
438
439    #[test]
440    fn test_composite_geometric_mean() {
441        let b = BeliefScore::new(1.0, 1.0, 1.0);
442        assert!((b.composite() - 1.0).abs() < 0.001);
443        let z = BeliefScore::new(0.0, 0.0, 0.0);
444        assert!((z.composite() - 0.0).abs() < 0.001);
445    }
446
447    #[test]
448    fn test_actionable() {
449        let b = BeliefScore::new(0.9, 0.8, 0.7);
450        assert!(b.actionable(0.8, 0.7, 0.6));
451        assert!(!b.actionable(1.0, 0.0, 0.0));
452    }
453
454    #[test]
455    fn test_constraint_audit_pass() {
456        let engine = ConstraintEngine::new();
457        assert_eq!(engine.audit("select * from users"), AuditOutcome::Pass);
458    }
459
460    #[test]
461    fn test_constraint_audit_fail() {
462        let engine = ConstraintEngine::new();
463        match engine.audit("RM -RF / HOME") {
464            AuditOutcome::Fail(msg) => assert!(msg.contains("Forbidden")),
465            _ => panic!("should fail"),
466        }
467    }
468
469    #[test]
470    fn test_lock_new_verify_violate() {
471        let mut lock = Lock::new("test", "danger", "BLOCK", LockSource::Expert);
472        assert_eq!(lock.source.base_trust(), 1.0);
473        lock.verify();
474        assert_eq!(lock.verifications, 1);
475        assert!(lock.strength >= 1.0); // base + 0.05, capped at 1.0
476        lock.violate();
477        assert_eq!(lock.violations, 1);
478    }
479
480    #[test]
481    fn test_lock_confidence() {
482        let mut lock = Lock::new("test", "x", "BLOCK", LockSource::Observation);
483        for _ in 0..10 { lock.verify(); }
484        assert!((lock.confidence() - 0.7).abs() < 0.001); // 10/10 * 0.7
485    }
486
487    #[test]
488    fn test_lock_accumulator_check() {
489        let mut acc = LockAccumulator::new();
490        acc.add(Lock::new("rm guard", "rm -rf", "BLOCK", LockSource::Expert));
491        let checks = acc.check("rm -rf /tmp/stuff");
492        assert_eq!(checks.len(), 1);
493        assert!(checks[0].triggered);
494        let no_match = acc.check("echo hello");
495        assert!(no_match.is_empty());
496    }
497
498    #[test]
499    fn test_dcs_query_with_beliefs() {
500        let mut dcs = MultiAgentDCS::new();
501        dcs.agent_join(1, vec!["math".to_string()]);
502        dcs.agent_join(2, vec!["geometry".to_string()]);
503        dcs.update_belief(1, "pythagorean", BeliefDimension::Confidence, 0.9);
504        dcs.update_belief(2, "pythagorean", BeliefDimension::Confidence, 0.7);
505
506        let results = dcs.dcs_query("pythagorean");
507        assert_eq!(results.len(), 2);
508        assert_eq!(results[0].0, 1); // agent 1 has higher belief
509    }
510
511    #[test]
512    fn test_shared_locks_across_agents() {
513        let mut dcs = MultiAgentDCS::new();
514        dcs.agent_join(1, vec!["ops".to_string()]);
515        dcs.agent_join(2, vec!["ops".to_string()]);
516        dcs.add_shared_lock(Lock::new("no rm", "rm -rf", "BLOCK", LockSource::Expert));
517
518        let checks1 = dcs.check_locks(1, "rm -rf /");
519        let checks2 = dcs.check_locks(2, "rm -rf /");
520        assert_eq!(checks1.len(), checks2.len());
521        assert!(checks1[0].triggered);
522    }
523
524    #[test]
525    fn test_consensus_round() {
526        let mut dcs = MultiAgentDCS::new();
527        dcs.agent_join(1, vec!["a".to_string()]);
528        dcs.agent_join(2, vec!["b".to_string()]);
529        let result = dcs.consensus_round(&[1, 2, 99]); // 99 doesn't exist
530        assert_eq!(result.active_agents, 2);
531        assert_eq!(result.disagreement_count, 1);
532        assert!((result.disagreement_rate - 0.333).abs() < 0.01);
533    }
534
535    #[test]
536    fn test_fused_belief_update() {
537        let mut dcs = MultiAgentDCS::new();
538        dcs.agent_join(1, vec!["test".to_string()]);
539        dcs.update_belief(1, "key", BeliefDimension::Confidence, 0.9);
540        let fused = dcs.shared.fused_beliefs.get(&1).unwrap();
541        assert!(fused.confidence > 0.5);
542    }
543
544    #[test]
545    fn test_constraint_audit_unknown_agent() {
546        let dcs = MultiAgentDCS::new();
547        match dcs.constraint_audit(99, "anything") {
548            AuditOutcome::Fail(msg) => assert!(msg.contains("not found")),
549            _ => panic!("should fail for unknown agent"),
550        }
551    }
552
553    #[test]
554    fn test_belief_dimension_set_get() {
555        let mut b = BeliefScore::default();
556        b.set(BeliefDimension::Trust, 0.99);
557        assert!((b.get(BeliefDimension::Trust) - 0.99).abs() < 0.001);
558        assert!((b.get(BeliefDimension::Confidence) - 0.5).abs() < 0.001); // unchanged
559    }
560
561    #[test]
562    fn test_lock_active_threshold() {
563        let lock = Lock::new("weak", "x", "WARN", LockSource::Inferred);
564        assert!(lock.is_active(0.3)); // 0.4 > 0.3
565        assert!(!lock.is_active(0.5)); // 0.4 < 0.5
566    }
567}