1use serde::Deserialize;
11use serde_json::Value;
12use switchyard_protocol::{Category, ContentBlock, InstructionBlock, Message, Role};
13
14use super::classifier_contract::{ClassifierContract, ClassifierContractConfig, validate_prompt};
15use super::llm_judge::{
16 ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
17 StructuredJudge,
18};
19use crate::core::algorithm::Driver;
20use crate::core::classifier::{Classification, Score};
21use crate::core::state::State;
22use crate::{LibsyError, Result};
23use switchyard_protocol::Request;
24
25const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
26const DEESCALATION_PROMPT: &str = include_str!("../../prompts/escalation/deescalation.md");
27const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");
28
29const TRIM_MARKER: &str = " ...[trimmed] ";
31
32const TRUNCATION_SUFFIX: &str = "...<truncated>";
34
35const SYSTEM_CHARS: usize = 1_000;
38
39const TASK_CHARS: usize = 4_000;
45
46const MAX_REQUEST_CHARS: usize = 18_000;
48
49#[derive(Clone, Copy, Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct DeescalationConfig {
53 pub strong_min_calls: u32,
55 #[serde(default)]
57 pub strong_max_calls: Option<u32>,
58 pub confirmations: u32,
60 #[serde(default)]
62 pub weak_cooldown_calls: u32,
63}
64
65impl DeescalationConfig {
66 fn validate(&self) -> Result<()> {
67 let reject = |message: &str| {
68 Err(LibsyError::AlgorithmError {
69 message: message.to_string(),
70 })
71 };
72 if self.strong_min_calls == 0 {
73 return reject("deescalation.strong_min_calls must be at least 1");
74 }
75 if self.confirmations == 0 {
76 return reject("deescalation.confirmations must be at least 1");
77 }
78 if self
79 .strong_max_calls
80 .is_some_and(|strong_max_calls| strong_max_calls < self.strong_min_calls)
81 {
82 return reject(
83 "deescalation.strong_max_calls must be at least deescalation.strong_min_calls",
84 );
85 }
86 Ok(())
87 }
88}
89
90#[derive(Clone, Debug, Deserialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct EscalationJudgeConfig {
97 pub confirmations: u32,
102 pub recent_turn_window: usize,
104 pub window_message_chars: usize,
106 pub deescalation: Option<DeescalationConfig>,
108}
109
110impl EscalationJudgeConfig {
111 fn validate(&self) -> Result<()> {
113 let reject = |message: String| Err(LibsyError::AlgorithmError { message });
114 if self.confirmations == 0 {
115 return reject("confirmations must be at least 1".to_string());
116 }
117 if self.recent_turn_window == 0 {
118 return reject("recent_turn_window must be at least 1".to_string());
119 }
120 if self.window_message_chars < 50 {
121 return reject(format!(
122 "window_message_chars must be at least 50, got {}",
123 self.window_message_chars
124 ));
125 }
126 if let Some(deescalation) = self.deescalation {
127 deescalation.validate()?;
128 }
129 Ok(())
130 }
131}
132
133impl Default for EscalationJudgeConfig {
134 fn default() -> Self {
135 Self {
136 confirmations: 2,
137 recent_turn_window: 28,
138 window_message_chars: 500,
139 deescalation: None,
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub(crate) enum EvaluationPhase {
147 Efficient,
148 Strong,
149}
150
151impl EvaluationPhase {
152 fn marker(self) -> &'static str {
153 match self {
154 Self::Efficient => "EFFICIENT_EVALUATION",
155 Self::Strong => "STRONG_EVALUATION",
156 }
157 }
158}
159
160#[derive(Deserialize)]
165pub(crate) struct EscalationVerdict {
166 escalate: bool,
167 #[serde(default)]
168 reason: String,
169}
170
171pub(crate) struct EscalationInput {
173 config: EscalationJudgeConfig,
174 phase: Option<EvaluationPhase>,
175}
176
177impl ClassifierInput for EscalationInput {
178 fn build_messages(&self, _state: &State, request: &Request) -> Vec<Message> {
179 let summary = summarize_for_judge(
180 &request.llm_request.instructions,
181 &request.llm_request.messages,
182 conversation_turn(request),
183 self.phase,
184 &self.config,
185 );
186 vec![Message::text(Role::User, summary)]
187 }
188}
189
190pub(crate) type EscalationJudge = StructuredJudge<EscalationInput, SerdeDecoder<EscalationVerdict>>;
192
193pub(crate) struct EscalationPolicy {
199 phase: Option<EvaluationPhase>,
200}
201
202impl JudgePolicy for EscalationPolicy {
203 type Verdict = EscalationVerdict;
204
205 fn to_classification(
206 &self,
207 verdict: Option<&EscalationVerdict>,
208 driver: &Driver,
209 ) -> Result<Classification> {
210 if let Some(verdict) = verdict {
211 tracing::debug!(
212 escalate = verdict.escalate,
213 reason = %verdict.reason,
214 "escalation judge verdict"
215 );
216 }
217 match verdict {
218 Some(verdict) if verdict.escalate => Ok(Classification::Scores(vec![Score {
219 target: driver.first_model_for(&Category::Capable)?.clone(),
220 confidence: 1.0,
221 category: Some(Category::Capable),
222 }])),
223 Some(_) => Ok(Classification::Scores(vec![Score {
224 target: driver.first_model_for(&Category::Efficient)?.clone(),
225 confidence: 1.0,
226 category: Some(Category::Efficient),
227 }])),
228 None => Ok(Classification::Ambiguous(Vec::new())),
229 }
230 }
231}
232
233fn escalation_evidence(
235 policy: &EscalationPolicy,
236 verdict: Option<&EscalationVerdict>,
237) -> Option<Value> {
238 verdict.map(|verdict| {
239 let verdict = match (policy.phase, verdict.escalate) {
240 (Some(EvaluationPhase::Strong), true) => "retain",
241 (Some(EvaluationPhase::Strong), false) => "deescalate",
242 (_, true) => "escalate",
243 (_, false) => "continue",
244 };
245 serde_json::json!({
246 "source": "escalation",
247 "verdict": verdict,
248 })
249 })
250}
251
252pub(crate) fn build_judge(
257 contract_config: &ClassifierContractConfig,
258 config: EscalationJudgeConfig,
259 phase: Option<EvaluationPhase>,
260 max_output_tokens: u64,
261) -> Result<JudgeClassifier<EscalationJudge, EscalationPolicy>> {
262 config.validate()?;
263 let contract = build_contract(contract_config, phase.is_some())?;
264 Ok(JudgeClassifier::new(
265 StructuredJudge::new(
266 EscalationInput { config, phase },
267 contract,
268 SerdeDecoder::new(),
269 JudgeRuntimeConfig::new(max_output_tokens)?,
270 ),
271 EscalationPolicy { phase },
272 )
273 .with_evidence(escalation_evidence))
274}
275
276fn build_contract(
277 contract_config: &ClassifierContractConfig,
278 phase_aware: bool,
279) -> Result<ClassifierContract> {
280 let prompt = contract_config.prompt().unwrap_or(PROMPT_TEMPLATE);
281 validate_prompt(prompt)?;
282 let phase_aware_config = phase_aware.then(|| {
283 contract_config.clone().with_prompt(format!(
284 "{}\n\n{}",
285 prompt.trim_end(),
286 DEESCALATION_PROMPT.trim()
287 ))
288 });
289 let contract_config = phase_aware_config.as_ref().unwrap_or(contract_config);
290 ClassifierContract::from_config(contract_config, PROMPT_TEMPLATE, SCHEMA_TEMPLATE)
291}
292
293pub(crate) fn conversation_turn(request: &Request) -> usize {
302 request
303 .llm_request
304 .messages
305 .iter()
306 .filter(|message| message.role == Role::Assistant)
307 .count()
308}
309
310fn message_text(message: &Message) -> String {
316 let mut parts = Vec::new();
317 collect_text(&message.content, &mut parts);
318 parts.join(" ")
319}
320
321fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
323 for block in content {
324 match block {
325 ContentBlock::Text { text } | ContentBlock::Refusal { text } => {
326 parts.push(text.clone());
327 }
328 ContentBlock::ToolCall(call) => {
329 parts.push(format!("tool_call {}({})", call.name, call.arguments));
330 }
331 ContentBlock::ToolResult(result) => collect_text(&result.content, parts),
332 _ => {}
333 }
334 }
335}
336
337fn truncate_middle(text: &str, limit: usize) -> String {
342 let chars: Vec<char> = text.chars().collect();
343 if chars.len() <= limit {
344 return text.to_string();
345 }
346 let keep = limit
347 .saturating_sub(TRIM_MARKER.chars().count())
348 .max(20)
349 .min(chars.len());
350 let head = keep * 2 / 3;
351 let tail = keep - head;
352 let mut out: String = chars[..head].iter().collect();
353 out.push_str(TRIM_MARKER);
354 out.extend(chars[chars.len() - tail..].iter());
355 out
356}
357
358fn summarize_for_judge(
368 instructions: &[InstructionBlock],
369 messages: &[Message],
370 turn: usize,
371 phase: Option<EvaluationPhase>,
372 config: &EscalationJudgeConfig,
373) -> String {
374 let mut instruction_anchors: Vec<String> = Vec::new();
375 let mut anchors: Vec<String> = Vec::new();
376 let mut window: Vec<String> = Vec::new();
377 let mut assistant_seen = false;
378
379 for instruction in instructions {
380 let mut parts = Vec::new();
381 collect_text(&instruction.content, &mut parts);
382 instruction_anchors.push(format!(
383 "[{}] {}",
384 role_label(instruction.role),
385 truncate_middle(&parts.join(" "), SYSTEM_CHARS)
386 ));
387 }
388
389 for message in messages {
390 let text = message_text(message);
391 match message.role {
392 Role::System | Role::Developer => instruction_anchors.push(format!(
393 "[{}] {}",
394 role_label(message.role),
395 truncate_middle(&text, SYSTEM_CHARS)
396 )),
397 Role::User if !assistant_seen => {
399 anchors.push(format!(
400 "[user (task)] {}",
401 truncate_middle(&text, TASK_CHARS)
402 ));
403 }
404 role => {
405 if role == Role::Assistant {
406 assistant_seen = true;
407 }
408 window.push(format!(
409 "[{}] {}",
410 role_label(role),
411 truncate_middle(&text, config.window_message_chars)
412 ));
413 }
414 }
415 }
416
417 if window.len() > config.recent_turn_window {
418 window.drain(..window.len() - config.recent_turn_window);
419 }
420
421 let assemble = |instructions: Option<&str>, window: &[String]| {
422 let header = format!(
423 "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
424 window.len(),
425 messages.len(),
426 );
427 phase
428 .map(|phase| format!("Routing phase: {}", phase.marker()))
429 .into_iter()
430 .chain(std::iter::once(header))
431 .chain(instructions.map(str::to_owned))
432 .chain(anchors.iter().cloned())
433 .chain(window.iter().cloned())
434 .collect::<Vec<_>>()
435 .join("\n")
436 };
437
438 let reserved = assemble(None, &window[window.len().saturating_sub(1)..])
439 .chars()
440 .count();
441 let instruction_budget = MAX_REQUEST_CHARS.saturating_sub(reserved + 1);
442 let instruction_text = truncate_middle(&instruction_anchors.join("\n"), instruction_budget)
444 .chars()
445 .take(instruction_budget)
446 .collect::<String>();
447 let instructions = (!instruction_text.is_empty()).then_some(instruction_text.as_str());
448 let mut text = assemble(instructions, &window);
449 while text.chars().count() > MAX_REQUEST_CHARS && !window.is_empty() {
450 window.remove(0);
451 text = assemble(instructions, &window);
452 }
453 if text.chars().count() > MAX_REQUEST_CHARS {
454 let keep = MAX_REQUEST_CHARS.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
455 text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
456 }
457 text
458}
459
460fn role_label(role: Role) -> &'static str {
462 match role {
463 Role::System => "system",
464 Role::Developer => "developer",
465 Role::User => "user",
466 Role::Assistant => "assistant",
467 Role::Tool => "tool",
468 }
469}
470
471#[cfg(test)]
476pub(crate) fn request_at_turn(session_id: Option<&str>, turn: usize) -> Request {
477 use switchyard_protocol::{LlmRequest, Metadata};
478
479 let mut messages = vec![Message::text(Role::User, "What is 2+2?")];
480 for attempt in 1..turn {
481 messages.push(Message::text(Role::Assistant, format!("attempt {attempt}")));
482 messages.push(Message::text(Role::User, format!("still wrong {attempt}")));
483 }
484 Request {
485 llm_request: LlmRequest {
486 model: Some("auto".to_string()),
487 messages,
488 ..LlmRequest::default()
489 },
490 raw_request: None,
491 metadata: session_id.map(|id| Metadata {
492 session_id: Some(id.to_string()),
493 ..Metadata::default()
494 }),
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use serde_json::json;
501 use switchyard_protocol::{ContentBlock, Message, Role, ToolCall, ToolResult};
502
503 use super::*;
504 use crate::algorithms::util::llm_judge::Judge;
505
506 fn escalation_judge(
507 max_output_tokens: u64,
508 phase: Option<EvaluationPhase>,
509 contract_config: &ClassifierContractConfig,
510 ) -> Result<EscalationJudge> {
511 Ok(StructuredJudge::new(
512 EscalationInput {
513 config: EscalationJudgeConfig::default(),
514 phase,
515 },
516 build_contract(contract_config, phase.is_some())?,
517 SerdeDecoder::new(),
518 JudgeRuntimeConfig::new(max_output_tokens)?,
519 ))
520 }
521
522 #[test]
523 fn judge_request_is_rubric_plus_summary_under_a_completion_cap() -> Result<()> {
524 let judge = escalation_judge(
525 super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS,
526 None,
527 &ClassifierContractConfig::default(),
528 )?;
529
530 let mut judged = request_at_turn(None, 4);
532 judged.llm_request.instructions = [
533 (Role::System, "system constraint"),
534 (Role::Developer, "developer constraint"),
535 ]
536 .into_iter()
537 .map(|(role, text)| InstructionBlock {
538 role,
539 content: Message::text(role, text).content,
540 })
541 .collect();
542 judged
543 .llm_request
544 .messages
545 .push(Message::text(Role::Assistant, "this turn's reply"));
546 let built = judge.build_request(&State::default(), &judged);
547
548 assert_eq!(built.llm_request.instructions.len(), 1);
550 assert_eq!(built.llm_request.instructions[0].role, Role::System);
551 assert_eq!(
552 built.llm_request.instructions[0].content.as_slice(),
553 &[ContentBlock::Text {
554 text: PROMPT_TEMPLATE.to_string()
555 }]
556 );
557 assert_eq!(built.llm_request.messages.len(), 1);
558 assert_eq!(built.llm_request.messages[0].role, Role::User);
559 let summary = built.llm_request.messages[0]
560 .text_content("")
561 .expect("summary");
562 assert!(summary.contains("Conversation turn 4"));
563 assert!(summary.contains(
564 "[system] system constraint\n[developer] developer constraint\n[user (task)] What is 2+2?"
565 ));
566 assert!(summary.contains("[assistant] this turn's reply"));
567 assert!(!summary.contains("Routing phase:"));
568 assert_eq!(
570 built.llm_request.output.max_output_tokens,
571 Some(super::super::DEFAULT_JUDGE_MAX_OUTPUT_TOKENS)
572 );
573 assert!(built.llm_request.output.response_format.is_some());
574
575 judged.llm_request.instructions.extend(vec![
576 InstructionBlock {
577 role: Role::Developer,
578 content: Message::text(Role::Developer, "x".repeat(SYSTEM_CHARS)).content,
579 };
580 MAX_REQUEST_CHARS / SYSTEM_CHARS + 1
581 ]);
582 let built = judge.build_request(&State::default(), &judged);
583 let summary = built.llm_request.messages[0]
584 .text_content("")
585 .expect("summary");
586 assert!(summary.chars().count() <= MAX_REQUEST_CHARS);
587 assert!(summary.contains(TRIM_MARKER));
588 assert!(summary.contains("[system] system constraint"));
589 assert!(summary.contains("[developer] developer constraint"));
590 assert!(summary.contains("[user (task)] What is 2+2?"));
591 assert!(summary.contains("[assistant] this turn's reply"));
592 Ok(())
593 }
594
595 #[test]
596 fn judge_request_uses_the_configured_completion_cap() -> Result<()> {
597 let judge = escalation_judge(512, None, &ClassifierContractConfig::default())?;
598
599 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
600
601 assert_eq!(built.llm_request.output.max_output_tokens, Some(512));
602 Ok(())
603 }
604
605 #[test]
606 fn conversation_turn_counts_assistant_replies() {
607 for turn in [1, 5] {
610 let mut judged = request_at_turn(None, turn);
611 judged
612 .llm_request
613 .messages
614 .push(Message::text(Role::Assistant, "this turn's reply"));
615 assert_eq!(conversation_turn(&judged), turn);
616 }
617 }
618
619 #[test]
620 fn message_text_keeps_tool_calls_and_results() {
621 let call = Message {
622 role: Role::Assistant,
623 content: vec![
624 ContentBlock::Text {
625 text: "running it".to_string(),
626 },
627 ContentBlock::ToolCall(ToolCall {
628 id: "call-1".to_string(),
629 name: "bash".to_string(),
630 arguments: json!({"cmd": "ls"}),
631 }),
632 ],
633 };
634 let text = message_text(&call);
635 assert!(text.contains("running it"), "{text}");
636 assert!(text.contains(r#"tool_call bash({"cmd":"ls"})"#), "{text}");
637
638 let result = Message {
639 role: Role::Tool,
640 content: vec![ContentBlock::ToolResult(ToolResult {
641 tool_call_id: "call-1".to_string(),
642 content: vec![ContentBlock::Text {
643 text: "no such file".to_string(),
644 }],
645 is_error: Some(true),
646 })],
647 };
648 assert_eq!(message_text(&result), "no such file");
649 }
650
651 #[test]
652 fn truncate_middle_keeps_head_and_tail() {
653 let text = "a".repeat(40) + &"z".repeat(40);
654 let trimmed = truncate_middle(&text, 50);
655 assert!(trimmed.chars().count() <= 50, "{trimmed}");
656 assert!(trimmed.starts_with('a'));
657 assert!(trimmed.ends_with('z'));
658 assert!(trimmed.contains("[trimmed]"));
659
660 assert_eq!(truncate_middle("short", 50), "short");
662 }
663
664 #[test]
665 fn deescalation_settings_must_be_valid() {
666 let zero = EscalationJudgeConfig {
667 deescalation: Some(DeescalationConfig {
668 strong_min_calls: 0,
669 strong_max_calls: None,
670 confirmations: 2,
671 weak_cooldown_calls: 0,
672 }),
673 ..EscalationJudgeConfig::default()
674 };
675 assert!(
676 zero.validate()
677 .is_err_and(|error| error.to_string().contains("at least 1"))
678 );
679
680 let inverted = EscalationJudgeConfig {
681 deescalation: Some(DeescalationConfig {
682 strong_min_calls: 4,
683 strong_max_calls: Some(3),
684 confirmations: 2,
685 weak_cooldown_calls: 0,
686 }),
687 ..EscalationJudgeConfig::default()
688 };
689 assert!(
690 inverted
691 .validate()
692 .is_err_and(|error| error.to_string().contains("at least deescalation"))
693 );
694 }
695
696 #[test]
697 fn deescalation_contract_marks_both_routing_phases() -> Result<()> {
698 let contract = ClassifierContractConfig::default().with_prompt("Custom trajectory rubric.");
699 for (phase, marker) in [
700 (EvaluationPhase::Efficient, "EFFICIENT_EVALUATION"),
701 (EvaluationPhase::Strong, "STRONG_EVALUATION"),
702 ] {
703 let judge = escalation_judge(512, Some(phase), &contract)?;
704 let built = judge.build_request(&State::default(), &request_at_turn(None, 1));
705 let system_prompt = built.llm_request.instructions[0].content.iter().find_map(
706 |content| match content {
707 ContentBlock::Text { text } => Some(text.as_str()),
708 _ => None,
709 },
710 );
711 assert!(system_prompt.is_some_and(|prompt| {
712 prompt.starts_with("Custom trajectory rubric.")
713 && prompt.contains("EFFICIENT_EVALUATION")
714 && prompt.contains("STRONG_EVALUATION")
715 }));
716 assert!(
717 built.llm_request.messages[0]
718 .text_content("")
719 .is_some_and(|summary| summary.starts_with(&format!(
720 "Routing phase: {marker}"
721 )))
722 );
723 }
724 Ok(())
725 }
726
727 #[test]
728 fn summary_keeps_anchors_and_the_recent_window() {
729 let mut messages = vec![
730 Message::text(Role::System, "you are a coding agent"),
731 Message::text(Role::User, "fix the failing test"),
732 ];
733 for i in 0..10 {
734 messages.push(Message::text(Role::Assistant, format!("step {i}")));
735 }
736 let config = EscalationJudgeConfig {
737 recent_turn_window: 3,
738 ..EscalationJudgeConfig::default()
739 };
740
741 let summary = summarize_for_judge(&[], &messages, 11, None, &config);
742
743 assert!(
744 summary.contains("[system] you are a coding agent"),
745 "{summary}"
746 );
747 assert!(
748 summary.contains("[user (task)] fix the failing test"),
749 "{summary}"
750 );
751 assert!(summary.contains("Conversation turn 11; showing the last 3 of 12 messages"));
752 assert!(summary.contains("step 9"), "{summary}");
754 assert!(summary.contains("step 7"), "{summary}");
755 assert!(!summary.contains("step 6"), "{summary}");
756 }
757
758 #[test]
759 fn summary_anchors_every_user_message_before_the_first_reply() {
760 let mut messages = vec![
763 Message::text(
764 Role::Developer,
765 "<skills_instructions>...</skills_instructions>",
766 ),
767 Message::text(
768 Role::User,
769 "<environment_context><cwd>/app</cwd></environment_context>",
770 ),
771 Message::text(Role::User, "Implement RFC 5545 timezone interop in rrule."),
772 ];
773 for i in 0..40 {
774 messages.push(Message::text(Role::Assistant, format!("step {i}")));
775 messages.push(Message::text(Role::User, format!("later user note {i}")));
776 }
777 let config = EscalationJudgeConfig {
778 recent_turn_window: 3,
779 ..EscalationJudgeConfig::default()
780 };
781
782 let summary = summarize_for_judge(&[], &messages, 40, None, &config);
783
784 assert!(
785 summary.contains("[user (task)] <environment_context>"),
786 "{summary}"
787 );
788 assert!(
789 summary.contains("[user (task)] Implement RFC 5545 timezone interop in rrule."),
790 "{summary}"
791 );
792 assert!(
794 !summary.contains("[user (task)] later user note"),
795 "{summary}"
796 );
797 assert!(summary.contains("[user] later user note 39"), "{summary}");
798 assert!(!summary.contains("later user note 0\n"), "{summary}");
799 }
800
801 #[test]
802 fn summary_drops_oldest_window_lines_under_the_char_cap() {
803 let mut messages = vec![
807 Message::text(Role::System, "framing"),
808 Message::text(Role::User, "task"),
809 ];
810 for i in 0..20 {
811 messages.push(Message::text(
812 Role::Assistant,
813 format!("{i} {}", "x".repeat(2_000)),
814 ));
815 }
816 let config = EscalationJudgeConfig {
817 window_message_chars: 2_000,
818 ..EscalationJudgeConfig::default()
819 };
820
821 let summary = summarize_for_judge(&[], &messages, 21, None, &config);
822
823 assert!(
824 summary.chars().count() <= MAX_REQUEST_CHARS,
825 "{}",
826 summary.chars().count()
827 );
828 assert!(summary.contains("[system] framing"), "{summary}");
830 assert!(summary.contains("[user (task)] task"), "{summary}");
831 assert!(summary.contains("19 xxx"), "{summary}");
832 assert!(!summary.contains("0 xxx"), "{summary}");
833 }
834}