car-memgine 0.48.0

Memgine — graph-based memory engine for Common Agent Runtime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
//! Conversation-outcome signal — the keystone of outcome-based routing.
//!
//! The conversation *is* the outcome label. After an assistant turn, what the
//! user does next reveals whether the result was actually right — no benchmark,
//! no thumbs-up, no explicit rating required:
//!
//! - User **moves on** (a new/expanded intent, or ends the session cleanly)
//!   → the result was RIGHT.
//! - User **keeps striving** (re-asks, corrects, repeats, shows frustration)
//!   → the result was WRONG.
//!
//! This is revealed behavior, not stated opinion, and it is the *actual*
//! objective rather than a proxy for it. Two traps a careless version of this
//! would fall into, both guarded here:
//!
//! 1. **Silence is ambiguous.** A satisfied user goes quiet — and so does a
//!    defeated one. A terminal silence *right after a repair attempt* is
//!    genuinely unknowable (the fix may have worked), so it is labeled
//!    [`TurnOutcome::AmbiguousExit`] at low confidence — never confident churn.
//!    Only an *explicit* abandonment earns [`TurnOutcome::FrustratedExit`].
//! 2. **Engagement is not success.** Continued interaction can be productive
//!    ([`TurnOutcome::Advance`]) or failure ([`TurnOutcome::Circle`]); the
//!    objective elsewhere is to *minimize* turns to a correct outcome.
//!
//! ## Inputs: typed turns vs the MemNode observer adapter
//!
//! The routing-grade entry point is [`classify_turns`], which takes
//! [`ConversationTurn`]s with **explicit roles and model attribution** — the
//! caller (the chat/inference layer) knows which turn the model produced and at
//! which `trace_id`, so credit can flow to the right model with no guessing.
//! This is what binds routing once the episode-credit model lands.
//!
//! [`classify_outcomes`] is a thin **observer adapter** over
//! [`MemKind::Conversation`] [`MemNode`]s: it infers roles from a
//! `"user:"`/`"assistant:"` prefix or `key`, carries no attribution, and drops
//! free-form-speaker turns (meeting transcripts, diarized audio, multi-agent
//! surfaces) which are out of scope — "what the user did next" is undefined when
//! there are N speakers and no single assistant. Observer-grade: safe to log and
//! aggregate, not for binding a decision.
//!
//! Heuristic-first (repair markers + lexical similarity). Embedding similarity
//! is the planned upgrade, but it must be paired with a refinement-vs-repeat
//! discriminator or it will mislabel productive same-topic follow-ups as
//! circling.

use crate::graph::{MemKind, MemNode};
use crate::reflection::turn_ref;

/// Who produced a conversation turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    User,
    Assistant,
}

/// A single conversation turn with explicit role and (for assistant turns)
/// model attribution. This is the routing-grade input: the chat layer builds
/// these directly from its message history + inference results, so the model
/// and trace that produced each assistant turn are known exactly.
#[derive(Debug, Clone)]
pub struct ConversationTurn {
    pub role: Role,
    /// Turn text with any role prefix already stripped.
    pub text: String,
    /// Stable reference for credit assignment (e.g. inference trace id, or a
    /// memgine turn ref). Echoed into [`AssistantTurnOutcome::turn_ref`].
    pub turn_ref: String,
    /// The model that produced this (assistant) turn, if known.
    pub model_id: Option<String>,
    /// The inference trace this (assistant) turn came from, if known — the key
    /// the `OutcomeTracker` already attributes outcomes against.
    pub trace_id: Option<String>,
}

impl ConversationTurn {
    /// A user turn (no attribution).
    pub fn user(text: impl Into<String>, turn_ref: impl Into<String>) -> Self {
        Self {
            role: Role::User,
            text: text.into(),
            turn_ref: turn_ref.into(),
            model_id: None,
            trace_id: None,
        }
    }

    /// An assistant turn attributed to the model/trace that produced it.
    pub fn assistant(
        text: impl Into<String>,
        turn_ref: impl Into<String>,
        model_id: Option<String>,
        trace_id: Option<String>,
    ) -> Self {
        Self {
            role: Role::Assistant,
            text: text.into(),
            turn_ref: turn_ref.into(),
            model_id,
            trace_id,
        }
    }
}

/// What the user's behavior after an assistant turn reveals about that turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnOutcome {
    /// User moved forward with a new or expanded intent — right, and built on.
    Advance,
    /// User re-attacked the *same* intent (repair / retry / rephrase) — wrong.
    Circle,
    /// User got their result and ended the session — right, cleanly.
    CleanExit,
    /// Session ended right after a repair attempt — outcome unknowable from
    /// silence alone. Low-confidence, non-success, barely moves an EMA.
    AmbiguousExit,
    /// User *explicitly* abandoned — confident churn; the worst outcome.
    FrustratedExit,
}

impl TurnOutcome {
    /// True when the user actually got their result.
    pub fn is_success(self) -> bool {
        matches!(self, TurnOutcome::Advance | TurnOutcome::CleanExit)
    }
}

/// The behavioral outcome of one assistant turn, with evidence strength and the
/// model attribution carried through for credit assignment.
#[derive(Debug, Clone)]
pub struct AssistantTurnOutcome {
    pub turn_ref: String,
    pub outcome: TurnOutcome,
    /// 0.0–1.0 ordinal evidence weight (for EMA aggregation), NOT a calibrated
    /// probability. Terminal/ambiguous cases score low.
    pub confidence: f64,
    /// The model that produced the judged turn (`None` on the observer path).
    pub model_id: Option<String>,
    /// The inference trace of the judged turn (`None` on the observer path).
    pub trace_id: Option<String>,
}

// --- Confidence weights (ordinal; auditable in one place) ---
const CONF_MARKER_CIRCLE: f64 = 0.85; // explicit "wrong/again/didn't work"
const CONF_ABANDON_EXIT: f64 = 0.80; // explicit "forget it / useless"
const CONF_SIM_CIRCLE: f64 = 0.60; // restated same request (lexical)
const CONF_ADVANCE: f64 = 0.60; // moved on (default success branch)
const CONF_CLEAN_EXIT: f64 = 0.55; // smooth ending
const CONF_AMBIGUOUS_EXIT: f64 = 0.45; // repair-then-silence (unknown)
const CONF_NO_EVIDENCE: f64 = 0.40; // terminal turn, nothing to judge on

/// Multi-word / punctuated repair phrases — substring matching is safe because
/// they're specific. (Separate detector from `reflection`'s CORRECTION/FRICTION
/// markers: this answers "is the user still striving?", reflection answers
/// "what should we learn?".)
const REPAIR_PHRASES: &[&str] = &[
    "no, ",
    "no not",
    "not what i",
    "that's wrong",
    "thats wrong",
    "that's not right",
    "that's not what",
    "wrong approach",
    "actually,",
    "instead,",
    "i said",
    "i meant",
    "i already",
    "like i said",
    "as i mentioned",
    "still not",
    "still doesn't",
    "still failing",
    "doesn't work",
    "didn't work",
    "not working",
    "does not work",
    "try again",
    "that failed",
    "you didn't",
    "you missed",
];

/// Single-word repair markers — matched on WORD BOUNDARIES, never as substrings
/// (so "again" does not fire inside "against", "undo" not inside "undocumented").
const REPAIR_WORDS: &[&str] = &["again", "undo", "revert", "incorrect"];

/// Explicit abandonment — the only signal that earns a *confident* churn label
/// from a terminal silence.
const ABANDON_PHRASES: &[&str] = &[
    "forget it",
    "forget this",
    "never mind",
    "nevermind",
    "give up",
    "giving up",
    "this is useless",
    "useless",
    "not helpful",
    "waste of time",
    "waste of my time",
];

/// Minimum token overlap (Jaccard) for two user turns to count as "the same
/// request restated" — corroborating evidence of circling when no marker fires.
const SIMILARITY_THRESHOLD: f64 = 0.6;

/// Routing-grade entry point: classify every assistant turn from explicitly
/// typed, attributed turns. Roles are trusted (not inferred); attribution is
/// carried through to each [`AssistantTurnOutcome`] for credit assignment.
pub fn classify_turns(turns: &[ConversationTurn]) -> Vec<AssistantTurnOutcome> {
    let mut out = Vec::new();
    for i in 0..turns.len() {
        if turns[i].role != Role::Assistant {
            continue;
        }
        let prev_user = turns[..i]
            .iter()
            .rev()
            .find(|t| t.role == Role::User)
            .map(|t| t.text.as_str());
        let next_user = turns[i + 1..]
            .iter()
            .find(|t| t.role == Role::User)
            .map(|t| t.text.as_str());

        let (outcome, confidence) = match next_user {
            Some(next) => classify_followup(prev_user, next),
            None => classify_exit(prev_user),
        };
        out.push(AssistantTurnOutcome {
            turn_ref: turns[i].turn_ref.clone(),
            outcome,
            confidence,
            model_id: turns[i].model_id.clone(),
            trace_id: turns[i].trace_id.clone(),
        });
    }
    out
}

/// Observer adapter over memgine conversation nodes. Infers roles, carries no
/// attribution, drops free-form speakers (see module docs). Observer-grade.
pub fn classify_outcomes(nodes: &[&MemNode]) -> Vec<AssistantTurnOutcome> {
    let turns: Vec<ConversationTurn> = nodes
        .iter()
        .filter(|n| n.kind == MemKind::Conversation)
        .filter_map(|n| {
            role_of(n).map(|role| ConversationTurn {
                role,
                text: content(n).to_string(),
                turn_ref: turn_ref(n),
                model_id: None,
                trace_id: None,
            })
        })
        .collect();

    if turns.is_empty() && !nodes.is_empty() {
        tracing::debug!(
            nodes = nodes.len(),
            "outcome_signal: no user/assistant-labeled turns; free-form/multi-speaker \
             conversations are out of scope (need model attribution)"
        );
    }
    classify_turns(&turns)
}

/// The user said something after this assistant turn: did they abandon, circle
/// (still striving for the same outcome), or advance (move on)?
fn classify_followup(prev_user: Option<&str>, next_user: &str) -> (TurnOutcome, f64) {
    let lower = next_user.to_lowercase();
    // Explicit abandonment as the very next thing the user says — they gave up
    // on THIS turn. Credit the failure here (the turn they abandoned); more
    // precise than waiting for a trailing assistant reply (which often doesn't
    // come, since the user left).
    if ABANDON_PHRASES.iter().any(|m| lower.contains(m)) {
        return (TurnOutcome::FrustratedExit, CONF_ABANDON_EXIT);
    }
    if has_repair(next_user) {
        return (TurnOutcome::Circle, CONF_MARKER_CIRCLE);
    }
    if let Some(prev) = prev_user {
        if jaccard(prev, next_user) >= SIMILARITY_THRESHOLD {
            return (TurnOutcome::Circle, CONF_SIM_CIRCLE);
        }
    }
    (TurnOutcome::Advance, CONF_ADVANCE)
}

/// Terminal assistant turn (user went silent). Only commit to a confident label
/// on explicit evidence; repair-then-silence is ambiguous, not churn.
fn classify_exit(prev_user: Option<&str>) -> (TurnOutcome, f64) {
    let Some(prev) = prev_user else {
        return (TurnOutcome::CleanExit, CONF_NO_EVIDENCE);
    };
    let lower = prev.to_lowercase();
    if ABANDON_PHRASES.iter().any(|m| lower.contains(m)) {
        return (TurnOutcome::FrustratedExit, CONF_ABANDON_EXIT);
    }
    if has_repair(prev) {
        return (TurnOutcome::AmbiguousExit, CONF_AMBIGUOUS_EXIT);
    }
    (TurnOutcome::CleanExit, CONF_CLEAN_EXIT)
}

/// True if the text shows the user is still striving (repair phrase OR a
/// word-boundary single-word marker).
fn has_repair(text: &str) -> bool {
    let lower = text.to_lowercase();
    if REPAIR_PHRASES.iter().any(|m| lower.contains(m)) {
        return true;
    }
    lower
        .split(|c: char| !c.is_alphanumeric())
        .filter(|w| !w.is_empty())
        .any(|w| REPAIR_WORDS.contains(&w))
}

/// Infer the speaker of a conversation node from its text prefix or `key`.
/// Returns `None` for free-form speakers (out of scope; see module docs).
fn role_of(node: &MemNode) -> Option<Role> {
    let lower = node.value.to_lowercase();
    if lower.starts_with("user:") || node.key == "user" {
        Some(Role::User)
    } else if lower.starts_with("assistant:") || node.key == "assistant" {
        Some(Role::Assistant)
    } else {
        None
    }
}

/// Borrow node text with any leading `"user:"`/`"assistant:"` prefix stripped
/// (case-insensitive, matching [`role_of`]). No allocation.
fn content(node: &MemNode) -> &str {
    let v = node.value.trim();
    for prefix in ["user:", "assistant:"] {
        if v.len() >= prefix.len() && v[..prefix.len()].eq_ignore_ascii_case(prefix) {
            return v[prefix.len()..].trim_start();
        }
    }
    v
}

/// Token-set Jaccard over alphanumeric words >2 chars. 0.0 when either side is
/// too short to be a meaningful restatement. Coverage: only verbose restatements
/// fire here; short re-asks ("fix it again") are caught by markers.
fn jaccard(a: &str, b: &str) -> f64 {
    use std::collections::HashSet;
    let toks = |s: &str| -> HashSet<String> {
        s.to_lowercase()
            .split(|c: char| !c.is_alphanumeric())
            .filter(|w| w.len() > 2)
            .map(|w| w.to_string())
            .collect()
    };
    let sa = toks(a);
    let sb = toks(b);
    if sa.len() < 3 || sb.len() < 3 {
        return 0.0;
    }
    let inter = sa.intersection(&sb).count() as f64;
    let union = sa.union(&sb).count() as f64;
    if union == 0.0 {
        0.0
    } else {
        inter / union
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{ContentType, FactMetadata, MemKind, MemNode};
    use chrono::Utc;

    fn node(key: &str, value: &str) -> MemNode {
        MemNode {
            kind: MemKind::Conversation,
            layer: 3,
            key: key.to_string(),
            value: value.to_string(),
            fact_id: None,
            scope: "global".to_string(),
            authority: "observed".to_string(),
            is_constraint: false,
            created_at: Utc::now(),
            expires_at: None,
            content_type: ContentType::NaturalLanguage,
            metadata: FactMetadata::default(),
        }
    }

    fn turn(role_value: &str) -> MemNode {
        let key = if role_value.to_lowercase().starts_with("user:") {
            "user"
        } else {
            "assistant"
        };
        node(key, role_value)
    }

    fn classify(nodes: &[MemNode]) -> Vec<AssistantTurnOutcome> {
        let refs: Vec<&MemNode> = nodes.iter().collect();
        classify_outcomes(&refs)
    }

    // --- typed, attributed path (Task 2: classify_turns) ---

    #[test]
    fn typed_path_carries_model_attribution() {
        let turns = vec![
            ConversationTurn::user("convert this to async", "t1"),
            ConversationTurn::assistant(
                "here's a threaded version",
                "t2",
                Some("gpt-5.4".into()),
                Some("trace-abc".into()),
            ),
            ConversationTurn::user("no, that's not what i asked", "t3"),
            ConversationTurn::assistant(
                "async version",
                "t4",
                Some("claude-sonnet-4-6".into()),
                Some("trace-def".into()),
            ),
        ];
        let o = classify_turns(&turns);
        assert_eq!(o[0].outcome, TurnOutcome::Circle);
        // Credit is attributed to the model that produced the circled-on turn.
        assert_eq!(o[0].model_id.as_deref(), Some("gpt-5.4"));
        assert_eq!(o[0].trace_id.as_deref(), Some("trace-abc"));
    }

    #[test]
    fn typed_abandonment_credits_the_failed_turn() {
        // Abandonment as the final user message (no trailing assistant turn):
        // credit the turn they gave up on, not Advance.
        let turns = vec![
            ConversationTurn::user("fix the deploy", "t1"),
            ConversationTurn::assistant(
                "<wrong fix>",
                "t2",
                Some("gpt-5.4".into()),
                Some("trace-x".into()),
            ),
            ConversationTurn::user("forget it, this is useless", "t3"),
        ];
        let o = classify_turns(&turns);
        assert_eq!(o.len(), 1);
        assert_eq!(o[0].outcome, TurnOutcome::FrustratedExit);
        assert!(!o[0].outcome.is_success());
        assert_eq!(o[0].model_id.as_deref(), Some("gpt-5.4"));
        assert_eq!(o[0].trace_id.as_deref(), Some("trace-x"));
    }

    #[test]
    fn typed_scan_skips_intervening_assistant_turns() {
        // user, assistant(tool), assistant(answer), user(circle): both assistant
        // turns are judged, each carrying its own attribution. (Tool-burst
        // over-attribution — both get Circle — is a Task 8 episode-credit concern.)
        let turns = vec![
            ConversationTurn::user("what's the weather in Tokyo", "t1"),
            ConversationTurn::assistant(
                "<calls weather tool>",
                "t2",
                Some("m1".into()),
                Some("tr2".into()),
            ),
            ConversationTurn::assistant(
                "it's sunny in Toronto",
                "t3",
                Some("m2".into()),
                Some("tr3".into()),
            ),
            ConversationTurn::user("no, i said Tokyo not Toronto", "t4"),
        ];
        let o = classify_turns(&turns);
        assert_eq!(o.len(), 2);
        assert_eq!(o[0].outcome, TurnOutcome::Circle);
        assert_eq!(o[0].trace_id.as_deref(), Some("tr2"));
        assert_eq!(o[1].outcome, TurnOutcome::Circle);
        assert_eq!(o[1].trace_id.as_deref(), Some("tr3"));
    }

    #[test]
    fn typed_path_trusts_explicit_roles_no_sniffing() {
        // Free-form-looking text but explicit roles → still classified.
        let turns = vec![
            ConversationTurn::user("ship it friday?", "t1"),
            ConversationTurn::assistant("done, scheduled", "t2", Some("m".into()), None),
        ];
        let o = classify_turns(&turns);
        assert_eq!(o.len(), 1);
        assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
        assert_eq!(o[0].model_id.as_deref(), Some("m"));
    }

    // --- observer adapter path (MemNode) ---

    #[test]
    fn advance_when_user_moves_to_new_intent() {
        let nodes = [
            turn("user: what's the capital of France?"),
            turn("assistant: Paris."),
            turn("user: great, now book me a flight to Tokyo next week"),
            turn("assistant: ...booked..."),
        ];
        let o = classify(&nodes);
        assert_eq!(o[0].outcome, TurnOutcome::Advance);
        assert!(o[0].outcome.is_success());
        assert!(
            o[0].model_id.is_none(),
            "observer path carries no attribution"
        );
    }

    #[test]
    fn circle_on_explicit_repair_marker() {
        let nodes = [
            turn("user: convert this to async"),
            turn("assistant: here is a threaded version"),
            turn("user: no, that's not what i asked, i said async"),
            turn("assistant: here is the async version"),
        ];
        let o = classify(&nodes);
        assert_eq!(o[0].outcome, TurnOutcome::Circle);
        assert!(o[0].confidence >= 0.8);
    }

    #[test]
    fn against_is_not_a_repair_marker() {
        let nodes = [
            turn("user: review my plan"),
            turn("assistant: here is feedback"),
            turn("user: I'd advise against rushing, but this looks great, ship it"),
            turn("assistant: shipping"),
        ];
        let o = classify(&nodes);
        assert_eq!(
            o[0].outcome,
            TurnOutcome::Advance,
            "'against' must not read as 'again'"
        );
    }

    #[test]
    fn circle_on_restated_request_without_marker() {
        let nodes = [
            turn("user: summarize the quarterly revenue report for me please"),
            turn("assistant: <unrelated answer about weather>"),
            turn("user: please summarize the quarterly revenue report"),
            turn("assistant: <summary>"),
        ];
        assert_eq!(classify(&nodes)[0].outcome, TurnOutcome::Circle);
    }

    #[test]
    fn clean_exit_when_smooth_session_ends() {
        let nodes = [turn("user: what's 2+2?"), turn("assistant: 4")];
        let o = classify(&nodes);
        assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
        assert!(o[0].outcome.is_success());
    }

    #[test]
    fn repair_then_silence_is_ambiguous_not_confident_churn() {
        let nodes = [
            turn("user: fix the failing test"),
            turn("assistant: <attempt 1>"),
            turn("user: that didn't work, still failing"),
            turn("assistant: <attempt 2 — may have fixed it>"),
        ];
        let last = classify(&nodes).pop().unwrap();
        assert_eq!(last.outcome, TurnOutcome::AmbiguousExit);
        assert!(!last.outcome.is_success());
        assert!(last.confidence < CONF_MARKER_CIRCLE);
    }

    #[test]
    fn frustrated_exit_only_on_explicit_abandonment() {
        let nodes = [
            turn("user: fix it"),
            turn("assistant: <wrong>"),
            turn("user: forget it, this is useless"),
            turn("assistant: sorry to hear that"),
        ];
        let last = classify(&nodes).pop().unwrap();
        assert_eq!(last.outcome, TurnOutcome::FrustratedExit);
        assert!(!last.outcome.is_success());
    }

    #[test]
    fn key_only_role_without_prefix_is_recognized() {
        let nodes = [node("user", "what time is it"), node("assistant", "3pm")];
        let o = classify(&nodes);
        assert_eq!(o.len(), 1);
        assert_eq!(o[0].outcome, TurnOutcome::CleanExit);
    }

    #[test]
    fn free_form_speakers_are_dropped_by_observer_adapter() {
        let nodes = [
            node("alice", "alice: shall we ship friday?"),
            node("bob", "bob: yes, let's do it"),
            node("ui-agent/chat", "ui-agent/chat: noted"),
        ];
        assert!(classify(&nodes).is_empty());
    }

    #[test]
    fn empty_and_single_user_turn_are_safe() {
        assert!(classify(&[]).is_empty());
        assert!(classify(&[turn("user: hello?")]).is_empty());
    }

    #[test]
    fn assistant_first_with_no_prior_user_advances() {
        let nodes = [
            turn("assistant: welcome! how can I help?"),
            turn("user: what's the weather"),
            turn("assistant: sunny"),
        ];
        assert_eq!(classify(&nodes)[0].outcome, TurnOutcome::Advance);
    }

    #[test]
    fn only_assistant_turns_are_judged() {
        let nodes = [
            turn("user: a"),
            turn("assistant: b"),
            turn("user: c"),
            turn("assistant: d"),
        ];
        assert_eq!(classify(&nodes).len(), 2);
    }
}