Skip to main content

car_multi/
topology.rs

1//! Turning coordination runs into topology-selection records.
2//!
3//! [`car_topology`] fits a selector on `(topology, query, utility, tokens)`
4//! tuples. `car-multi` is where those tuples come from: every pattern already
5//! returns `AgentOutput`s carrying [`TokenAccounting`](crate::types::TokenAccounting),
6//! so the measured cost of a coordination run is already in hand — it just was
7//! not being kept.
8//!
9//! This module is the bridge in both directions:
10//!
11//! - [`execution_record`] folds a finished run into one
12//!   [`car_topology::ExecutionRecord`].
13//! - [`team_homogeneity`] answers, for a real `Vec<AgentSpec>`, the question
14//!   the paper shows decides whether a profile-node message-passing scorer can
15//!   rank anything at all.
16//!
17//! Nothing here calls a model. Collecting records costs LLM calls because
18//! *running the coordination* does; the bookkeeping is free.
19
20use 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
29/// Total measured tokens across a run's outputs, or `None` when no output
30/// reported any accounting.
31///
32/// `None` rather than `0` on purpose. [`TokenAccounting`](crate::types::TokenAccounting)
33/// is opt-in — a runner that does not populate it is not reporting a zero-cost
34/// run, it is reporting nothing — and a record claiming zero tokens would drag
35/// the proxy's cost head toward a topology that merely went unmetered.
36pub 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
50/// Fold a finished coordination run into one execution record.
51///
52/// `team_size` is the number of agents the *topology* is over, which is not
53/// always `outputs.len()`: a swarm's optional synthesizer and a supervisor's
54/// reviewer rounds add outputs without adding nodes. Pass the length of the
55/// `AgentSpec` list the pattern was built from.
56///
57/// `utility` is the caller's — CAR does not know whether a coordination run
58/// answered the question. Use 1.0/0.0 for a pass/fail check, or a graded score
59/// where one exists.
60///
61/// Returns `None` when the run reported no token accounting, so an unmetered
62/// run is skipped rather than logged as free.
63pub 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    // `Solo` is refused rather than recorded. It folds to the empty adjacency,
72    // which is byte-identical to `Swarm`, so a one-agent run logged with
73    // `team_size = 4` becomes a *Swarm* record carrying one agent's token cost.
74    // Nothing downstream can undo that: `shape_of` cannot recover `Solo` from
75    // the matrix, so `select_shape` hands the caller `Swarm`, who then runs
76    // four agents against a price learned from one. The paper's model has no
77    // room for it either — a solo run is a different `N`, not a different
78    // adjacency over the same `N`.
79    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
96/// The same fold for a run whose topology is not one of the named shapes —
97/// a topology a selector chose, for instance.
98pub 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/// How much of a success claim has to be demonstrated before it counts.
112///
113/// CAR already draws this line elsewhere — the flagship assistant cross-checks
114/// a final summary's operational claims against same-run tool receipts rather
115/// than believing the prose. The same reasoning applies here, and harder: a
116/// codebook fitted on runs that merely *said* they succeeded is a codebook of
117/// whatever the model is most confident about.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum UtilityEvidence {
120    /// Take [`OutcomeStatus`] at face value, whatever backs it.
121    Reported,
122    /// Require at least one evidence item that is not the agent's own
123    /// assessment — a tool result, a state change, an external verification, or
124    /// a product evaluator. A `Success` backed only by
125    /// [`EvidenceKind::SelfAssessment`] yields `None`, not `0.0`: "nobody
126    /// checked" is not "it failed", and recording it either way is a fabricated
127    /// training label.
128    Grounded,
129}
130
131/// How to reduce a multi-agent run's outcomes to one utility.
132///
133/// There is no universal rule, so this is the caller's to state rather than
134/// something to infer from the outputs.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum UtilityAggregation {
137    /// The last output carrying an outcome is the run's answer.
138    ///
139    /// Right for the shapes where one agent's output *is* the result —
140    /// [`CoordinationShape::Pipeline`] (the last stage is what the caller sees)
141    /// and [`CoordinationShape::Supervisor`] (the reviewer has the last word).
142    Final,
143    /// The fraction of outcome-carrying outputs that succeeded.
144    ///
145    /// Right for the shapes where agents answer independently and the answer is
146    /// a vote or a pick — [`CoordinationShape::Swarm`] and
147    /// [`CoordinationShape::Debate`]. Yields the graded utility in `[0, 1]` that
148    /// [`ExecutionRecord`] already supports, rather than forcing a binary.
149    Consensus,
150}
151
152/// Map one agent outcome to a utility in `[0, 1]`, or `None` when it carries no
153/// success signal.
154///
155/// The mapping is dictated by the type rather than chosen:
156///
157/// | [`OutcomeStatus`] | utility |
158/// |---|---|
159/// | `Success` | `1.0` |
160/// | `PartialSuccess` | `0.5` |
161/// | `Failure`, `GiveUp`, `Timeout` | `0.0` |
162/// | `Done` | `None` |
163///
164/// `Done` is `None` because `car-ir` documents it as *"neutral — may or may not
165/// have succeeded"*. Scoring a deliberately neutral status as either outcome
166/// would invent the one number this crate exists to measure honestly.
167pub 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
184/// Derive a run's utility from its agents' structured outcomes.
185///
186/// Returns `None` when no output carries a usable outcome — a runner that
187/// reports nothing structured is not reporting success, and the caller should
188/// skip the record rather than label it. That is the same discipline
189/// [`measured_tokens`] applies to cost, for the same reason: the paper's whole
190/// argument is that these two numbers must be *measured*, and a default is not
191/// a measurement.
192///
193/// **This is only as good as the runner.** `AgentOutput::outcome` is populated
194/// by the caller's [`AgentRunner`](crate::AgentRunner), not by this crate: every
195/// aggregate output the coordination patterns build themselves (a swarm's
196/// synthesizer, a map-reduce reducer, a vote's tally) sets `outcome: None` by
197/// construction, because the runtime does not own the model and cannot classify
198/// what it did not run. A team whose runner never fills the field will record
199/// nothing through this path, which is the intended failure — fail closed, not
200/// fabricate.
201pub 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
220/// Record a finished coordination run, deriving BOTH measured numbers from the
221/// run itself.
222///
223/// The ergonomic path deliberately cannot fabricate either one: it writes
224/// nothing and returns `false` when the runner metered no tokens *or* when no
225/// agent reported a usable outcome. Defaulting utility to 1.0 — the obvious
226/// shortcut — would make the codebook the set of everything ever run, which is
227/// exactly the short list the paper says does not exist.
228///
229/// Use [`record_run`] instead when the caller has a better utility signal than
230/// the agents' self-reported outcomes: a `car-verify` goal verdict, a product
231/// evaluator, or a graded score.
232pub 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
251/// Record a finished coordination run to a [`RecordJournal`].
252///
253/// The one-call form of [`execution_record`] + [`RecordJournal::append`], which
254/// is what a daemon call site wants: run the coordination, decide the utility,
255/// hand the run here.
256///
257/// `embedder` names the encoder that produced `query`. It is not optional
258/// because the journal's whole guard is that a record can never be folded into
259/// a set from a different embedding space — see [`car_topology::journal`].
260///
261/// Returns `false` when the run reported no token accounting and so was not
262/// recorded. That is the honest outcome, not a failure: an unmetered run has no
263/// measured cost, and this crate's whole premise is scoring on measured cost.
264pub 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
281/// Whether a team's agents are distinguishable, judged on the specs themselves.
282///
283/// The paper measures homogeneity over profile *embeddings*; for CAR the
284/// question is answerable without an encoder, because a team is usually built
285/// by cloning one `AgentSpec` N times. Two agents count as distinguishable when
286/// their system prompts differ — the prompt is the profile.
287///
288/// Names are deliberately ignored: `solver_1` … `solver_4` around one identical
289/// prompt is the homogeneous case, and letting the name split them would give
290/// exactly the false negative this check exists to prevent.
291pub 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
306/// What a profile-node message-passing scorer could do on this team, phrased
307/// for a log line next to a selection.
308pub 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
330/// Select a coordination shape for a query, falling back to `default_shape`
331/// when the selected topology matches no named pattern.
332///
333/// A learned codebook can hold topologies with no `car-multi` pattern behind
334/// them — that is a real outcome of fitting on records, not a bug — and this
335/// function is for the caller who can only execute the named patterns. Callers
336/// that can execute an arbitrary adjacency should use
337/// [`TopologySelector::select`] directly and read [`Selection::topology`].
338pub 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    // Prefer the winner's own shape; otherwise the best-scoring candidate that
345    // has one; otherwise the caller's default.
346    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        // Solo and Swarm share the empty adjacency, so recording a 1-agent run
419        // at team_size 4 would mint a Swarm record priced for one agent — and
420        // `shape_of` could never recover the Solo, so a later `select_shape`
421        // would hand back Swarm and run four.
422        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        // Swarm itself still records fine.
428        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        // car-ir documents Done as "neutral -- may or may not have succeeded".
523        // Scoring it either way would invent the number this crate measures.
524        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            // A neutral Done and an outcome-less aggregate must not displace it.
589            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        // Metered and grounded -> recorded.
655        assert!(write(
656            &mut journal,
657            "ok",
658            &[output_with(
659                "a",
660                Some((400, 100)),
661                OutcomeStatus::Success,
662                ev
663            )]
664        ));
665        // Grounded but unmetered -> no measured cost, so no record.
666        assert!(!write(
667            &mut journal,
668            "unmetered",
669            &[output_with("a", None, OutcomeStatus::Success, ev)]
670        ));
671        // Metered but only self-assessed -> no measured utility, so no record.
672        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        // Metered but neutral -> likewise.
683        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    /// End to end through the public surface: record a batch of runs, fit a
734    /// selector on them, and get a shape back that a `car-multi` caller can
735    /// execute.
736    #[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            // Debate is cheap on "math"-flavoured queries, dear on "code" ones.
743            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}