#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CiteSpan {
pub start: usize,
pub end: usize,
pub text: String,
}
impl CiteSpan {
pub fn utf16_range(&self, source: &str) -> (usize, usize) {
let start = source[..self.start].encode_utf16().count();
(
start,
start + source[self.start..self.end].encode_utf16().count(),
)
}
pub fn char_range(&self, source: &str) -> (usize, usize) {
let start = source[..self.start].chars().count();
(start, start + source[self.start..self.end].chars().count())
}
}
pub fn resolve_cite(source: &str, cite: &str) -> Option<CiteSpan> {
for cand in candidates(cite) {
if cand.is_empty() {
continue;
}
if let Some(idx) = source.find(&cand) {
return Some(span_of(source, idx, idx + cand.len()));
}
if let Some(span) = find_normalized(source, &cand) {
return Some(span);
}
}
None
}
pub fn cite_resolves(source: &str, cite: &str) -> bool {
resolve_cite(source, cite).is_some()
}
fn span_of(source: &str, start: usize, end: usize) -> CiteSpan {
let (start, end) = trim_emphasis_edges(source, start, end);
CiteSpan {
start,
end,
text: source[start..end].to_string(),
}
}
fn trim_emphasis_edges(source: &str, mut start: usize, mut end: usize) -> (usize, usize) {
let bytes = source.as_bytes();
while start < end && bytes[start] == b'*' {
start += 1;
}
while end > start && bytes[end - 1] == b'*' {
end -= 1;
}
(start, end)
}
fn candidates(cite: &str) -> Vec<String> {
let mut out = Vec::new();
let push = |s: &str, out: &mut Vec<String>| {
let t = s.trim().to_string();
if !t.is_empty() && !out.contains(&t) {
out.push(t);
}
};
let trimmed = cite.trim();
push(trimmed, &mut out);
let deblocked: String = trimmed
.lines()
.map(|l| {
l.trim_start()
.trim_start_matches('>')
.trim_start_matches(['-', '*', '•'])
.trim_start_matches(|c: char| c.is_ascii_digit())
.trim_start_matches(['.', ')'])
.trim()
})
.collect::<Vec<_>>()
.join(" ");
push(&deblocked, &mut out);
for sep in [": ", " — ", " - ", " – "] {
if let Some(pos) = deblocked.find(sep) {
push(&deblocked[pos + sep.len()..], &mut out);
}
}
let snapshot = out.clone();
for s in &snapshot {
if let Some(inner) = strip_wrapping_quotes(s) {
push(inner, &mut out);
}
}
out
}
fn strip_wrapping_quotes(s: &str) -> Option<&str> {
const PAIRS: &[(char, char)] = &[
('"', '"'),
('\'', '\''),
('\u{201c}', '\u{201d}'),
('\u{2018}', '\u{2019}'),
('\u{ab}', '\u{bb}'),
('`', '`'),
];
let first = s.chars().next()?;
let last = s.chars().next_back()?;
for &(o, c) in PAIRS {
if first == o && last == c && s.chars().count() >= 2 {
return Some(&s[o.len_utf8()..s.len() - c.len_utf8()]);
}
}
None
}
fn emphasis_runs(s: &str) -> Vec<(usize, usize)> {
let bytes = s.as_bytes();
let mut runs = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'*' {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && bytes[i] == b'*' {
i += 1;
}
let open_side_blank = start == 0
|| s[..start]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
let close_side_blank =
i == bytes.len() || s[i..].chars().next().is_some_and(char::is_whitespace);
if !(open_side_blank && close_side_blank) {
runs.push((start, i));
}
}
runs
}
fn normalize(s: &str) -> (String, Vec<usize>) {
let drops = emphasis_runs(s);
let mut out = String::with_capacity(s.len());
let mut map: Vec<usize> = Vec::with_capacity(s.len());
let mut prev_ws = false;
let mut drop_idx = 0usize;
for (i, ch) in s.char_indices() {
while drop_idx < drops.len() && drops[drop_idx].1 <= i {
drop_idx += 1;
}
if drop_idx < drops.len() && i >= drops[drop_idx].0 && i < drops[drop_idx].1 {
continue;
}
if ch.is_whitespace() {
if !prev_ws && !out.is_empty() {
out.push(' ');
map.push(i);
}
prev_ws = true;
continue;
}
for folded in ch.to_lowercase() {
let before = out.len();
out.push(folded);
for _ in before..out.len() {
map.push(i);
}
}
prev_ws = false;
}
if out.ends_with(' ') {
out.pop();
map.pop();
}
(out, map)
}
fn find_normalized(source: &str, cand: &str) -> Option<CiteSpan> {
let (src_norm, map) = normalize(source);
let (cand_norm, _) = normalize(cand);
if cand_norm.is_empty() {
return None;
}
let idx = src_norm.find(&cand_norm)?;
let start = *map.get(idx)?;
let last = *map.get(idx + cand_norm.len() - 1)?;
let end = last
+ source[last..]
.chars()
.next()
.map_or(0, |c| c.len_utf8())
.min(source.len() - last);
Some(span_of(source, start, end))
}
pub const REQUOTE_BUDGET: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroundingPolicy {
Reject,
Repair,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedCite {
pub target_id: String,
pub cite: String,
}
impl std::fmt::Display for UnresolvedCite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, " • [{}] {:?}", self.target_id, self.cite)
}
}
pub trait GroundableClaim {
fn cite(&self) -> &str;
fn claim_id(&self) -> Option<&str>;
fn set_resolved(&mut self, text: String, anchor: super::ClaimAnchor);
fn set_claim_id(&mut self, id: String);
}
impl GroundableClaim for super::ClaimAssessment {
fn cite(&self) -> &str {
&self.claim
}
fn claim_id(&self) -> Option<&str> {
self.claim_id.as_deref()
}
fn set_resolved(&mut self, text: String, anchor: super::ClaimAnchor) {
self.claim = text;
self.anchor = Some(anchor);
}
fn set_claim_id(&mut self, id: String) {
self.claim_id = Some(id);
}
}
impl GroundableClaim for super::mcp_tools::McpClaimAssessment {
fn cite(&self) -> &str {
&self.claim
}
fn claim_id(&self) -> Option<&str> {
self.claim_id.as_deref()
}
fn set_resolved(&mut self, text: String, anchor: super::ClaimAnchor) {
self.claim = text;
self.anchor = Some(anchor);
}
fn set_claim_id(&mut self, id: String) {
self.claim_id = Some(id);
}
}
pub trait Groundable {
type Claim: GroundableClaim;
fn target_id(&self) -> &str;
fn claims_mut(&mut self) -> &mut Vec<Self::Claim>;
}
impl Groundable for (String, super::Evaluation) {
type Claim = super::ClaimAssessment;
fn target_id(&self) -> &str {
&self.0
}
fn claims_mut(&mut self) -> &mut Vec<Self::Claim> {
&mut self.1.claim_assessments
}
}
impl Groundable for super::nsed_agent::BatchEvaluationItem {
type Claim = super::ClaimAssessment;
fn target_id(&self) -> &str {
&self.agent_id
}
fn claims_mut(&mut self) -> &mut Vec<Self::Claim> {
&mut self.claim_assessments
}
}
impl Groundable for super::mcp_tools::EvaluationItem {
type Claim = super::mcp_tools::McpClaimAssessment;
fn target_id(&self) -> &str {
&self.target_id
}
fn claims_mut(&mut self) -> &mut Vec<Self::Claim> {
&mut self.claim_assessments
}
}
pub fn ground_all<E: Groundable>(
candidates: &[super::CandidateProposal],
round: u32,
evaluations: &mut [E],
policy: GroundingPolicy,
) -> Vec<UnresolvedCite> {
let corpus_by_id: std::collections::HashMap<&str, (&str, String)> = candidates
.iter()
.map(|c| {
let thoughts_shown: String = c
.proposal
.thought_process
.chars()
.take(crate::prompts::defaults::EVAL_THOUGHT_LIMIT)
.collect();
(c.id.as_str(), (c.proposal.content.as_str(), thoughts_shown))
})
.collect();
let mut unresolved = Vec::new();
for e in evaluations.iter_mut() {
let Some((content, thoughts)) = corpus_by_id.get(e.target_id()) else {
continue;
};
let target_id = e.target_id().to_string();
let mut missed_at = Vec::new();
for (idx, ca) in e.claims_mut().iter_mut().enumerate() {
if ca.cite().trim().is_empty() {
if ca.claim_id().map(str::trim).unwrap_or("").is_empty() {
missed_at.push((idx, String::new()));
}
continue;
}
let cite = ca.cite();
let resolved = resolve_cite(content, cite)
.map(|span| {
let (start_utf16, end_utf16) = span.utf16_range(content);
(
span.text,
super::ClaimAnchor::AnswerBody {
start_utf16,
end_utf16,
},
)
})
.or_else(|| {
resolve_cite(thoughts, cite)
.map(|span| (span.text, super::ClaimAnchor::ThoughtWindow))
});
match resolved {
Some((text, anchor)) => {
if ca.claim_id().map(str::trim).unwrap_or("").is_empty() {
ca.set_claim_id(claim_fingerprint(&target_id, &text, round));
}
ca.set_resolved(text, anchor);
}
None => missed_at.push((idx, cite.to_string())),
}
}
if missed_at.is_empty() {
continue;
}
if policy == GroundingPolicy::Reject {
let bad: std::collections::HashSet<usize> = missed_at.iter().map(|(i, _)| *i).collect();
let mut idx = 0;
e.claims_mut().retain(|_| {
let keep = !bad.contains(&idx);
idx += 1;
keep
});
}
unresolved.extend(missed_at.into_iter().map(|(_, cite)| UnresolvedCite {
target_id: target_id.clone(),
cite,
}));
}
unresolved
}
fn claim_fingerprint(target_id: &str, claim: &str, round: u32) -> String {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
let mut eat = |bytes: &[u8]| {
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(PRIME);
}
};
eat(target_id.as_bytes());
eat(b"\x1f");
eat(claim.as_bytes());
eat(b"\x1f");
eat(&round.to_le_bytes());
format!("{:06x}", hash & 0xff_ffff)
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str =
"The algorithm sorts in O(n log n) time and is stable.\nIt uses a merge step.";
#[test]
fn exact_substring_resolves_to_itself() {
let span = resolve_cite(SOURCE, "sorts in O(n log n) time").unwrap();
assert_eq!(span.text, "sorts in O(n log n) time");
assert_eq!(&SOURCE[span.start..span.end], span.text);
}
#[test]
fn strips_common_quote_wrappers() {
for c in [
"\"sorts in O(n log n) time\"",
"\u{201c}sorts in O(n log n) time\u{201d}",
"`sorts in O(n log n) time`",
"'sorts in O(n log n) time'",
"\u{ab}sorts in O(n log n) time\u{bb}",
"> sorts in O(n log n) time",
"- sorts in O(n log n) time",
"Claim: \"sorts in O(n log n) time\"",
] {
assert_eq!(
resolve_cite(SOURCE, c).map(|s| s.text).as_deref(),
Some("sorts in O(n log n) time"),
"cite {c:?} should resolve"
);
}
}
#[test]
fn collapses_whitespace_runs_and_newlines() {
assert_eq!(
resolve_cite(SOURCE, "is stable. It uses a merge step.")
.map(|s| s.text)
.as_deref(),
Some("is stable.\nIt uses a merge step.")
);
}
#[test]
fn resolves_through_markdown_emphasis_on_either_side() {
let src = "The algorithm **sorts in O(n log n) time** and is stable.";
let span = resolve_cite(src, "sorts in O(n log n) time").unwrap();
assert_eq!(span.text, "sorts in O(n log n) time");
assert_eq!(&src[span.start..span.end], span.text);
assert_eq!(
resolve_cite(SOURCE, "**sorts in O(n log n) time**")
.map(|s| s.text)
.as_deref(),
Some("sorts in O(n log n) time")
);
assert_eq!(
resolve_cite(SOURCE, "*sorts* in O(n log n) time")
.map(|s| s.text)
.as_deref(),
Some("sorts in O(n log n) time")
);
}
#[test]
fn resolves_case_insensitively() {
let src = "No international tribunal has formally declared it.";
assert_eq!(
resolve_cite(src, "no international tribunal has formally declared it.")
.map(|s| s.text)
.as_deref(),
Some("No international tribunal has formally declared it.")
);
}
#[test]
fn returns_the_original_span_not_the_normalised_form() {
let src = "It holds that **No Tribunal** has ruled.";
let a = resolve_cite(src, "no tribunal").expect("lowercase cite resolves");
let b = resolve_cite(src, "**No Tribunal**").expect("emphasised cite resolves");
assert_eq!(a.text, "No Tribunal", "original casing, markup excluded");
assert_eq!(a, b, "differently decorated cites converge on one span");
assert_eq!(&src[a.start..a.end], a.text);
assert_eq!(
resolve_cite(src, "has ruled.").map(|s| s.text).as_deref(),
Some("has ruled.")
);
}
#[test]
fn does_not_strip_underscores_or_spaced_asterisks() {
assert!(!cite_resolves("the claimid field", "the claim_id field"));
assert!(!cite_resolves("compute a b here", "compute a * b here"));
assert_eq!(
resolve_cite("the claim_id field", "the claim_id field")
.map(|s| s.text)
.as_deref(),
Some("the claim_id field")
);
assert_eq!(
resolve_cite("compute a * b here", "compute a * b here")
.map(|s| s.text)
.as_deref(),
Some("compute a * b here")
);
}
#[test]
fn ellipsis_elision_does_not_resolve() {
assert!(!cite_resolves(
"From a normative perspective, the pattern of documented conduct provides \
the evidentiary basis for the claim.",
"From a normative perspective, the pattern ... provides the evidentiary basis \
for the claim."
));
}
#[test]
fn fabricated_cite_does_not_resolve() {
assert!(!cite_resolves(SOURCE, "runs in constant time"));
assert!(!cite_resolves(SOURCE, "\"quantum entanglement\""));
assert!(resolve_cite(SOURCE, "").is_none());
assert!(resolve_cite(SOURCE, " ").is_none());
}
#[test]
fn span_ending_on_a_multibyte_char_is_a_char_boundary() {
let span = resolve_cite("x café", "x café").unwrap();
assert_eq!(span.text, "x café");
let span = resolve_cite("value is 5\u{20ac}\ndone", "value is 5\u{20ac} done").unwrap();
assert_eq!(span.text, "value is 5\u{20ac}\ndone");
}
#[test]
fn offsets_convert_to_utf16_and_char_units() {
let src = "a war\u{2011}criminal state was alleged";
let span = resolve_cite(src, "state was alleged").unwrap();
assert_eq!(&src[span.start..span.end], "state was alleged");
assert_eq!(span.start, 17, "byte offset");
assert_eq!(
span.utf16_range(src),
(15, 32),
"UTF-16 start trails the byte start by the 2 extra bytes of U+2011"
);
assert_eq!(span.char_range(src), (15, 32));
assert_eq!(src.len(), 32 + 2);
}
}
#[cfg(test)]
mod grounding_tests {
use super::*;
use crate::agents::{CandidateProposal, ClaimAssessment, ClaimVerdict, Evaluation, Proposal};
const CONTENT: &str = "The algorithm sorts in O(n log n) time and is stable.";
const THOUGHTS: &str = "I considered a quicksort but the pivot is adversarial.";
fn candidates() -> Vec<CandidateProposal> {
vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
thought_process: THOUGHTS.to_string(),
content: CONTENT.to_string(),
..Default::default()
},
}]
}
fn claim(text: &str) -> ClaimAssessment {
ClaimAssessment {
claim: text.to_string(),
verdict: ClaimVerdict::Verified,
..Default::default()
}
}
fn native_eval(claims: Vec<ClaimAssessment>) -> (String, Evaluation) {
(
"Candidate_A".to_string(),
Evaluation {
claim_assessments: claims,
..Default::default()
},
)
}
#[test]
fn native_unresolvable_cite_is_reported() {
let cands = candidates();
let mut evals = vec![native_eval(vec![claim(
"no international tribunal has ruled on this",
)])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Repair);
assert_eq!(unresolved.len(), 1, "fabricated cite must be reported");
assert_eq!(unresolved[0].target_id, "Candidate_A");
assert_eq!(
unresolved[0].cite,
"no international tribunal has ruled on this"
);
}
#[test]
fn repair_keeps_the_unresolvable_claim() {
let cands = candidates();
let mut evals = vec![native_eval(vec![
claim("**sorts in O(n log n) time**"),
claim("runs in constant time"),
])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Repair);
let claims = &evals[0].1.claim_assessments;
assert_eq!(claims.len(), 2, "repair never drops a claim");
assert_eq!(
claims[0].claim, "sorts in O(n log n) time",
"resolvable cite is replaced by the exact proposal span"
);
assert_eq!(claims[1].claim, "runs in constant time", "left as written");
assert_eq!(unresolved.len(), 1);
}
#[test]
fn reject_drops_only_the_unresolvable_claim() {
let cands = candidates();
let mut evals = vec![native_eval(vec![
claim("**sorts in O(n log n) time**"),
claim("runs in constant time"),
])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
let claims = &evals[0].1.claim_assessments;
assert_eq!(claims.len(), 1, "only the bad claim is dropped");
assert_eq!(claims[0].claim, "sorts in O(n log n) time");
assert_eq!(unresolved.len(), 1, "retry signal is still raised");
}
#[test]
fn reject_does_not_touch_a_clean_sibling_evaluation() {
let mut cands = candidates();
cands.push(CandidateProposal {
id: "Candidate_B".to_string(),
proposal: Proposal {
content: "A hash join avoids the sort entirely.".to_string(),
..Default::default()
},
});
let mut evals = vec![
native_eval(vec![claim("runs in constant time")]),
(
"Candidate_B".to_string(),
Evaluation {
claim_assessments: vec![claim("A hash join avoids the sort entirely.")],
..Default::default()
},
),
];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert_eq!(unresolved.len(), 1);
assert_eq!(unresolved[0].target_id, "Candidate_A");
assert!(evals[0].1.claim_assessments.is_empty());
assert_eq!(
evals[1].1.claim_assessments.len(),
1,
"clean evaluation survives a sibling's bad cite"
);
}
#[test]
fn policies_agree_on_a_resolvable_cite() {
for policy in [GroundingPolicy::Reject, GroundingPolicy::Repair] {
let cands = candidates();
let mut evals = vec![native_eval(vec![claim("> \"sorts in O(n log n) time\"")])];
let unresolved = ground_all(&cands, 1, &mut evals, policy);
assert!(unresolved.is_empty(), "{policy:?} must resolve this cite");
assert_eq!(
evals[0].1.claim_assessments[0].claim, "sorts in O(n log n) time",
"{policy:?} must produce the exact proposal span"
);
}
}
#[test]
fn grounds_against_the_shown_thought_window() {
let cands = candidates();
let mut evals = vec![native_eval(vec![claim("the pivot is adversarial")])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert!(unresolved.is_empty());
assert_eq!(
evals[0].1.claim_assessments[0].claim,
"the pivot is adversarial"
);
}
#[test]
fn does_not_ground_past_the_thought_window() {
let tail = "the sentinel phrase lives here";
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
thought_process: format!(
"{}{tail}",
"x".repeat(crate::prompts::defaults::EVAL_THOUGHT_LIMIT)
),
content: CONTENT.to_string(),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim(tail)])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Repair);
assert_eq!(
unresolved.len(),
1,
"beyond the shown window must not ground"
);
}
#[test]
fn blank_claim_without_claim_id_is_unresolvable() {
let cands = candidates();
let mut evals = vec![native_eval(vec![claim(" ")])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert_eq!(unresolved.len(), 1, "identity-less claim must be reported");
assert!(evals[0].1.claim_assessments.is_empty());
}
#[test]
fn blank_claim_with_claim_id_is_a_backreference() {
let cands = candidates();
let mut evals = vec![native_eval(vec![ClaimAssessment {
claim_id: Some("a1b2c3".to_string()),
claim: String::new(),
verdict: ClaimVerdict::Contested,
..Default::default()
}])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert!(unresolved.is_empty(), "back-reference is not a bad cite");
assert_eq!(evals[0].1.claim_assessments.len(), 1);
}
fn slice_utf16(content: &str, start: usize, end: usize) -> String {
let units: Vec<u16> = content.encode_utf16().collect();
String::from_utf16(&units[start..end]).expect("anchor slices on a boundary")
}
fn anchor_of(ca: &ClaimAssessment) -> (usize, usize) {
match ca.anchor.as_ref().expect("claim is anchored") {
crate::agents::ClaimAnchor::AnswerBody {
start_utf16,
end_utf16,
} => (*start_utf16, *end_utf16),
other => panic!("expected an answer-body anchor, got {other:?}"),
}
}
#[test]
fn emitted_offsets_slice_to_the_emitted_cite() {
for (content, submitted) in [
(
"The system sorts in O(n log n) time.",
"sorts in O(n log n) time",
),
(
"The system **sorts in O(n log n) time** here.",
"sorts in O(n log n) time",
),
(
"The system sorts in O(n log n) time.",
"**sorts in O(n log n) time**",
),
("No tribunal has ruled on it.", "no tribunal has ruled"),
("It is stable.\nIt merges.", "\"It is stable. It merges.\""),
] {
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
content: content.to_string(),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim(submitted)])];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert!(unresolved.is_empty(), "cite {submitted:?} should resolve");
let ca = &evals[0].1.claim_assessments[0];
let (start, end) = anchor_of(ca);
assert_eq!(
slice_utf16(content, start, end),
ca.claim,
"content sliced by the anchor must equal the emitted cite \
(content {content:?}, submitted {submitted:?})"
);
}
}
#[test]
fn anchor_offsets_are_utf16_not_bytes() {
let content = "A war\u{2011}criminal state was alleged by critics.";
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
content: content.to_string(),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim("state was alleged")])];
assert!(ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject).is_empty());
let ca = &evals[0].1.claim_assessments[0];
let (start, end) = anchor_of(ca);
assert_eq!(slice_utf16(content, start, end), "state was alleged");
let byte_start = content.find("state was alleged").unwrap();
assert_ne!(start, byte_start, "U+2011 makes the two units disagree");
assert_eq!(start, 15, "UTF-16 units");
assert_eq!(byte_start, 17, "bytes — U+2011 costs 2 more");
}
#[test]
fn thought_window_match_carries_no_answer_offsets() {
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
content: "Final answer: 42.".to_string(),
thought_process: "I first considered a merge step.".to_string(),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim("I first considered a merge step.")])];
assert!(ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject).is_empty());
assert_eq!(
evals[0].1.claim_assessments[0].anchor,
Some(crate::agents::ClaimAnchor::ThoughtWindow),
"no offsets, and explicit about where it matched"
);
}
#[test]
fn answer_body_wins_when_a_cite_appears_in_both() {
let shared = "the merge step is stable";
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
content: format!("Final answer: {shared}."),
thought_process: format!("I reasoned that {shared}."),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim(shared)])];
assert!(ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject).is_empty());
anchor_of(&evals[0].1.claim_assessments[0]);
}
#[test]
fn generated_claim_id_is_stable_and_content_derived() {
let run = |round: u32| {
let cands = candidates();
let mut evals = vec![native_eval(vec![claim("sorts in O(n log n) time")])];
ground_all(&cands, round, &mut evals, GroundingPolicy::Reject);
evals[0].1.claim_assessments[0]
.claim_id
.clone()
.expect("an id is generated")
};
let first = run(1);
assert_eq!(first.len(), 6, "6 hex chars");
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(first, run(1), "same inputs, same id");
assert_ne!(first, run(2), "round participates in the identity");
}
#[test]
fn an_existing_claim_id_is_never_overwritten() {
let cands = candidates();
let mut evals = vec![native_eval(vec![ClaimAssessment {
claim_id: Some("deadbe".to_string()),
claim: "sorts in O(n log n) time".to_string(),
verdict: ClaimVerdict::Verified,
..Default::default()
}])];
ground_all(&cands, 3, &mut evals, GroundingPolicy::Reject);
assert_eq!(
evals[0].1.claim_assessments[0].claim_id.as_deref(),
Some("deadbe")
);
}
#[test]
fn anchor_and_claim_id_survive_the_wire_round_trip() {
let content = "A war\u{2011}criminal state was alleged by critics.";
let cands = vec![CandidateProposal {
id: "Candidate_A".to_string(),
proposal: Proposal {
content: content.to_string(),
..Default::default()
},
}];
let mut evals = vec![native_eval(vec![claim("state was alleged")])];
ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
let json = serde_json::to_string(&evals[0].1).expect("Evaluation serializes");
assert!(
json.contains("\"anchor\"") && json.contains("answer_body"),
"the anchor must actually be on the wire: {json}"
);
let back: Evaluation = serde_json::from_str(&json).expect("round trips");
let before = &evals[0].1.claim_assessments[0];
let after = &back.claim_assessments[0];
assert_eq!(after.anchor, before.anchor, "anchor survives");
assert_eq!(after.claim_id, before.claim_id, "claim id survives");
assert_eq!(after.claim, before.claim);
let (start, end) = anchor_of(after);
assert_eq!(
slice_utf16(content, start, end),
after.claim,
"the property still holds after a round trip"
);
}
#[test]
fn an_unanchored_claim_omits_the_field_entirely() {
let eval = Evaluation {
claim_assessments: vec![claim("never grounded")],
..Default::default()
};
let json = serde_json::to_string(&eval).unwrap();
assert!(!json.contains("anchor"), "absent, not null: {json}");
}
#[test]
fn unknown_target_id_is_left_alone() {
let cands = candidates();
let mut evals = vec![(
"Candidate_ZZ".to_string(),
Evaluation {
claim_assessments: vec![claim("runs in constant time")],
..Default::default()
},
)];
let unresolved = ground_all(&cands, 1, &mut evals, GroundingPolicy::Reject);
assert!(unresolved.is_empty());
assert_eq!(evals[0].1.claim_assessments.len(), 1);
}
}