use serde::Deserialize;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::time::Instant;
use crate::retry::{FailureClass, RetryFailureRecord, RetryLoop, RetryPolicy};
use crate::{ChatRequest, ChatRequestMeta, Workspace};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupingMember {
pub id: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupingGroup {
pub heading: String,
#[serde(default)]
pub contradiction: bool,
pub members: Vec<GroupingMember>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupingOutput {
pub summary: String,
pub groups: Vec<GroupingGroup>,
#[serde(default)]
pub ungrouped: Vec<GroupingMember>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupingDelta {
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub groups: Vec<GroupingGroup>,
#[serde(default)]
pub ungrouped: Vec<GroupingMember>,
#[serde(default)]
pub references: Vec<GroupingReference>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupingReference {
pub group: usize,
pub member: GroupingMember,
}
pub(crate) enum RepairOutcome {
Repaired {
output: GroupingOutput,
references: Vec<GroupingReference>,
},
Fallback,
}
pub(crate) struct ItemTable<'a> {
rows: Vec<(usize, usize)>,
items_by_agent: &'a [Vec<String>],
}
impl<'a> ItemTable<'a> {
#[must_use]
pub(crate) fn new(items_by_agent: &'a [Vec<String>]) -> Self {
let mut rows = Vec::new();
for (agent, items) in items_by_agent.iter().enumerate() {
for i in 0..items.len() {
rows.push((agent, i));
}
}
Self {
rows,
items_by_agent,
}
}
#[must_use]
pub(crate) fn len(&self) -> usize {
self.rows.len()
}
#[must_use]
pub(crate) fn resolve(&self, id: usize) -> Option<(usize, &str)> {
let (agent, item) = *self.rows.get(id)?;
let text = self.items_by_agent.get(agent)?.get(item)?;
Some((agent, text))
}
#[must_use]
pub(crate) fn resolve_index(&self, id: usize) -> Option<(usize, usize)> {
self.rows.get(id).copied()
}
#[must_use]
pub(crate) fn agent(&self, id: usize) -> Option<usize> {
self.rows.get(id).map(|r| r.0)
}
}
#[must_use]
pub(crate) fn distinct_agents(group: &GroupingGroup, table: &ItemTable<'_>) -> Vec<usize> {
let mut agents: Vec<usize> = group
.members
.iter()
.filter_map(|m| table.agent(m.id))
.collect();
agents.sort_unstable();
agents.dedup();
agents
}
#[must_use]
pub(crate) fn bracket_label(n: usize, n_valid: usize, disputed: bool) -> String {
if disputed {
format!("[{n}/{n_valid} · DISPUTED]")
} else {
format!("[{n}/{n_valid}]")
}
}
#[must_use]
pub(crate) fn render_member_line(member: &GroupingMember, table: &ItemTable<'_>) -> String {
match table.resolve(member.id) {
Some((agent, text)) => format!("Agent {agent}: {text}"),
None => format!("Agent ?: <unknown item id {}>", member.id),
}
}
#[must_use]
pub(crate) fn numbered_items_material(items_by_agent: &[Vec<String>]) -> String {
let mut material = String::new();
let mut id = 0usize;
for (agent_idx, items) in items_by_agent.iter().enumerate() {
if items.is_empty() {
continue;
}
let _ = writeln!(material, "Agent {agent_idx}:");
for item in items {
let _ = writeln!(material, "- {id}: {item}");
id += 1;
}
}
material
}
#[must_use]
pub(crate) fn render_ungrouped_section(
output: &GroupingOutput,
references: &[GroupingReference],
render_member: impl Fn(&GroupingMember, &str) -> String,
) -> String {
if output.ungrouped.is_empty() {
return String::new();
}
let mut out = String::from("\n\n**Ungrouped**");
for member in &output.ungrouped {
let mut disputed = String::new();
for reference in references.iter().filter(|r| r.member.id == member.id) {
if let Some(group) = output.groups.get(reference.group) {
let _ = write!(
disputed,
" [DISPUTED — contradicts group {} \"{}\"]",
reference.group, group.heading
);
}
}
let _ = write!(out, "\n- {}", render_member(member, &disputed));
}
out
}
pub(crate) struct RepairState<'a> {
table: ItemTable<'a>,
frozen_groups: Vec<GroupingGroup>,
placed: HashSet<usize>,
references: Vec<GroupingReference>,
pinned: HashSet<usize>,
flagged: HashSet<usize>,
summary: Option<String>,
}
impl<'a> RepairState<'a> {
pub(crate) fn new(items_by_agent: &'a [Vec<String>]) -> Self {
Self {
table: ItemTable::new(items_by_agent),
frozen_groups: Vec::new(),
placed: HashSet::new(),
references: Vec::new(),
pinned: HashSet::new(),
flagged: HashSet::new(),
summary: None,
}
}
#[must_use]
pub(crate) fn remainder(&self) -> Vec<usize> {
(0..self.table.len())
.filter(|id| !self.placed.contains(id))
.collect()
}
#[must_use]
pub(crate) fn complete(&self) -> bool {
(0..self.table.len()).all(|id| self.placed.contains(&id) || self.pinned.contains(&id))
}
}
pub(crate) struct RoundInput {
pub summary: Option<String>,
pub groups: Vec<GroupingGroup>,
pub ungrouped: Vec<GroupingMember>,
pub references: Vec<GroupingReference>,
}
#[derive(Default)]
pub(crate) struct RoundOutcome {
pub round_rejection: Option<String>,
pub rejections: Vec<(FailureClass, String)>,
pub froze: usize,
pub accepted_refs: usize,
pub accepted_summary: bool,
}
fn member_range_error(member: &GroupingMember, table: &ItemTable<'_>) -> Option<String> {
(member.id >= table.len()).then(|| format!("unknown item id {}", member.id))
}
fn reject(outcome: &mut RoundOutcome, cause: FailureClass, msg: String) {
outcome.rejections.push((cause, msg));
}
#[expect(clippy::too_many_lines)]
pub(crate) fn process_round(input: RoundInput, state: &mut RepairState<'_>) -> RoundOutcome {
let RoundInput {
summary,
groups,
ungrouped,
references,
} = input;
let mut outcome = RoundOutcome::default();
let mut raw: HashSet<usize> = HashSet::new();
for g in &groups {
raw.extend(g.members.iter().map(|m| m.id));
}
raw.extend(ungrouped.iter().map(|m| m.id));
for id in 0..state.table.len() {
if state.placed.contains(&id) || state.pinned.contains(&id) {
continue;
}
if !raw.contains(&id) {
outcome.round_rejection = Some(format!(
"item {id} missing from every proposed group and the ungrouped list"
));
return outcome;
}
}
if state.summary.is_none() {
if let Some(s) = summary.as_deref().filter(|s| !s.trim().is_empty()) {
state.summary = Some(s.trim().to_string());
outcome.accepted_summary = true;
} else {
reject(
&mut outcome,
FailureClass::ValidationOther,
"summary is empty or missing — supply a non-empty summary".to_string(),
);
}
}
for (i, group) in groups.into_iter().enumerate() {
let mut reasons: Vec<String> = Vec::new();
let mut cause: Option<FailureClass> = None;
if group.members.is_empty() {
reasons.push("group has no members".to_string());
cause.get_or_insert(FailureClass::Membership);
}
if group.heading.trim().is_empty() {
reasons.push("empty heading".to_string());
cause.get_or_insert(FailureClass::ValidationOther);
}
if group.contradiction && distinct_agents(&group, &state.table).len() < 2 {
reasons.push("contradiction without ≥2 distinct cited agents".to_string());
cause.get_or_insert(FailureClass::ContradictionAgents);
}
let mut seen: HashSet<usize> = HashSet::new();
for member in &group.members {
if !seen.insert(member.id) {
reasons.push("duplicate member within the group".to_string());
} else if state.pinned.contains(&member.id) {
reasons.push(
"member has an accepted contradiction reference and must remain ungrouped"
.to_string(),
);
} else if let Some(msg) = member_range_error(member, &state.table) {
reasons.push(msg);
} else if state.placed.contains(&member.id) {
reasons.push("member already placed in a frozen group".to_string());
}
cause.get_or_insert(FailureClass::Membership);
}
if reasons.is_empty() {
for member in &group.members {
state.placed.insert(member.id);
}
if group.contradiction {
state.flagged.insert(state.frozen_groups.len());
}
state.frozen_groups.push(group);
outcome.froze += 1;
} else {
reject(
&mut outcome,
cause.unwrap(),
format!("group {i}: {}", reasons.join("; ")),
);
}
}
for (i, member) in ungrouped.into_iter().enumerate() {
if let Some(msg) = member_range_error(&member, &state.table) {
reject(
&mut outcome,
FailureClass::Membership,
format!("ungrouped entry {i}: {msg}"),
);
}
}
for (i, reference) in references.into_iter().enumerate() {
if reference.group >= state.frozen_groups.len() {
reject(
&mut outcome,
FailureClass::ValidationOther,
format!(
"reference {i}: unknown frozen group index {}",
reference.group
),
);
continue;
}
if state.flagged.contains(&reference.group) {
continue; }
if state
.references
.iter()
.any(|r| r.group == reference.group && r.member.id == reference.member.id)
{
continue; }
if let Some(msg) = member_range_error(&reference.member, &state.table) {
reject(
&mut outcome,
FailureClass::Membership,
format!("reference {i}: {msg}"),
);
continue;
}
if state.placed.contains(&reference.member.id) {
reject(
&mut outcome,
FailureClass::Membership,
format!("reference {i}: member already placed in a frozen group"),
);
continue;
}
let mut agents = distinct_agents(&state.frozen_groups[reference.group], &state.table);
if let Some(agent) = state.table.agent(reference.member.id)
&& !agents.contains(&agent)
{
agents.push(agent);
}
if agents.len() < 2 {
reject(
&mut outcome,
FailureClass::ContradictionAgents,
format!("reference {i}: contradiction without ≥2 distinct cited agents"),
);
continue;
}
state.pinned.insert(reference.member.id);
state.references.push(reference);
outcome.accepted_refs += 1;
}
outcome
}
#[derive(Clone, Copy, Debug)]
enum PrevRound {
Transport,
Parse,
Processed {
accepted: bool,
},
}
const REPAIR_FRAMING_FALLBACK: [&str; 5] = [
"The previous call did not complete (transport failure) — please respond now with the repair delta.",
"Your previous response could not be parsed — please respond now with the repair delta.",
"Your previous response was rejected — the rejected proposals are listed below.",
"Your previous response was accepted; the remaining items below still need placement.",
"Your previous response was partially accepted; rejected proposals are listed below.",
];
fn repair_framing(prev: PrevRound, rejections: &[String]) -> String {
let idx = match prev {
PrevRound::Transport => 0,
PrevRound::Parse => 1,
PrevRound::Processed { accepted: false } => 2,
PrevRound::Processed { accepted: true } if rejections.is_empty() => 3,
PrevRound::Processed { accepted: true } => 4,
};
let sections = crate::prompt::load_prompt_sections("synthesis/repair_framing.md");
let bounded = sections.get(..REPAIR_FRAMING_FALLBACK.len());
bounded
.and_then(|framings| framings.get(idx))
.cloned()
.unwrap_or_else(|| {
let fallback = REPAIR_FRAMING_FALLBACK
.get(idx)
.copied()
.unwrap_or_default();
tracing::warn!(
asset = "synthesis/repair_framing.md",
framing_sections = sections.len().min(REPAIR_FRAMING_FALLBACK.len()),
index = idx,
"repair framing section unavailable — using {}",
if fallback.is_empty() {
"an empty framing (no fallback const for this index)"
} else {
"the in-Rust fallback literal"
}
);
fallback.to_owned()
})
}
fn append_repair_instructions(
request: &mut ChatRequest,
round: u32,
state: &RepairState<'_>,
rejections: &[String],
prev: PrevRound,
) {
let framing = repair_framing(prev, rejections);
let mut rejections_section = String::new();
if !rejections.is_empty() {
rejections_section.push_str("\nRejected proposals still outstanding (fix these):\n");
for r in rejections {
let _ = writeln!(rejections_section, "- {r}");
}
}
let mut frozen_groups_lines = String::new();
if state.frozen_groups.is_empty() {
frozen_groups_lines.push_str("- none\n");
} else {
for (i, g) in state.frozen_groups.iter().enumerate() {
let agents = distinct_agents(g, &state.table)
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ");
let representative = g.members.first().map_or_else(
|| "—".to_string(),
|m| format!("{} (id {})", render_member_line(m, &state.table), m.id),
);
let _ = writeln!(
frozen_groups_lines,
"- {i}: {:?} [agents {agents}, contradiction: {}] — e.g. {representative}",
g.heading, g.contradiction,
);
}
}
let mut remainder_lines = String::new();
for id in state.remainder() {
let line = render_member_line(&GroupingMember { id }, &state.table);
if state.pinned.contains(&id) {
let _ = writeln!(
remainder_lines,
"- {id}: {line} [pinned — accepted contradiction reference; do NOT place in a group]"
);
} else {
let _ = writeln!(remainder_lines, "- {id}: {line}");
}
}
let section = crate::prompt::substitute(
&crate::prompt::load_prompt("synthesis/repair_round.md"),
&[
("{{round}}", &round.to_string()),
("{{framing}}", &framing),
("{{rejections_section}}", &rejections_section),
("{{frozen_groups_lines}}", &frozen_groups_lines),
("{{remainder_lines}}", &remainder_lines),
],
);
if let Some(last) = request.messages.last_mut() {
last.content.push_str(§ion);
}
}
#[expect(clippy::too_many_lines)]
pub(crate) async fn run_grouping_repair(
ws: &Workspace,
purpose: &'static str,
mut request: ChatRequest,
items_by_agent: &[Vec<String>],
parent: Option<crate::registry::ParentKey>,
parent_label: Option<String>,
) -> RepairOutcome {
let _call = crate::call_registry::NON_AGENT_CALLS.register(
purpose,
&ws.name,
parent,
false,
parent_label,
);
crate::prompt::prepend_general_context(&mut request.messages, ws).await;
let policy = RetryPolicy::synthesis();
let mut loop_state = RetryLoop::new(&policy);
let operation_started = Instant::now();
let mut state = RepairState::new(items_by_agent);
let mut rejections: Vec<String> = Vec::new();
let mut prev_round = PrevRound::Processed { accepted: false };
let mut transport_failures: u32 = 0;
for round in 1..=policy.max_attempts {
if loop_state.expired() {
break;
}
if round > 1 {
append_repair_instructions(&mut request, round, &state, &rejections, prev_round);
}
let attempt_started = Instant::now();
let resp = match crate::providers::chat_scoped(
request.clone(),
policy.idle_timeout,
loop_state.deadline(),
)
.await
{
Ok(resp) => resp,
Err(err) => {
let non_retryable = !err.class.is_retryable();
loop_state.record(err.record);
if non_retryable {
break;
}
transport_failures += 1;
if round < policy.max_attempts
&& let Err(FailureClass::Shutdown) =
loop_state.sleep_between(transport_failures).await
{
break;
}
prev_round = PrevRound::Transport;
continue;
}
};
let raw = resp.text_or_empty();
if !resp.tool_calls.is_empty() {
let rec = RetryFailureRecord::new_simple(
FailureClass::Parse,
&anyhow::anyhow!("grouping response returned a tool call instead of JSON"),
None,
);
loop_state.record(rec);
prev_round = PrevRound::Parse;
continue;
}
let parsed: Result<RoundInput, _> = if round == 1 {
crate::util::json::parse_fenced_json::<GroupingOutput>(raw).map(|o| RoundInput {
summary: Some(o.summary),
groups: o.groups,
ungrouped: o.ungrouped,
references: Vec::new(),
})
} else {
crate::util::json::parse_fenced_json::<GroupingDelta>(raw).map(|d| RoundInput {
summary: d.summary,
groups: d.groups,
ungrouped: d.ungrouped,
references: d.references,
})
};
let outcome = match parsed {
Ok(input) => {
let outcome = process_round(input, &mut state);
rejections.clear();
if let Some(msg) = &outcome.round_rejection {
rejections.push(msg.clone());
}
rejections.extend(outcome.rejections.iter().map(|(_, msg)| msg.clone()));
outcome
}
Err(e) => {
let rec = RetryFailureRecord::new_simple(FailureClass::Parse, &e, None);
loop_state.record(rec);
prev_round = PrevRound::Parse;
continue;
}
};
let accepted = outcome.froze > 0 || outcome.accepted_refs > 0 || outcome.accepted_summary;
prev_round = PrevRound::Processed { accepted };
if accepted {
crate::stats::record_llm_success(&request, attempt_started, round, &resp).await;
}
let cause = if outcome.round_rejection.is_some() {
FailureClass::Completeness
} else {
outcome
.rejections
.first()
.map_or(FailureClass::ValidationOther, |(c, _)| *c)
};
tracing::info!(
purpose,
workspace = %ws.name,
round,
accepted,
froze = outcome.froze,
refs = outcome.accepted_refs,
summary = outcome.accepted_summary,
rejections = outcome.rejections.len(),
cause = cause.label(),
"Grouping synthesis repair round",
);
if !accepted {
let detail = if rejections.is_empty() {
"round accepted nothing (zero-progress; no validation rejections)".to_string()
} else {
rejections.join("; ")
};
let err = anyhow::anyhow!("grouping validation failed: {detail}");
let rec = RetryFailureRecord::new_simple(cause, &err, None);
loop_state.record(rec);
}
if state.complete() {
break; }
if round > 1 && outcome.froze == 0 {
break; }
}
if state.frozen_groups.is_empty() {
tracing::warn!(
purpose,
workspace = %ws.name,
attempts = policy.max_attempts,
"Grouping synthesis exhausted — writing deterministic fail-open output",
);
if !loop_state.has_failures() {
let detail = if state.summary.is_some() {
"operation exhausted after accepting only a summary — no groups ever frozen"
} else {
"operation exhausted with no accepted output"
};
let rec = RetryFailureRecord::new_simple(
FailureClass::ValidationOther,
&anyhow::anyhow!("{detail}"),
None,
);
loop_state.record(rec);
}
let final_class = loop_state.final_class();
let exhausted = crate::retry::RetryExhausted::with_last_raw(
loop_state.into_failures(),
final_class,
None,
);
crate::stats::record_llm_failure(&request, operation_started, &exhausted).await;
return RepairOutcome::Fallback;
}
let ungrouped: Vec<GroupingMember> = state
.remainder()
.into_iter()
.map(|id| GroupingMember { id })
.collect();
tracing::info!(
purpose,
workspace = %ws.name,
groups = state.frozen_groups.len(),
ungrouped = ungrouped.len(),
"Grouping synthesis repaired",
);
RepairOutcome::Repaired {
output: GroupingOutput {
summary: state.summary.unwrap_or_default(),
groups: state.frozen_groups,
ungrouped,
},
references: state.references,
}
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn grouping_request(
ws: &Workspace,
purpose: &'static str,
system: &str,
user: &str,
model: String,
reasoning_effort: Option<String>,
provider_order: Option<String>,
max_tokens: Option<u32>,
) -> ChatRequest {
ChatRequest {
messages: vec![
crate::ChatMessage::system(system),
crate::ChatMessage::user(user),
],
tools: None,
model,
allow_image_parts: false,
max_tokens,
reasoning_effort,
provider_order,
meta: Some(ChatRequestMeta {
purpose,
agent_id: format!("grouping_{}_{}", ws.name, crate::generate_suffix()),
role: "grouping".to_string(),
workspace: ws.name.clone(),
ticket_id: None,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[expect(clippy::too_many_lines)] #[test]
fn repair_instructions_assembly_is_byte_exact() {
let items = vec![vec!["first".to_string()], vec!["second".to_string()]];
let mut state = RepairState::new(&items);
let outcome = process_round(
RoundInput {
summary: Some("s".to_string()),
groups: vec![GroupingGroup {
heading: "G".to_string(),
contradiction: false,
members: vec![GroupingMember { id: 0 }],
}],
ungrouped: vec![GroupingMember { id: 1 }],
references: Vec::new(),
},
&mut state,
);
assert_eq!(outcome.froze, 1);
assert_eq!(state.remainder(), vec![1]);
let rejections =
vec!["item 1 missing from every proposed group and the ungrouped list".to_string()];
let asset_sections = crate::prompt::load_prompt_sections("synthesis/repair_framing.md");
let fallback: Vec<String> = REPAIR_FRAMING_FALLBACK
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(
asset_sections.len(),
REPAIR_FRAMING_FALLBACK.len() + 1,
"repair_framing.md must hold the framing sections plus the order-contract comment"
);
assert_eq!(
asset_sections.get(..REPAIR_FRAMING_FALLBACK.len()),
Some(fallback.as_slice()),
"fallback framings must mirror the asset sections"
);
let template = crate::prompt::load_prompt("synthesis/repair_round.md");
let (raw_prefix, after_framing) = template
.split_once("{{framing}}")
.expect("repair_round.md must keep the {{framing}} slot");
let prefix = raw_prefix.replace("{{round}}", "2");
let (lead_in, _) = after_framing
.split_once("{{rejections_section}}")
.expect("repair_round.md must keep the {{rejections_section}} slot");
let (_, after_remainder) = template
.split_once("{{remainder_lines}}")
.expect("repair_round.md must keep the {{remainder_lines}} slot");
let tail = after_remainder
.strip_prefix('\n')
.expect("repair_round.md must put a single newline after {{remainder_lines}}");
assert!(
!tail.starts_with('\n'),
"repair_round.md must put exactly one newline after {{remainder_lines}}"
);
let frozen_header = "Frozen groups (skeletons — do NOT re-propose their members; references to a group flagged contradiction:true are ignored):\n";
let remainder_header = "Remaining items (place EVERY un-pinned one in a proposed group or the ungrouped list; pinned items must NOT be placed in a group). Reference each item by its id:\n";
let with_rejections = format!(
"\nRejected proposals still outstanding (fix these):\n\
- item 1 missing from every proposed group and the ungrouped list\n\
\n{frozen_header}\
- 0: \"G\" [agents 0, contradiction: false] — e.g. Agent 0: first (id 0)\n\
\n{remainder_header}\
- 1: Agent 1: second\n"
);
let without_rejections = format!(
"\n{frozen_header}\
- 0: \"G\" [agents 0, contradiction: false] — e.g. Agent 0: first (id 0)\n\
\n{remainder_header}\
- 1: Agent 1: second\n"
);
let variants: [(PrevRound, bool); 5] = [
(PrevRound::Transport, false),
(PrevRound::Parse, false),
(PrevRound::Processed { accepted: false }, false),
(PrevRound::Processed { accepted: true }, true),
(PrevRound::Processed { accepted: true }, false),
];
for (i, (prev, empty_rejections)) in variants.into_iter().enumerate() {
let mut request =
crate::providers::test_request(vec![crate::ChatMessage::user("")], None);
append_repair_instructions(
&mut request,
2,
&state,
if empty_rejections { &[] } else { &rejections },
prev,
);
let middle = if empty_rejections {
&without_rejections
} else {
&with_rejections
};
let expected = format!(
"{prefix}{framing}{lead_in}{middle}\n{tail}",
framing = REPAIR_FRAMING_FALLBACK[i]
);
assert_eq!(
request.messages.last().unwrap().content,
expected,
"variant {i}: framing misselection or newline drift"
);
}
let no_frozen = RepairState::new(&items);
let mut request = crate::providers::test_request(vec![crate::ChatMessage::user("")], None);
append_repair_instructions(&mut request, 2, &no_frozen, &[], PrevRound::Transport);
let empty_frozen = format!(
"\n{frozen_header}\
- none\n\
\n{remainder_header}\
- 0: Agent 0: first\n\
- 1: Agent 1: second\n"
);
assert_eq!(
request.messages.last().unwrap().content,
format!(
"{prefix}{framing}{lead_in}{empty_frozen}\n{tail}",
framing = REPAIR_FRAMING_FALLBACK[0]
),
"empty-frozen: `- none` line or newline drift"
);
let mut pinned_state = RepairState::new(&items);
let outcome = process_round(
RoundInput {
summary: Some("s".to_string()),
groups: vec![GroupingGroup {
heading: "G".to_string(),
contradiction: false,
members: vec![GroupingMember { id: 0 }],
}],
ungrouped: vec![GroupingMember { id: 1 }],
references: vec![GroupingReference {
member: GroupingMember { id: 1 },
group: 0,
}],
},
&mut pinned_state,
);
assert_eq!(outcome.froze, 1);
assert_eq!(outcome.accepted_refs, 1);
let mut request = crate::providers::test_request(vec![crate::ChatMessage::user("")], None);
append_repair_instructions(&mut request, 2, &pinned_state, &[], PrevRound::Transport);
let pinned = format!(
"\n{frozen_header}\
- 0: \"G\" [agents 0, contradiction: false] — e.g. Agent 0: first (id 0)\n\
\n{remainder_header}\
- 1: Agent 1: second [pinned — accepted contradiction reference; do NOT place in a group]\n"
);
assert_eq!(
request.messages.last().unwrap().content,
format!(
"{prefix}{framing}{lead_in}{pinned}\n{tail}",
framing = REPAIR_FRAMING_FALLBACK[0]
),
"pinned-marker: marker text or newline drift"
);
let mut all_frozen = RepairState::new(&items);
let outcome = process_round(
RoundInput {
summary: Some("s".to_string()),
groups: vec![
GroupingGroup {
heading: "G".to_string(),
contradiction: false,
members: vec![GroupingMember { id: 0 }],
},
GroupingGroup {
heading: "G1".to_string(),
contradiction: false,
members: vec![GroupingMember { id: 1 }],
},
],
ungrouped: Vec::new(),
references: Vec::new(),
},
&mut all_frozen,
);
assert_eq!(outcome.froze, 2);
assert!(all_frozen.remainder().is_empty());
let mut request = crate::providers::test_request(vec![crate::ChatMessage::user("")], None);
append_repair_instructions(&mut request, 2, &all_frozen, &[], PrevRound::Transport);
let empty_remainder = format!(
"\n{frozen_header}\
- 0: \"G\" [agents 0, contradiction: false] — e.g. Agent 0: first (id 0)\n\
- 1: \"G1\" [agents 1, contradiction: false] — e.g. Agent 1: second (id 1)\n\
\n{remainder_header}"
);
assert_eq!(
request.messages.last().unwrap().content,
format!(
"{prefix}{framing}{lead_in}{empty_remainder}\n{tail}",
framing = REPAIR_FRAMING_FALLBACK[0]
),
"empty-remainder: must not gain a blank line before Respond"
);
}
#[test]
fn repair_framing_mapping_is_pinned() {
let rejections =
vec!["item 1 missing from every proposed group and the ungrouped list".to_string()];
let variants: [(PrevRound, &[String], &str); 5] = [
(PrevRound::Transport, &[], "transport"),
(PrevRound::Parse, &[], "parsed"),
(PrevRound::Processed { accepted: false }, &[], "rejected"),
(
PrevRound::Processed { accepted: true },
&[],
"still need placement",
),
(
PrevRound::Processed { accepted: true },
&rejections,
"partially",
),
];
let mut seen = HashSet::new();
for (prev, outstanding, keyword) in variants {
let framing = repair_framing(prev, outstanding);
assert!(
framing.contains(keyword),
"framing for {prev:?} lost its disposition keyword {keyword:?}"
);
assert!(
seen.insert(framing),
"framings must be pairwise distinct for {prev:?}"
);
}
}
}