1use car_topology::{
21 CoordinationShape, ExecutionRecord, Homogeneity, JournalError, RecordJournal, ScorerAdvice,
22 Selection, Topology, TopologyError, TopologySelector,
23};
24
25use car_ir::{AgentOutcome, EvidenceKind, OutcomeStatus};
26
27use crate::types::{AgentOutput, AgentSpec};
28
29pub fn measured_tokens(outputs: &[AgentOutput]) -> Option<u64> {
37 let mut total = 0u64;
38 let mut any = false;
39 for output in outputs {
40 if let Some(tokens) = &output.tokens {
41 any = true;
42 total = total
43 .saturating_add(tokens.input_tokens)
44 .saturating_add(tokens.output_tokens);
45 }
46 }
47 any.then_some(total)
48}
49
50pub fn execution_record(
64 task_id: impl Into<String>,
65 query: Vec<f32>,
66 shape: CoordinationShape,
67 team_size: usize,
68 outputs: &[AgentOutput],
69 utility: f32,
70) -> Result<Option<ExecutionRecord>, TopologyError> {
71 if shape == CoordinationShape::Solo {
80 return Err(TopologyError::BadConfig {
81 field: "shape",
82 expected: "a shape with a distinguishable adjacency",
83 found: "solo — indistinguishable from swarm; record the run under its real team size, or not at all"
84 .into(),
85 });
86 }
87 let Some(tokens) = measured_tokens(outputs) else {
88 return Ok(None);
89 };
90 let topology = shape.topology(team_size)?;
91 Ok(Some(ExecutionRecord::new(
92 task_id, query, topology, utility, tokens,
93 )))
94}
95
96pub fn execution_record_for(
99 task_id: impl Into<String>,
100 query: Vec<f32>,
101 topology: Topology,
102 outputs: &[AgentOutput],
103 utility: f32,
104) -> Option<ExecutionRecord> {
105 let tokens = measured_tokens(outputs)?;
106 Some(ExecutionRecord::new(
107 task_id, query, topology, utility, tokens,
108 ))
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum UtilityEvidence {
120 Reported,
122 Grounded,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum UtilityAggregation {
137 Final,
143 Consensus,
150}
151
152pub fn outcome_utility(outcome: &AgentOutcome, evidence: UtilityEvidence) -> Option<f32> {
168 if evidence == UtilityEvidence::Grounded
169 && !outcome
170 .evidence
171 .iter()
172 .any(|e| e.kind != EvidenceKind::SelfAssessment)
173 {
174 return None;
175 }
176 match outcome.status {
177 OutcomeStatus::Success => Some(1.0),
178 OutcomeStatus::PartialSuccess => Some(0.5),
179 OutcomeStatus::Failure | OutcomeStatus::GiveUp | OutcomeStatus::Timeout => Some(0.0),
180 OutcomeStatus::Done => None,
181 }
182}
183
184pub fn run_utility(
202 outputs: &[AgentOutput],
203 aggregation: UtilityAggregation,
204 evidence: UtilityEvidence,
205) -> Option<f32> {
206 let scored: Vec<f32> = outputs
207 .iter()
208 .filter_map(|o| o.outcome.as_ref())
209 .filter_map(|o| outcome_utility(o, evidence))
210 .collect();
211 if scored.is_empty() {
212 return None;
213 }
214 match aggregation {
215 UtilityAggregation::Final => scored.last().copied(),
216 UtilityAggregation::Consensus => Some(scored.iter().sum::<f32>() / scored.len() as f32),
217 }
218}
219
220pub fn record_run_from_outcomes(
233 journal: &mut RecordJournal,
234 embedder: &str,
235 task_id: impl Into<String>,
236 query: Vec<f32>,
237 shape: CoordinationShape,
238 team_size: usize,
239 outputs: &[AgentOutput],
240 aggregation: UtilityAggregation,
241 evidence: UtilityEvidence,
242) -> Result<bool, JournalError> {
243 let Some(utility) = run_utility(outputs, aggregation, evidence) else {
244 return Ok(false);
245 };
246 record_run(
247 journal, embedder, task_id, query, shape, team_size, outputs, utility,
248 )
249}
250
251pub fn record_run(
265 journal: &mut RecordJournal,
266 embedder: &str,
267 task_id: impl Into<String>,
268 query: Vec<f32>,
269 shape: CoordinationShape,
270 team_size: usize,
271 outputs: &[AgentOutput],
272 utility: f32,
273) -> Result<bool, JournalError> {
274 let Some(record) = execution_record(task_id, query, shape, team_size, outputs, utility)? else {
275 return Ok(false);
276 };
277 journal.append(embedder, &record)?;
278 Ok(true)
279}
280
281pub fn team_homogeneity(agents: &[AgentSpec]) -> Homogeneity {
292 let Some(first) = agents.first() else {
293 return Homogeneity::Homogeneous;
294 };
295 if agents
296 .iter()
297 .skip(1)
298 .any(|a| a.system_prompt != first.system_prompt)
299 {
300 Homogeneity::Heterogeneous
301 } else {
302 Homogeneity::Homogeneous
303 }
304}
305
306pub fn scorer_advice(agents: &[AgentSpec]) -> ScorerAdvice {
309 match team_homogeneity(agents) {
310 Homogeneity::Homogeneous => ScorerAdvice {
311 homogeneity: Homogeneity::Homogeneous,
312 message_passing_is_adjacency_blind: true,
313 reason: format!(
314 "all {} agents share one system prompt, so a message-passing scorer over \
315 profile nodes pools identical features and scores every candidate topology \
316 the same; rank on the adjacency itself",
317 agents.len()
318 ),
319 },
320 Homogeneity::Heterogeneous => ScorerAdvice {
321 homogeneity: Homogeneity::Heterogeneous,
322 message_passing_is_adjacency_blind: false,
323 reason: "agents carry different system prompts, so profile-node message passing \
324 can in principle distinguish adjacencies"
325 .into(),
326 },
327 }
328}
329
330pub fn select_shape(
339 selector: &TopologySelector,
340 query: &[f32],
341 default_shape: CoordinationShape,
342) -> Result<(CoordinationShape, Selection), TopologyError> {
343 let selection = selector.select(query)?;
344 let shape = selection.shape.or_else(|| {
347 let mut named: Vec<_> = selection
348 .considered
349 .iter()
350 .filter(|c| c.shape.is_some())
351 .collect();
352 named.sort_by(|a, b| {
353 b.objective
354 .total_cmp(&a.objective)
355 .then(a.code.cmp(&b.code))
356 });
357 named.first().and_then(|c| c.shape)
358 });
359 Ok((shape.unwrap_or(default_shape), selection))
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use crate::types::TokenAccounting;
366 use car_topology::{RecordSet, SelectorConfig};
367
368 fn output(name: &str, tokens: Option<(u64, u64)>) -> AgentOutput {
369 AgentOutput {
370 name: name.into(),
371 answer: "done".into(),
372 turns: 1,
373 tool_calls: 0,
374 duration_ms: 1.0,
375 error: None,
376 outcome: None,
377 tokens: tokens.map(|(i, o)| TokenAccounting::new(i, o, 0.0)),
378 tools_used: Vec::new(),
379 }
380 }
381
382 fn spec(name: &str, prompt: &str) -> AgentSpec {
383 AgentSpec::new(name, prompt)
384 }
385
386 #[test]
387 fn tokens_sum_across_outputs() {
388 let outputs = vec![output("a", Some((100, 20))), output("b", Some((50, 30)))];
389 assert_eq!(measured_tokens(&outputs), Some(200));
390 }
391
392 #[test]
393 fn an_unmetered_run_reports_none_not_zero() {
394 let outputs = vec![output("a", None), output("b", None)];
395 assert_eq!(measured_tokens(&outputs), None);
396 assert_eq!(
397 execution_record(
398 "t",
399 vec![0.5],
400 CoordinationShape::Pipeline,
401 4,
402 &outputs,
403 1.0
404 )
405 .unwrap(),
406 None
407 );
408 }
409
410 #[test]
411 fn a_partially_metered_run_still_counts() {
412 let outputs = vec![output("a", Some((100, 20))), output("b", None)];
413 assert_eq!(measured_tokens(&outputs), Some(120));
414 }
415
416 #[test]
417 fn a_solo_run_is_refused_rather_than_recorded_as_a_swarm() {
418 let outputs = vec![output("a", Some((100, 20)))];
423 assert!(matches!(
424 execution_record("t", vec![0.5], CoordinationShape::Solo, 4, &outputs, 1.0),
425 Err(TopologyError::BadConfig { field: "shape", .. })
426 ));
427 assert!(
429 execution_record("t", vec![0.5], CoordinationShape::Swarm, 4, &outputs, 1.0)
430 .unwrap()
431 .is_some()
432 );
433 }
434
435 #[test]
436 fn a_record_carries_the_shapes_topology_over_the_team_size() {
437 let outputs = vec![output("a", Some((100, 20)))];
438 let record = execution_record("t", vec![0.5], CoordinationShape::Debate, 4, &outputs, 1.0)
439 .unwrap()
440 .unwrap();
441 assert_eq!(record.topology, Topology::complete(4).unwrap());
442 assert_eq!(record.tokens, 120);
443 assert_eq!(record.task_id, "t");
444 }
445
446 #[test]
447 fn a_cloned_team_is_homogeneous_whatever_the_names() {
448 let team = vec![
449 spec("solver_1", "You solve math problems."),
450 spec("solver_2", "You solve math problems."),
451 spec("solver_3", "You solve math problems."),
452 ];
453 let advice = scorer_advice(&team);
454 assert_eq!(advice.homogeneity, Homogeneity::Homogeneous);
455 assert!(advice.message_passing_is_adjacency_blind);
456 assert!(advice.reason.contains("share one system prompt"));
457 }
458
459 #[test]
460 fn distinct_roles_are_heterogeneous() {
461 let team = vec![
462 spec("researcher", "You gather evidence."),
463 spec("verifier", "You check the answer."),
464 ];
465 assert_eq!(team_homogeneity(&team), Homogeneity::Heterogeneous);
466 assert!(!scorer_advice(&team).message_passing_is_adjacency_blind);
467 }
468
469 #[test]
470 fn an_empty_team_is_homogeneous() {
471 assert_eq!(team_homogeneity(&[]), Homogeneity::Homogeneous);
472 }
473
474 fn outcome(status: OutcomeStatus, kinds: &[EvidenceKind]) -> AgentOutcome {
475 AgentOutcome {
476 status,
477 summary: String::new(),
478 evidence: kinds
479 .iter()
480 .map(|&kind| car_ir::Evidence {
481 kind,
482 description: String::new(),
483 data: None,
484 })
485 .collect(),
486 metrics: Default::default(),
487 timestamp: chrono::Utc::now(),
488 }
489 }
490
491 fn output_with(
492 name: &str,
493 tokens: Option<(u64, u64)>,
494 status: OutcomeStatus,
495 kinds: &[EvidenceKind],
496 ) -> AgentOutput {
497 let mut o = output(name, tokens);
498 o.outcome = Some(outcome(status, kinds));
499 o
500 }
501
502 #[test]
503 fn the_status_to_utility_mapping_is_the_one_the_type_dictates() {
504 let ev = &[EvidenceKind::ToolResult];
505 for (status, expected) in [
506 (OutcomeStatus::Success, Some(1.0)),
507 (OutcomeStatus::PartialSuccess, Some(0.5)),
508 (OutcomeStatus::Failure, Some(0.0)),
509 (OutcomeStatus::GiveUp, Some(0.0)),
510 (OutcomeStatus::Timeout, Some(0.0)),
511 ] {
512 assert_eq!(
513 outcome_utility(&outcome(status, ev), UtilityEvidence::Reported),
514 expected,
515 "{status:?}"
516 );
517 }
518 }
519
520 #[test]
521 fn a_neutral_done_carries_no_success_signal_and_is_not_scored() {
522 assert_eq!(
525 outcome_utility(
526 &outcome(OutcomeStatus::Done, &[EvidenceKind::ToolResult]),
527 UtilityEvidence::Reported
528 ),
529 None
530 );
531 }
532
533 #[test]
534 fn grounded_evidence_rejects_a_success_backed_only_by_self_assessment() {
535 let self_only = outcome(OutcomeStatus::Success, &[EvidenceKind::SelfAssessment]);
536 assert_eq!(
537 outcome_utility(&self_only, UtilityEvidence::Reported),
538 Some(1.0)
539 );
540 assert_eq!(
541 outcome_utility(&self_only, UtilityEvidence::Grounded),
542 None,
543 "unchecked is not failed — it must not be scored 0.0 either"
544 );
545
546 let backed = outcome(
547 OutcomeStatus::Success,
548 &[
549 EvidenceKind::SelfAssessment,
550 EvidenceKind::ExternalVerification,
551 ],
552 );
553 assert_eq!(
554 outcome_utility(&backed, UtilityEvidence::Grounded),
555 Some(1.0)
556 );
557 }
558
559 #[test]
560 fn an_outcome_with_no_evidence_at_all_is_ungrounded() {
561 let bare = outcome(OutcomeStatus::Success, &[]);
562 assert_eq!(outcome_utility(&bare, UtilityEvidence::Reported), Some(1.0));
563 assert_eq!(outcome_utility(&bare, UtilityEvidence::Grounded), None);
564 }
565
566 #[test]
567 fn final_aggregation_takes_the_last_scored_stage() {
568 let ev = &[EvidenceKind::ToolResult];
569 let outputs = vec![
570 output_with("a", Some((10, 10)), OutcomeStatus::Failure, ev),
571 output_with("b", Some((10, 10)), OutcomeStatus::Success, ev),
572 ];
573 assert_eq!(
574 run_utility(
575 &outputs,
576 UtilityAggregation::Final,
577 UtilityEvidence::Reported
578 ),
579 Some(1.0)
580 );
581 }
582
583 #[test]
584 fn final_aggregation_skips_trailing_outputs_that_carry_no_signal() {
585 let ev = &[EvidenceKind::ToolResult];
586 let outputs = vec![
587 output_with("a", Some((10, 10)), OutcomeStatus::Success, ev),
588 output_with("done", Some((10, 10)), OutcomeStatus::Done, ev),
590 output("aggregate", Some((10, 10))),
591 ];
592 assert_eq!(
593 run_utility(
594 &outputs,
595 UtilityAggregation::Final,
596 UtilityEvidence::Reported
597 ),
598 Some(1.0)
599 );
600 }
601
602 #[test]
603 fn consensus_aggregation_grades_independent_answers() {
604 let ev = &[EvidenceKind::ToolResult];
605 let outputs = vec![
606 output_with("a", Some((10, 10)), OutcomeStatus::Success, ev),
607 output_with("b", Some((10, 10)), OutcomeStatus::Success, ev),
608 output_with("c", Some((10, 10)), OutcomeStatus::Failure, ev),
609 output_with("d", Some((10, 10)), OutcomeStatus::Failure, ev),
610 ];
611 assert_eq!(
612 run_utility(
613 &outputs,
614 UtilityAggregation::Consensus,
615 UtilityEvidence::Reported
616 ),
617 Some(0.5)
618 );
619 }
620
621 #[test]
622 fn a_run_with_no_structured_outcomes_yields_no_utility() {
623 let outputs = vec![output("a", Some((10, 10))), output("b", Some((10, 10)))];
624 for aggregation in [UtilityAggregation::Final, UtilityAggregation::Consensus] {
625 assert_eq!(
626 run_utility(&outputs, aggregation, UtilityEvidence::Reported),
627 None
628 );
629 }
630 }
631
632 #[test]
633 fn the_derived_path_refuses_to_fabricate_either_measured_number() {
634 let dir = tempfile::tempdir().unwrap();
635 let path = car_topology::journal_path(dir.path());
636 let mut journal = RecordJournal::open(&path).unwrap();
637 let ev = &[EvidenceKind::ToolResult];
638
639 let write = |journal: &mut RecordJournal, task: &str, outputs: &[AgentOutput]| {
640 record_run_from_outcomes(
641 journal,
642 "mini-lm",
643 task,
644 vec![0.5, 0.5],
645 CoordinationShape::Debate,
646 4,
647 outputs,
648 UtilityAggregation::Final,
649 UtilityEvidence::Grounded,
650 )
651 .unwrap()
652 };
653
654 assert!(write(
656 &mut journal,
657 "ok",
658 &[output_with(
659 "a",
660 Some((400, 100)),
661 OutcomeStatus::Success,
662 ev
663 )]
664 ));
665 assert!(!write(
667 &mut journal,
668 "unmetered",
669 &[output_with("a", None, OutcomeStatus::Success, ev)]
670 ));
671 assert!(!write(
673 &mut journal,
674 "ungrounded",
675 &[output_with(
676 "a",
677 Some((400, 100)),
678 OutcomeStatus::Success,
679 &[EvidenceKind::SelfAssessment]
680 )]
681 ));
682 assert!(!write(
684 &mut journal,
685 "neutral",
686 &[output_with("a", Some((400, 100)), OutcomeStatus::Done, ev)]
687 ));
688
689 drop(journal);
690 let entries = RecordJournal::load(&path).unwrap();
691 assert_eq!(entries.len(), 1, "only the fully measured run is recorded");
692 assert_eq!(entries[0].record.task_id, "ok");
693 assert_eq!(entries[0].record.utility, 1.0);
694 }
695
696 #[test]
697 fn record_run_writes_a_metered_run_and_skips_an_unmetered_one() {
698 let dir = tempfile::tempdir().unwrap();
699 let path = car_topology::journal_path(dir.path());
700 let mut journal = RecordJournal::open(&path).unwrap();
701
702 assert!(record_run(
703 &mut journal,
704 "mini-lm",
705 "t1",
706 vec![0.5, 0.5],
707 CoordinationShape::Debate,
708 4,
709 &[output("a", Some((400, 100)))],
710 1.0,
711 )
712 .unwrap());
713
714 assert!(!record_run(
715 &mut journal,
716 "mini-lm",
717 "t2",
718 vec![0.5, 0.5],
719 CoordinationShape::Pipeline,
720 4,
721 &[output("a", None)],
722 1.0,
723 )
724 .unwrap());
725
726 drop(journal);
727 let entries = RecordJournal::load(&path).unwrap();
728 assert_eq!(entries.len(), 1, "only the metered run is recorded");
729 assert_eq!(entries[0].record.tokens, 500);
730 assert_eq!(entries[0].embedder, "mini-lm");
731 }
732
733 #[test]
737 fn recorded_runs_fit_a_selector_that_returns_an_executable_shape() {
738 let team_size = 4;
739 let mut records = Vec::new();
740 for i in 0..6 {
741 let drift = i as f32 * 0.01;
742 for (family, query, cheap, dear) in [
744 (
745 "math",
746 vec![1.0, 0.0, drift],
747 CoordinationShape::Debate,
748 CoordinationShape::Pipeline,
749 ),
750 (
751 "code",
752 vec![0.0, 1.0, drift],
753 CoordinationShape::Pipeline,
754 CoordinationShape::Debate,
755 ),
756 ] {
757 let task = format!("{family}{i}");
758 records.push(
759 execution_record(
760 &task,
761 query.clone(),
762 cheap,
763 team_size,
764 &[output("a", Some((400, 200)))],
765 1.0,
766 )
767 .unwrap()
768 .unwrap(),
769 );
770 records.push(
771 execution_record(
772 &task,
773 query,
774 dear,
775 team_size,
776 &[output("a", Some((1600, 800)))],
777 1.0,
778 )
779 .unwrap()
780 .unwrap(),
781 );
782 }
783 }
784
785 let selector = TopologySelector::fit(
786 &RecordSet::new(records).unwrap(),
787 &SelectorConfig::default(),
788 )
789 .unwrap();
790
791 let (math, _) =
792 select_shape(&selector, &[1.0, 0.0, 0.0], CoordinationShape::Swarm).unwrap();
793 let (code, _) =
794 select_shape(&selector, &[0.0, 1.0, 0.0], CoordinationShape::Swarm).unwrap();
795 assert_eq!(math, CoordinationShape::Debate);
796 assert_eq!(code, CoordinationShape::Pipeline);
797 }
798}