Skip to main content

phago_runtime/
metrics.rs

1//! Quantitative metrics for proving biological computing model correctness.
2//!
3//! Computes four categories of proof metrics from colony state:
4//! - Transfer Effect: vocabulary sharing across agents
5//! - Dissolution Effect: boundary modulation reinforces knowledge
6//! - Graph Richness: structural complexity of the knowledge graph
7//! - Vocabulary Spread: how well knowledge propagates
8
9use crate::colony::{Colony, ColonyEvent, ColonySnapshot};
10use phago_core::topology::TopologyGraph;
11use serde::Serialize;
12use std::collections::{HashMap, HashSet};
13
14/// Transfer effect metrics — proves vocabulary sharing works.
15#[derive(Debug, Clone, Serialize)]
16pub struct TransferMetrics {
17    /// Number of unique terms known by 2+ agents.
18    pub shared_terms: usize,
19    /// Total unique terms across all agents.
20    pub total_terms: usize,
21    /// Ratio of shared to total terms.
22    pub shared_term_ratio: f64,
23    /// Average vocabulary size per agent.
24    pub avg_vocabulary_size: f64,
25    /// Total export events.
26    pub total_exports: usize,
27    /// Total integration events.
28    pub total_integrations: usize,
29}
30
31/// Dissolution effect metrics — proves boundary modulation reinforces knowledge.
32#[derive(Debug, Clone, Serialize)]
33pub struct DissolutionMetrics {
34    /// Average access count of Concept nodes (which dissolution reinforces).
35    pub dissolved_node_avg_access: f64,
36    /// Average access count of non-Concept nodes (Insight/Anomaly).
37    pub non_dissolved_avg_access: f64,
38    /// Ratio of dissolved/non-dissolved access (reinforcement ratio).
39    pub reinforcement_ratio: f64,
40    /// Total dissolution events recorded.
41    pub total_dissolutions: usize,
42    /// Total terms externalized across all dissolutions.
43    pub total_terms_externalized: usize,
44}
45
46/// Graph richness metrics — proves colony builds meaningful structure.
47#[derive(Debug, Clone, Serialize)]
48pub struct GraphRichnessMetrics {
49    pub node_count: usize,
50    pub edge_count: usize,
51    /// edge_count / (node_count * (node_count-1) / 2)
52    pub density: f64,
53    pub avg_degree: f64,
54    /// Approximate clustering coefficient.
55    pub clustering_coefficient: f64,
56    /// Number of bridge concepts (connected to nodes from different clusters).
57    pub bridge_concepts: usize,
58}
59
60/// Vocabulary spread metrics — proves knowledge propagates across agents.
61#[derive(Debug, Clone, Serialize)]
62pub struct VocabularySpreadMetrics {
63    /// Per-agent vocabulary sizes (from latest snapshot with agents).
64    pub per_agent_sizes: Vec<usize>,
65    /// Gini coefficient (0 = perfectly equal, 1 = maximally unequal).
66    pub gini_coefficient: f64,
67    pub max_vocabulary: usize,
68    pub min_vocabulary: usize,
69}
70
71/// All colony metrics combined.
72#[derive(Debug, Clone, Serialize)]
73pub struct ColonyMetrics {
74    pub transfer: TransferMetrics,
75    pub dissolution: DissolutionMetrics,
76    pub graph_richness: GraphRichnessMetrics,
77    pub vocabulary_spread: VocabularySpreadMetrics,
78}
79
80/// Compute all proof metrics from the colony's current state and history.
81pub fn compute(colony: &Colony) -> ColonyMetrics {
82    let transfer = compute_transfer(colony);
83    let dissolution = compute_dissolution(colony);
84    let graph_richness = compute_graph_richness(colony);
85    let vocabulary_spread = compute_vocabulary_spread(colony);
86
87    ColonyMetrics {
88        transfer,
89        dissolution,
90        graph_richness,
91        vocabulary_spread,
92    }
93}
94
95/// Compute metrics from snapshots (for use when agents may be dead).
96pub fn compute_from_snapshots(colony: &Colony, snapshots: &[ColonySnapshot]) -> ColonyMetrics {
97    let transfer = compute_transfer_from_snapshots(colony, snapshots);
98    let dissolution = compute_dissolution(colony);
99    let graph_richness = compute_graph_richness(colony);
100    let vocabulary_spread = compute_vocabulary_spread_from_snapshots(snapshots);
101
102    ColonyMetrics {
103        transfer,
104        dissolution,
105        graph_richness,
106        vocabulary_spread,
107    }
108}
109
110fn compute_transfer(colony: &Colony) -> TransferMetrics {
111    // Use live agents if available
112    let agents = colony.agents();
113
114    let mut total_exports = 0usize;
115    let mut total_integrations = 0usize;
116
117    for (_, event) in colony.event_history() {
118        match event {
119            ColonyEvent::CapabilityExported { .. } => total_exports += 1,
120            ColonyEvent::CapabilityIntegrated { .. } => total_integrations += 1,
121            _ => {}
122        }
123    }
124
125    if !agents.is_empty() {
126        let mut term_agent_count: HashMap<String, usize> = HashMap::new();
127        let mut total_vocab_size = 0usize;
128
129        for agent in agents {
130            let vocab = agent.externalize_vocabulary();
131            total_vocab_size += vocab.len();
132            let unique: HashSet<String> = vocab.into_iter().collect();
133            for term in unique {
134                *term_agent_count.entry(term).or_insert(0) += 1;
135            }
136        }
137
138        let shared_terms = term_agent_count.values().filter(|&&c| c >= 2).count();
139        let total_terms = term_agent_count.len();
140        let shared_term_ratio = if total_terms > 0 {
141            shared_terms as f64 / total_terms as f64
142        } else {
143            0.0
144        };
145        let avg_vocabulary_size = total_vocab_size as f64 / agents.len() as f64;
146
147        return TransferMetrics {
148            shared_terms,
149            total_terms,
150            shared_term_ratio,
151            avg_vocabulary_size,
152            total_exports,
153            total_integrations,
154        };
155    }
156
157    // Agents are dead — estimate from graph node co-occurrence
158    // Nodes that appear across multiple documents are "shared" terms
159    let graph = colony.substrate().graph();
160    let all_nodes = graph.all_nodes();
161    let total_terms = all_nodes.len();
162
163    // Nodes with access_count > 1 were reinforced by multiple agents
164    let shared_terms = all_nodes.iter()
165        .filter(|nid| graph.get_node(nid).map_or(false, |n| n.access_count > 1))
166        .count();
167    let shared_term_ratio = if total_terms > 0 {
168        shared_terms as f64 / total_terms as f64
169    } else {
170        0.0
171    };
172
173    // Avg vocabulary from integration events
174    let integrated_terms: usize = colony.event_history().iter()
175        .filter_map(|(_, event)| {
176            if let ColonyEvent::CapabilityIntegrated { terms_count, .. } = event {
177                Some(*terms_count)
178            } else {
179                None
180            }
181        })
182        .sum();
183    let avg_vocabulary_size = if total_integrations > 0 {
184        integrated_terms as f64 / total_integrations as f64
185    } else {
186        0.0
187    };
188
189    TransferMetrics {
190        shared_terms,
191        total_terms,
192        shared_term_ratio,
193        avg_vocabulary_size,
194        total_exports,
195        total_integrations,
196    }
197}
198
199fn compute_transfer_from_snapshots(colony: &Colony, snapshots: &[ColonySnapshot]) -> TransferMetrics {
200    let mut total_exports = 0usize;
201    let mut total_integrations = 0usize;
202
203    for (_, event) in colony.event_history() {
204        match event {
205            ColonyEvent::CapabilityExported { .. } => total_exports += 1,
206            ColonyEvent::CapabilityIntegrated { .. } => total_integrations += 1,
207            _ => {}
208        }
209    }
210
211    // Find the snapshot with the most agents alive (peak activity)
212    let best_snapshot = snapshots.iter()
213        .max_by_key(|s| s.agents.len())
214        .or(snapshots.last());
215
216    if let Some(snap) = best_snapshot {
217        if !snap.agents.is_empty() {
218            let sizes = &snap.agents.iter().map(|a| a.vocabulary_size).collect::<Vec<_>>();
219            let total_vocab: usize = sizes.iter().sum();
220            let avg_vocabulary_size = total_vocab as f64 / snap.agents.len() as f64;
221
222            // Approximate shared terms from graph reinforcement
223            let graph = colony.substrate().graph();
224            let all_nodes = graph.all_nodes();
225            let total_terms = all_nodes.len();
226            let shared_terms = all_nodes.iter()
227                .filter(|nid| graph.get_node(nid).map_or(false, |n| n.access_count > 1))
228                .count();
229            let shared_term_ratio = if total_terms > 0 {
230                shared_terms as f64 / total_terms as f64
231            } else {
232                0.0
233            };
234
235            return TransferMetrics {
236                shared_terms,
237                total_terms,
238                shared_term_ratio,
239                avg_vocabulary_size,
240                total_exports,
241                total_integrations,
242            };
243        }
244    }
245
246    // Fallback to event-based
247    compute_transfer(colony)
248}
249
250fn compute_dissolution(colony: &Colony) -> DissolutionMetrics {
251    let mut total_dissolutions = 0usize;
252    let mut total_terms_externalized = 0usize;
253
254    for (_, event) in colony.event_history() {
255        if let ColonyEvent::Dissolved { terms_externalized, .. } = event {
256            total_dissolutions += 1;
257            total_terms_externalized += terms_externalized;
258        }
259    }
260
261    // Compare access counts of Concept nodes (which dissolution reinforces)
262    // vs Insight/Anomaly nodes (which are not reinforced by dissolution)
263    let graph = colony.substrate().graph();
264    let mut concept_access_sum = 0u64;
265    let mut concept_count = 0u64;
266    let mut other_access_sum = 0u64;
267    let mut other_count = 0u64;
268
269    for nid in graph.all_nodes() {
270        if let Some(node) = graph.get_node(&nid) {
271            match node.node_type {
272                phago_core::types::NodeType::Concept => {
273                    concept_access_sum += node.access_count;
274                    concept_count += 1;
275                }
276                _ => {
277                    other_access_sum += node.access_count;
278                    other_count += 1;
279                }
280            }
281        }
282    }
283
284    let dissolved_avg = if concept_count > 0 {
285        concept_access_sum as f64 / concept_count as f64
286    } else {
287        0.0
288    };
289    let other_avg = if other_count > 0 {
290        other_access_sum as f64 / other_count as f64
291    } else {
292        0.0
293    };
294    let ratio = if other_avg > 0.0 {
295        dissolved_avg / other_avg
296    } else if dissolved_avg > 0.0 {
297        dissolved_avg
298    } else {
299        1.0
300    };
301
302    DissolutionMetrics {
303        dissolved_node_avg_access: dissolved_avg,
304        non_dissolved_avg_access: other_avg,
305        reinforcement_ratio: ratio,
306        total_dissolutions,
307        total_terms_externalized,
308    }
309}
310
311fn compute_graph_richness(colony: &Colony) -> GraphRichnessMetrics {
312    let graph = colony.substrate().graph();
313    let n = graph.node_count();
314    let e = graph.edge_count();
315
316    let max_edges = if n > 1 { n * (n - 1) / 2 } else { 1 };
317    let density = e as f64 / max_edges as f64;
318    let avg_degree = if n > 0 { 2.0 * e as f64 / n as f64 } else { 0.0 };
319
320    // Approximate clustering coefficient
321    let all_nodes = graph.all_nodes();
322    let mut clustering_sum = 0.0f64;
323    let mut clusterable_nodes = 0usize;
324
325    for nid in &all_nodes {
326        let neighbors = graph.neighbors(nid);
327        let k = neighbors.len();
328        if k < 2 {
329            continue;
330        }
331        clusterable_nodes += 1;
332
333        let neighbor_ids: Vec<_> = neighbors.iter().map(|(id, _)| *id).collect();
334        let mut triangles = 0u64;
335        for i in 0..neighbor_ids.len() {
336            for j in (i + 1)..neighbor_ids.len() {
337                if graph.get_edge(&neighbor_ids[i], &neighbor_ids[j]).is_some() {
338                    triangles += 1;
339                }
340            }
341        }
342        let possible = k * (k - 1) / 2;
343        if possible > 0 {
344            clustering_sum += triangles as f64 / possible as f64;
345        }
346    }
347
348    let clustering_coefficient = if clusterable_nodes > 0 {
349        clustering_sum / clusterable_nodes as f64
350    } else {
351        0.0
352    };
353
354    let bridge_threshold = if avg_degree > 0.0 { avg_degree * 1.5 } else { 2.0 };
355    let bridge_concepts = all_nodes.iter()
356        .filter(|nid| graph.neighbors(nid).len() as f64 > bridge_threshold)
357        .count();
358
359    GraphRichnessMetrics {
360        node_count: n,
361        edge_count: e,
362        density,
363        avg_degree,
364        clustering_coefficient,
365        bridge_concepts,
366    }
367}
368
369fn compute_vocabulary_spread(colony: &Colony) -> VocabularySpreadMetrics {
370    let agents = colony.agents();
371    if agents.is_empty() {
372        // Fall back to event-based estimation
373        return compute_vocabulary_spread_from_events(colony);
374    }
375
376    let sizes: Vec<usize> = agents.iter().map(|a| a.vocabulary_size()).collect();
377    let max_vocabulary = *sizes.iter().max().unwrap_or(&0);
378    let min_vocabulary = *sizes.iter().min().unwrap_or(&0);
379    let gini_coefficient = compute_gini(&sizes);
380
381    VocabularySpreadMetrics {
382        per_agent_sizes: sizes,
383        gini_coefficient,
384        max_vocabulary,
385        min_vocabulary,
386    }
387}
388
389fn compute_vocabulary_spread_from_snapshots(snapshots: &[ColonySnapshot]) -> VocabularySpreadMetrics {
390    // Find the snapshot with the most agents (peak activity)
391    let best_snapshot = snapshots.iter()
392        .max_by_key(|s| s.agents.len());
393
394    if let Some(snap) = best_snapshot {
395        if !snap.agents.is_empty() {
396            let sizes: Vec<usize> = snap.agents.iter().map(|a| a.vocabulary_size).collect();
397            let max_vocabulary = *sizes.iter().max().unwrap_or(&0);
398            let min_vocabulary = *sizes.iter().min().unwrap_or(&0);
399            let gini_coefficient = compute_gini(&sizes);
400
401            return VocabularySpreadMetrics {
402                per_agent_sizes: sizes,
403                gini_coefficient,
404                max_vocabulary,
405                min_vocabulary,
406            };
407        }
408    }
409
410    VocabularySpreadMetrics {
411        per_agent_sizes: Vec::new(),
412        gini_coefficient: 0.0,
413        max_vocabulary: 0,
414        min_vocabulary: 0,
415    }
416}
417
418fn compute_vocabulary_spread_from_events(colony: &Colony) -> VocabularySpreadMetrics {
419    // Estimate from capability events — group exported terms by agent
420    let mut agent_terms: HashMap<String, usize> = HashMap::new();
421
422    for (_, event) in colony.event_history() {
423        if let ColonyEvent::CapabilityExported { agent_id, terms_count } = event {
424            let key = agent_id.0.to_string();
425            let entry = agent_terms.entry(key).or_insert(0);
426            *entry = (*entry).max(*terms_count);
427        }
428    }
429
430    if agent_terms.is_empty() {
431        return VocabularySpreadMetrics {
432            per_agent_sizes: Vec::new(),
433            gini_coefficient: 0.0,
434            max_vocabulary: 0,
435            min_vocabulary: 0,
436        };
437    }
438
439    let sizes: Vec<usize> = agent_terms.values().copied().collect();
440    let max_vocabulary = *sizes.iter().max().unwrap_or(&0);
441    let min_vocabulary = *sizes.iter().min().unwrap_or(&0);
442    let gini_coefficient = compute_gini(&sizes);
443
444    VocabularySpreadMetrics {
445        per_agent_sizes: sizes,
446        gini_coefficient,
447        max_vocabulary,
448        min_vocabulary,
449    }
450}
451
452fn compute_gini(values: &[usize]) -> f64 {
453    let n = values.len();
454    if n == 0 {
455        return 0.0;
456    }
457
458    let mut sorted: Vec<f64> = values.iter().map(|&v| v as f64).collect();
459    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
460
461    let mean = sorted.iter().sum::<f64>() / n as f64;
462    if mean == 0.0 {
463        return 0.0;
464    }
465
466    let mut sum_abs_diff = 0.0;
467    for i in 0..n {
468        for j in 0..n {
469            sum_abs_diff += (sorted[i] - sorted[j]).abs();
470        }
471    }
472
473    sum_abs_diff / (2.0 * n as f64 * n as f64 * mean)
474}
475
476/// Print a formatted metrics report to the terminal.
477pub fn print_report(metrics: &ColonyMetrics) {
478    println!("── Quantitative Proof ──────────────────────────────");
479    println!("  Transfer Effect:");
480    println!("    Terms known by 2+ agents:  {} / {} ({:.1}%)",
481        metrics.transfer.shared_terms,
482        metrics.transfer.total_terms,
483        metrics.transfer.shared_term_ratio * 100.0);
484    println!("    Avg vocabulary size:       {:.1} terms/agent",
485        metrics.transfer.avg_vocabulary_size);
486    println!("    Exports / Integrations:    {} / {}",
487        metrics.transfer.total_exports,
488        metrics.transfer.total_integrations);
489    println!();
490    println!("  Dissolution Effect:");
491    println!("    Concept avg access:         {:.1}",
492        metrics.dissolution.dissolved_node_avg_access);
493    println!("    Non-concept avg access:     {:.1}",
494        metrics.dissolution.non_dissolved_avg_access);
495    println!("    Reinforcement ratio:        {:.2}x",
496        metrics.dissolution.reinforcement_ratio);
497    println!("    Dissolutions / Terms:       {} / {}",
498        metrics.dissolution.total_dissolutions,
499        metrics.dissolution.total_terms_externalized);
500    println!();
501    println!("  Graph Richness:");
502    println!("    Density:                    {:.2}",
503        metrics.graph_richness.density);
504    println!("    Avg degree:                 {:.1}",
505        metrics.graph_richness.avg_degree);
506    println!("    Clustering coefficient:     {:.2}",
507        metrics.graph_richness.clustering_coefficient);
508    println!("    Bridge concepts:            {}",
509        metrics.graph_richness.bridge_concepts);
510    println!();
511    println!("  Vocabulary Spread:");
512    println!("    Gini coefficient:           {:.2} (low = well-spread)",
513        metrics.vocabulary_spread.gini_coefficient);
514    println!("    Max vocabulary:             {} terms",
515        metrics.vocabulary_spread.max_vocabulary);
516    println!("    Min vocabulary:             {} terms",
517        metrics.vocabulary_spread.min_vocabulary);
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::colony::Colony;
524    use phago_core::types::Position;
525
526    #[test]
527    fn metrics_compute_on_empty_colony() {
528        let colony = Colony::new();
529        let metrics = compute(&colony);
530        assert_eq!(metrics.transfer.shared_terms, 0);
531        assert_eq!(metrics.graph_richness.node_count, 0);
532        assert_eq!(metrics.vocabulary_spread.per_agent_sizes.len(), 0);
533    }
534
535    #[test]
536    fn metrics_compute_on_populated_colony() {
537        use phago_agents::digester::Digester;
538
539        let mut colony = Colony::new();
540
541        colony.ingest_document(
542            "Test",
543            "The cell membrane controls transport of molecules into the cell. \
544             Proteins serve as channels and receptors.",
545            Position::new(0.0, 0.0),
546        );
547        colony.spawn(Box::new(
548            Digester::new(Position::new(0.0, 0.0)).with_max_idle(80),
549        ));
550        colony.spawn(Box::new(
551            Digester::new(Position::new(1.0, 0.0)).with_max_idle(80),
552        ));
553
554        colony.run(20);
555
556        let metrics = compute(&colony);
557        assert!(metrics.graph_richness.node_count > 0);
558        print_report(&metrics);
559    }
560
561    #[test]
562    fn gini_coefficient_is_correct() {
563        assert!((compute_gini(&[5, 5, 5, 5]) - 0.0).abs() < 0.01);
564
565        let values = vec![0, 0, 0, 100];
566        let g = compute_gini(&values);
567        assert!(g > 0.5, "Gini should be high for unequal distribution: {}", g);
568    }
569}