1use crate::colony::{Colony, ColonyEvent, ColonySnapshot};
10use phago_core::topology::TopologyGraph;
11use serde::Serialize;
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, Serialize)]
16pub struct TransferMetrics {
17 pub shared_terms: usize,
19 pub total_terms: usize,
21 pub shared_term_ratio: f64,
23 pub avg_vocabulary_size: f64,
25 pub total_exports: usize,
27 pub total_integrations: usize,
29}
30
31#[derive(Debug, Clone, Serialize)]
33pub struct DissolutionMetrics {
34 pub dissolved_node_avg_access: f64,
36 pub non_dissolved_avg_access: f64,
38 pub reinforcement_ratio: f64,
40 pub total_dissolutions: usize,
42 pub total_terms_externalized: usize,
44}
45
46#[derive(Debug, Clone, Serialize)]
48pub struct GraphRichnessMetrics {
49 pub node_count: usize,
50 pub edge_count: usize,
51 pub density: f64,
53 pub avg_degree: f64,
54 pub clustering_coefficient: f64,
56 pub bridge_concepts: usize,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct VocabularySpreadMetrics {
63 pub per_agent_sizes: Vec<usize>,
65 pub gini_coefficient: f64,
67 pub max_vocabulary: usize,
68 pub min_vocabulary: usize,
69}
70
71#[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
80pub 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
95pub 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 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 let graph = colony.substrate().graph();
160 let all_nodes = graph.all_nodes();
161 let total_terms = all_nodes.len();
162
163 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 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 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 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 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 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 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 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 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 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
476pub 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}