use crate::agent::{Agent, Conversation, RunContext};
use crate::mcp::McpClient;
use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
pub struct LensedSearch {
client: Arc<McpClient>,
label: String,
sources: Vec<String>,
since: String,
until: String,
description: String,
}
impl LensedSearch {
pub fn new(
client: Arc<McpClient>,
label: &str,
sources: Vec<String>,
since: &str,
until: &str,
) -> Self {
let description = format!(
"Search the user's knowledge graph. You can see ONLY these sources: {}. \
Evidence is limited to {since}..{until}. Another assistant is reading \
different sources and can see things you cannot.",
sources.join(", ")
);
LensedSearch {
client,
label: label.to_string(),
sources,
since: since.to_string(),
until: until.to_string(),
description,
}
}
pub fn lens_schema() -> Value {
json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to look for"},
"k": {"type": "integer", "description": "Max results (default 10)"}
},
"required": ["query"]
})
}
pub fn lens_capabilities() -> Capabilities {
Capabilities {
private_data: true,
untrusted_input: true,
external_send: false,
destructive: false,
}
}
}
#[async_trait]
impl Tool for LensedSearch {
fn name(&self) -> &str {
"kg_search"
}
fn description(&self) -> &str {
&self.description
}
fn input_schema(&self) -> Value {
Self::lens_schema()
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Self::lens_capabilities()
}
async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
let Some(query) = input.get("query").and_then(Value::as_str) else {
return Ok(ToolOutput::err("missing required string argument `query`"));
};
let args = json!({
"query": query,
"k": input.get("k").and_then(Value::as_u64).unwrap_or(10),
"include_private": true,
"sources": self.sources,
"since": self.since,
"until": self.until,
"scope": "evidence_only",
"probe": true,
});
let mut out = self.client.call_tool("kg_search", args).await?;
out.content = format!("[{} view]\n{}", self.label, out.content);
Ok(out)
}
}
pub struct GraphTool {
client: Arc<McpClient>,
name: String,
description: String,
schema: Value,
}
impl GraphTool {
pub fn verify(client: Arc<McpClient>) -> Self {
GraphTool {
client,
name: "kg_verify".into(),
description: "Check what the graph BELIEVES against what its evidence \
actually says — deterministic, no model in the loop. Give a `node` \
(name or id) for every live claim about it. Verdicts include \
supported, contradicted, denied, stale, residue, unrooted."
.into(),
schema: json!({
"type": "object",
"properties": {
"node": {"type": "string", "description": "Entity name, alias or id"},
"fact": {"type": "string", "description": "A single fact uid"},
"limit": {"type": "integer"}
}
}),
}
}
pub fn entity(client: Arc<McpClient>) -> Self {
GraphTool {
client,
name: "kg_entity".into(),
description: "Look up an entity's record: node id, aliases, identifiers \
(emails, Slack ids), which sources cover it, interaction count and \
when it was last seen. Use this for claims about the graph's own \
bookkeeping rather than about events."
.into(),
schema: json!({
"type": "object",
"properties": {
"name_or_id": {"type": "string", "description": "Entity name, alias or id"}
},
"required": ["name_or_id"]
}),
}
}
pub fn search_everything(client: Arc<McpClient>) -> Self {
GraphTool {
client,
name: "kg_search".into(),
description: "Search the whole knowledge graph — every source, no time \
limit, facts as well as evidence. Use it to find whether anything \
actually supports a claim."
.into(),
schema: json!({
"type": "object",
"properties": {
"query": {"type": "string"},
"k": {"type": "integer", "description": "Max results (default 10)"}
},
"required": ["query"]
}),
}
}
}
#[async_trait]
impl Tool for GraphTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn input_schema(&self) -> Value {
self.schema.clone()
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities {
private_data: true,
untrusted_input: true,
external_send: false,
destructive: false,
}
}
async fn call(&self, mut input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
if self.name == "kg_search" {
if let Some(o) = input.as_object_mut() {
o.insert("include_private".into(), json!(true));
}
}
self.client.call_tool(&self.name, input).await
}
}
pub fn family(source: &str) -> &'static str {
match source {
s if s.starts_with("bee.") => "spoken",
"reflect.note" => "reflected.note",
"reflect.daily" => "reflected.daily",
s if s.starts_with("reflect.") => "reflected",
s if s.starts_with("session.") || s.starts_with("agent:") => "agentic",
"calendar.event" => "scheduled",
"slack.thread" | "mbox" | "email.thread" => "written",
_ => "other",
}
}
pub fn family_of_origin(origin: &str) -> Option<&'static str> {
if origin.starts_with("agent:") {
return Some(family(origin));
}
match origin.split_once(':') {
Some(("bee", _)) => Some("spoken"),
Some(_) => None,
None if origin == "llm" => None,
None => Some(family(origin)),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SourceCoverage {
pub source: String,
pub episodes: i64,
}
pub fn choose_vantages(coverage: &[SourceCoverage], min: i64) -> Option<(Vantage, Vantage)> {
let mut viable: Vec<&SourceCoverage> = coverage.iter().filter(|c| c.episodes >= min).collect();
viable.sort_by_key(|c| -c.episodes);
let first = *viable.first()?;
let second = viable
.iter()
.find(|c| family(&c.source) != family(&first.source))
.copied()
.or_else(|| viable.get(1).copied())?;
Some((
Vantage {
label: family(&first.source).into(),
sources: vec![first.source.clone()],
},
Vantage {
label: family(&second.source).into(),
sources: vec![second.source.clone()],
},
))
}
pub async fn coverage(
client: &McpClient,
entity: &str,
) -> Result<(String, Vec<SourceCoverage>, Vec<String>)> {
let out = client
.call_tool("kg_entity", json!({ "name_or_id": entity }))
.await
.context("kg_entity")?;
let body: Value = serde_json::from_str(&out.content)
.with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
let names = cands
.iter()
.map(|c| format!("{} ({})", c["name"], c["id"]))
.collect();
return Ok((String::new(), vec![], names));
}
anyhow::ensure!(
body["found"] != json!(false),
"no entity matching '{entity}'"
);
let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
let sources: Vec<SourceCoverage> =
serde_json::from_value(body["sources"].clone()).unwrap_or_default();
Ok((name, sources, vec![]))
}
pub async fn coverage_best(
client: &McpClient,
entity: &str,
) -> Result<(String, Vec<SourceCoverage>, bool)> {
let ask = |q: String| async move {
let out = client
.call_tool("kg_entity", json!({ "name_or_id": q }))
.await?;
let body: Value = serde_json::from_str(&out.content)
.with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
anyhow::Ok(body)
};
let body = ask(entity.to_string()).await?;
if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
let Some(best) = cands.iter().max_by_key(|c| {
c.get("interaction_count")
.and_then(Value::as_i64)
.unwrap_or(0)
}) else {
return Ok((String::new(), vec![], true));
};
let Some(id) = best["id"].as_str().filter(|s| !s.is_empty()) else {
return Ok((String::new(), vec![], true));
};
let body = ask(id.to_string()).await?;
if body["found"] == json!(false) {
return Ok((String::new(), vec![], true));
}
let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
return Ok((name, sources, true));
}
if body["found"] == json!(false) {
return Ok((String::new(), vec![], false));
}
let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
Ok((name, sources, false))
}
pub async fn windowed_coverage(
client: &McpClient,
entity: &str,
sources: &[SourceCoverage],
since: &str,
until: &str,
) -> Result<Vec<SourceCoverage>> {
let mut out = Vec::new();
for c in sources {
let res = client
.call_tool(
"kg_search",
json!({
"query": entity, "k": 25, "include_private": true,
"scope": "evidence_only", "sources": [c.source.clone()],
"since": since, "until": until,
}),
)
.await?;
let body: Value = serde_json::from_str(&res.content).unwrap_or_else(|_| json!({}));
let n = body["items"].as_array().map(|a| a.len()).unwrap_or(0) as i64;
if n > 0 {
out.push(SourceCoverage {
source: c.source.clone(),
episodes: n,
});
}
}
Ok(out)
}
pub fn asker(
provider: Box<dyn crate::provider::Provider>,
tool_ctx: ToolCtx,
agent_cfg: crate::config::AgentConfig,
model: Option<String>,
) -> Result<Agent> {
let approver = Arc::new(crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
});
let mut cfg = agent_cfg;
cfg.system_prompt = Some(FOLLOWUP_SYS.to_string());
let mut agent = Agent::new(
provider,
crate::tool::Registry::new(),
approver,
tool_ctx,
cfg,
model,
)?;
agent.set_cache_contended();
Ok(agent)
}
pub fn extractor(
provider: Box<dyn crate::provider::Provider>,
tool_ctx: ToolCtx,
agent_cfg: crate::config::AgentConfig,
model: Option<String>,
) -> Result<Agent> {
let approver = Arc::new(crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
});
let mut cfg = agent_cfg;
cfg.system_prompt = Some(EXTRACT_SYS.to_string());
let mut agent = Agent::new(
provider,
crate::tool::Registry::new(),
approver,
tool_ctx,
cfg,
model,
)?;
agent.set_cache_contended();
Ok(agent)
}
pub fn verifier(
provider: Box<dyn crate::provider::Provider>,
client: Arc<McpClient>,
tool_ctx: ToolCtx,
agent_cfg: crate::config::AgentConfig,
model: Option<String>,
) -> Result<Agent> {
let approver = Arc::new(crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
});
let mut registry = crate::tool::Registry::new();
registry.insert(Arc::new(GraphTool::verify(Arc::clone(&client))));
registry.insert(Arc::new(GraphTool::entity(Arc::clone(&client))));
registry.insert(Arc::new(GraphTool::search_everything(client)));
let mut cfg = agent_cfg;
cfg.system_prompt = Some(VERIFY_SYS.to_string());
let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
agent.set_cache_contended();
Ok(agent)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vantage {
pub label: String,
pub sources: Vec<String>,
}
pub struct ReaderSetup {
pub client: Arc<McpClient>,
pub vantage: Vantage,
pub since: String,
pub until: String,
pub tool_ctx: ToolCtx,
pub agent_cfg: crate::config::AgentConfig,
pub model: Option<String>,
pub system_prompt: String,
}
pub fn reader(provider: Box<dyn crate::provider::Provider>, setup: ReaderSetup) -> Result<Agent> {
let ReaderSetup {
client,
vantage,
since,
until,
tool_ctx,
agent_cfg,
model,
system_prompt,
} = setup;
let mut registry = crate::tool::Registry::new();
registry.insert(Arc::new(LensedSearch::new(
client,
&vantage.label,
vantage.sources.clone(),
&since,
&until,
)) as Arc<dyn Tool>);
let approver = Arc::new(crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
});
let mut cfg = agent_cfg;
cfg.system_prompt = Some(system_prompt);
let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
agent.set_cache_contended();
Ok(agent)
}
#[derive(Debug, Clone, Serialize)]
pub struct Round {
pub n: u32,
pub asked: Vec<(String, String)>,
pub answered: Vec<(String, String)>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stalled: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Exchange {
pub entity: String,
pub vantages: Vec<Vantage>,
pub rounds: Vec<Round>,
}
pub const ANSWER_SYS: &str = "\
You and another assistant are each reading DIFFERENT sources about one \
person, comparing notes. Search your sources and answer from what you find.
You are talking to the other assistant, not to a user. Never address the \
user, never offer to look something up, never ask what they need — there is \
nobody there to answer, and an offer is a wasted turn.
At most three sentences. Say plainly and briefly when your sources do not \
cover it: the other assistant may see what you cannot, and 'my sources show \
nothing about that' is a real contribution. Report only what you read — do \
not speculate, and do not pad a thin answer by listing what you would need \
in order to answer.";
pub const FOLLOWUP_SYS: &str = "\
You and another assistant each read DIFFERENT sources about one person, so \
each of you can see things the other cannot.
You have both just answered and you can see their answer. Ask ONE question \
that THEIR sources might answer and yours cannot — aim at what they seem to \
have seen and you did not. Prefer relationships, roles, commitments and the \
reasons behind things over dates and logistics. If their sources turned up \
nothing, ask instead about something yours hinted at and could not settle.
Output the question and nothing else: one interrogative sentence. Do not \
answer it yourself, do not summarise what was said, do not explain your \
reasoning. You have no tools and nothing to look up — the question IS your \
whole output.";
pub async fn exchange(
answerers: &[(Vantage, Agent)],
askers: &[(Vantage, Agent)],
cx: &RunContext,
entity: &str,
seed: &str,
rounds: u32,
) -> Result<Exchange> {
anyhow::ensure!(
answerers.len() == 2,
"gossip is a pair; got {}",
answerers.len()
);
anyhow::ensure!(askers.len() == 2, "one asker per reader");
let agents = answerers;
let mut questions: Vec<String> = vec![seed.to_string(), seed.to_string()];
let mut out = Exchange {
entity: entity.to_string(),
vantages: agents.iter().map(|(v, _)| v.clone()).collect(),
rounds: vec![],
};
let mut said: Vec<Vec<(String, String)>> = vec![vec![], vec![]];
let mut stalled: Vec<(String, String)> = vec![];
for n in 1..=rounds {
let mut answers = Vec::new();
for (i, (vantage, agent)) in agents.iter().enumerate() {
let mut prior = String::new();
for (q, a) in &said[i] {
prior.push_str(&format!("\nEarlier you were asked: {q}\nYou said: {a}\n"));
}
let mut convo = Conversation::user(format!(
"The person is {entity}.{prior}\nThe other assistant asks you: {}",
questions[i]
));
let outcome = agent
.run_in(cx, &mut convo, None)
.await
.with_context(|| format!("{} reader, round {n}", vantage.label))?;
let answer = strip_user_directed(outcome.text.trim());
said[i].push((questions[i].clone(), answer.clone()));
answers.push(answer);
}
out.rounds.push(Round {
stalled: std::mem::take(&mut stalled),
n,
asked: agents
.iter()
.enumerate()
.map(|(i, (v, _))| (v.label.clone(), questions[i].clone()))
.collect(),
answered: agents
.iter()
.enumerate()
.map(|(i, (v, _))| (v.label.clone(), answers[i].clone()))
.collect(),
});
if n == rounds {
break;
}
let mut next = questions.clone();
for (i, (vantage, _)) in agents.iter().enumerate() {
let other = 1 - i;
let brief = |s: &String| -> String { s.chars().take(700).collect() };
let reveal = format!(
"The person is {entity}.\n\nYou read: {}\nYou answered: {}\n\n\
They read: {}\nThey answered: {}\n\n\
Now ask them ONE question. Do not summarise either answer. \
Your entire output is a single sentence ending in a question \
mark.",
vantage.sources.join(", "),
brief(&answers[i]),
agents[other].0.sources.join(", "),
brief(&answers[other]),
);
let mut convo = Conversation::user(reveal);
let mut outcome = askers[i].1.run_in(cx, &mut convo, None).await?;
if usable_question(&outcome.text).is_none() {
let mut bare = Conversation::user(format!(
"They said this about {entity}: {}\n\n\
Ask them one question about it. Output only the question.",
brief(&answers[other]),
));
outcome = askers[i].1.run_in(cx, &mut bare, None).await?;
}
match usable_question(&outcome.text) {
Some(q) => next[other] = q,
None => stalled.push((
agents[other].0.label.clone(),
outcome.text.trim().chars().take(300).collect(),
)),
}
}
questions = next;
}
Ok(out)
}
pub fn strip_user_directed(text: &str) -> String {
let serves_a_user = |l: &str| {
let lower = l.to_lowercase();
l.ends_with('?')
|| lower.starts_with("let me know")
|| lower.starts_with("would you")
|| lower.starts_with("if you'd like")
|| lower.starts_with("i can look")
|| lower.starts_with("i can dig")
};
let mut lines: Vec<&str> = text.lines().collect();
while let Some(last) = lines.last() {
let t = last.trim().trim_start_matches(['*', '-', '#', '>', ' ']);
if t.is_empty() || serves_a_user(t) {
lines.pop();
} else {
break;
}
}
let cut = lines.join("\n").trim().to_string();
if cut.is_empty() {
"(no answer — the reader only offered to look things up)".to_string()
} else {
cut
}
}
fn elicits_a_preference(line: &str) -> bool {
let l = line.to_lowercase();
[
"interested in",
"would you like",
"do you want",
"should i",
"can i help",
"what would you",
"how can i",
"anything else",
]
.iter()
.any(|p| l.contains(p))
}
pub fn usable_question(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|l| {
l.ends_with('?')
&& l.len() > 10
&& !l.contains("tool:")
&& !l.contains("args:")
&& !l.starts_with('{')
&& !elicits_a_preference(l)
})
.map(|l| l.trim_start_matches(['*', '-', '#', ' ']).to_string())
}
pub const EXTRACT_SYS: &str = "\
You are given a transcript in which two assistants discussed one person. \
List the factual claims they made about that person or about the graph's \
records of them.
One claim per line, each a single short sentence that stands on its own — \
resolve pronouns and back-references so a line can be checked without the \
transcript. Include claims you suspect are wrong; judging them is not your \
job. Exclude questions, hedges about what a source failed to contain, and \
statements about the assistants themselves.
Output only the list. No numbering, no headings, no commentary.";
pub const VERIFY_SYS: &str = "\
You check one claim against a knowledge graph. You can see everything: all \
sources, all time, facts as well as evidence.
Use kg_search to look for evidence, and kg_verify to see what the graph \
already believes about an entity and whether its own evidence holds up.
Then answer in exactly this form, two lines:
VERDICT: supported | unsupported | contradicted
BASIS: one sentence, naming what you found
'supported' means you found evidence that actually says this. 'contradicted' \
means the graph or its evidence says otherwise. 'unsupported' means you \
looked and found nothing either way — which is the verdict for anything the \
assistant knew from outside the graph, however true it may be in the world. \
Absence of evidence is 'unsupported', never 'contradicted'.";
#[derive(Debug, Clone, Serialize)]
pub struct ClaimVerdict {
pub claim: String,
pub verdict: String,
pub basis: String,
}
pub fn parse_verdict(text: &str) -> (String, String) {
const WORDS: [&str; 3] = ["supported", "unsupported", "contradicted"];
let word_at = |s: &str| -> Option<String> {
let v = s.trim().to_lowercase();
let head = v
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())?;
WORDS.contains(&head).then(|| head.to_string())
};
let mut verdict = String::new();
let mut basis = String::new();
for line in text.lines() {
let l = line
.trim()
.trim_start_matches(['*', '-', '#', '>', ' '])
.trim_matches(['*', '`', ' '])
.to_string();
let upper = l.to_uppercase();
if upper.starts_with("VERDICT:") {
if let Some(w) = word_at(&l[8..]) {
verdict = w;
}
} else if upper.starts_with("BASIS:") {
basis = l[6..].trim().to_string();
} else if verdict.is_empty() && WORDS.contains(&l.to_lowercase().as_str()) {
verdict = l.to_lowercase();
}
}
if verdict.is_empty() {
let said: String = text.trim().chars().take(200).collect();
return (
"unchecked".into(),
if said.is_empty() {
"the adjudicator said nothing at all".into()
} else {
format!("not in form; it said: {}", said.replace('\n', " "))
},
);
}
(verdict, basis)
}
pub fn claim_lines(text: &str, max: usize) -> Vec<String> {
let is_intent = |l: &str| {
let lower = l.to_lowercase();
lower.contains("search_query")
|| lower.contains("tool:")
|| lower.starts_with("i will ")
|| lower.starts_with("i'll ")
|| lower.starts_with("let me ")
|| lower.starts_with("i need to ")
|| lower.starts_with("first, i")
};
text.lines()
.map(|l| {
l.trim()
.trim_start_matches(['*', '-', '#', '•', ' '])
.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')')
.trim()
.to_string()
})
.filter(|l| l.len() > 15 && !l.ends_with(':') && !l.ends_with('?') && !is_intent(l))
.take(max)
.collect()
}
pub async fn graph_findings(client: &McpClient, entity: &str) -> Result<String> {
let out = client
.call_tool("kg_verify", json!({ "node": entity, "limit": 20 }))
.await
.context("kg_verify")?;
Ok(out.content)
}
pub async fn audit(
extractor: &Agent,
verifier: &Agent,
cx: &RunContext,
exchange: &Exchange,
max_claims: usize,
) -> Result<Vec<ClaimVerdict>> {
let mut convo = Conversation::user(format!(
"The person is {}.\n\n{}\n\n\
Now list the factual claims made about {} in the transcript above. \
One per line. Do not search, do not comment, do not explain — you \
have no tools and the list is your whole output.",
exchange.entity,
render(exchange),
exchange.entity,
));
let listed = extractor
.run_in(cx, &mut convo, None)
.await
.context("extracting claims from the exchange")?;
let mut out = Vec::new();
for claim in claim_lines(&listed.text, max_claims) {
let mut convo = Conversation::user(format!(
"The person is {}.\n\nClaim to check: {claim}\n\n\
Search first, then reply with exactly two lines:\n\
VERDICT: supported | unsupported | contradicted\n\
BASIS: one sentence naming what you found",
exchange.entity
));
let res = verifier
.run_in(cx, &mut convo, None)
.await
.with_context(|| format!("checking claim: {claim}"))?;
let (verdict, basis) = parse_verdict(&res.text);
out.push(ClaimVerdict {
claim,
verdict,
basis,
});
}
Ok(out)
}
pub fn render_audit(verdicts: &[ClaimVerdict]) -> String {
let rank = |v: &str| match v {
"contradicted" => 0,
"unsupported" => 1,
"unchecked" => 2,
_ => 3,
};
let mut sorted: Vec<&ClaimVerdict> = verdicts.iter().collect();
sorted.sort_by_key(|c| rank(&c.verdict));
let mut s = String::from("\nAudit\n");
for c in &sorted {
s.push_str(&format!(
" [{}] {}\n {}\n",
c.verdict, c.claim, c.basis
));
}
let n = |v: &str| verdicts.iter().filter(|c| c.verdict == v).count();
s.push_str(&format!(
" — {} claim(s): {} supported, {} unsupported, {} contradicted, {} unchecked\n",
verdicts.len(),
n("supported"),
n("unsupported"),
n("contradicted"),
n("unchecked"),
));
s
}
pub fn render(x: &Exchange) -> String {
let mut s = format!("Gossip about {} \n", x.entity);
for v in &x.vantages {
s.push_str(&format!(" {} reads: {}\n", v.label, v.sources.join(", ")));
}
for r in &x.rounds {
s.push_str(&format!("\nRound {}\n", r.n));
for ((who, q), (_, a)) in r.asked.iter().zip(r.answered.iter()) {
if let Some((_, raw)) = r.stalled.iter().find(|(l, _)| l == who) {
let raw = if raw.is_empty() {
"(nothing at all)".to_string()
} else {
raw.replace('\n', " ")
};
s.push_str(&format!(
" ! {who}'s asker produced no question. It emitted: {raw}\n"
));
}
s.push_str(&format!(" {who} was asked: {q}\n {who} said: {a}\n"));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn cov(pairs: &[(&str, i64)]) -> Vec<SourceCoverage> {
pairs
.iter()
.map(|(s, n)| SourceCoverage {
source: s.to_string(),
episodes: *n,
})
.collect()
}
#[test]
fn an_offer_to_the_user_never_reaches_the_other_reader() {
let answered = "Slack shows he ran a hyperscanning practice with Rutgers.\n\
His birthday is May 28.\n\n\
Would you like me to dig deeper into one of these workstreams?";
let cut = strip_user_directed(answered);
assert!(cut.ends_with("His birthday is May 28."));
assert!(!cut.contains("dig deeper"));
assert!(cut.contains("hyperscanning"));
assert_eq!(
strip_user_directed("My sources show nothing about that."),
"My sources show nothing about that."
);
assert!(strip_user_directed("Would you like me to search?").starts_with("(no answer"));
}
#[test]
fn a_verdict_is_computed_from_two_sightings_not_asked_for() {
use Sighting::*;
assert_eq!(corroboration_verdict(Seen, Seen), "corroborated");
assert_eq!(corroboration_verdict(Seen, Unseen), "single_source");
assert_eq!(corroboration_verdict(Unseen, Seen), "single_source");
assert_eq!(corroboration_verdict(Unseen, Unseen), "unseen");
assert_eq!(corroboration_verdict(Seen, Contradicted), "contradicted");
assert_eq!(corroboration_verdict(Contradicted, Seen), "contradicted");
assert_eq!(corroboration_verdict(Seen, Unclear), "unclear");
assert_eq!(corroboration_verdict(Unclear, Unseen), "unclear");
}
#[test]
fn a_sighting_is_parsed_or_admitted() {
let (s, cite) = parse_sighting("SIGHTING: SEEN\nCITE: slack #random, 2026-05-28");
assert_eq!(s, Sighting::Seen);
assert_eq!(cite, "slack #random, 2026-05-28");
assert_eq!(
parse_sighting("SIGHTING: UNSEEN\nCITE: nothing").0,
Sighting::Unseen
);
assert_eq!(
parse_sighting("**SIGHTING:** CONTRADICTED").0,
Sighting::Contradicted
);
assert_eq!(parse_sighting("UNSEEN").0, Sighting::Unseen);
let (s, basis) = parse_sighting("I have not seen anything like this claim.");
assert_eq!(s, Sighting::Unclear);
assert!(
basis.contains("I have not seen"),
"the rejected text is kept"
);
}
#[test]
fn corroboration_never_reads_the_source_it_came_from() {
let spread = cov(&[
("bee.conversation", 40),
("bee.daily", 35),
("slack.thread", 30),
("reflect.daily", 20),
]);
let (a, b) = vantages_excluding(&spread, Some("bee.conversation"), 3).unwrap();
for v in [&a, &b] {
assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
}
let (a, b) = vantages_excluding(&spread, Some("bee:suggested"), 3).unwrap();
for v in [&a, &b] {
assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
}
let only = cov(&[("bee.conversation", 40), ("bee.daily", 9)]);
assert!(vantages_excluding(&only, Some("bee:suggested"), 3).is_none());
}
#[test]
fn every_origin_bars_its_own_family_not_a_reconstruction() {
let spread = cov(&[
("slack.thread", 40),
("bee.conversation", 30),
("calendar.event", 20),
]);
for origin in ["slack.thread", "mbox", "email.thread"] {
let (a, b) = vantages_excluding(&spread, Some(origin), 3).unwrap();
for v in [&a, &b] {
assert!(
!v.sources.iter().any(|s| s == "slack.thread"),
"origin {origin} left its own family eligible: {v:?}"
);
}
}
let (a, b) = vantages_excluding(&spread, Some("calendar.event"), 3).unwrap();
for v in [&a, &b] {
assert!(!v.sources.iter().any(|s| s == "calendar.event"), "{v:?}");
}
assert!(vantages_excluding(&spread, Some("llm:commitment"), 3).is_none());
assert!(vantages_excluding(&spread, Some("llm"), 3).is_none());
assert_eq!(family_of_origin("agent:mecha"), Some("agentic"));
}
#[test]
fn an_unparseable_verdict_is_never_guessed() {
assert_eq!(
parse_verdict("VERDICT: contradicted\nBASIS: the graph lists one node."),
("contradicted".into(), "the graph lists one node.".into())
);
let (v, basis) = parse_verdict("I think this is probably supported by the Slack thread.");
assert_eq!(v, "unchecked");
assert!(basis.contains("I think this is probably supported"));
assert_eq!(parse_verdict("supported").0, "supported");
assert_eq!(parse_verdict("**VERDICT:** contradicted").0, "contradicted");
assert_eq!(
parse_verdict("Verdict: unsupported\nBasis: nothing found").0,
"unsupported"
);
assert_eq!(parse_verdict("").0, "unchecked");
assert_eq!(
parse_verdict("VERDICT: mostly true\nBASIS: x").0,
"unchecked"
);
}
#[test]
fn claim_extraction_drops_scaffolding() {
let listed = "**Claims:**\n\
1. Luke J Chang works at Dartmouth.\n\
- py-feat is a tool for fNIRS analysis.\n\
Is he the lab PI?\n\
short\n\
He maintains the /home/ljchang/Git directory.";
let claims = claim_lines(listed, 8);
assert_eq!(claims.len(), 3, "got {claims:?}");
assert!(claims[0].starts_with("Luke J Chang works"));
assert!(
!claims.iter().any(|c| c.ends_with('?')),
"questions are not claims"
);
assert!(!claims.iter().any(|c| c.contains("Claims:")));
assert_eq!(claim_lines(listed, 2).len(), 2);
let intent = "I will search the knowledge graph for the Slack handle U034F8HLM7S.\n\
search_query: ljchang@email.arizona.edu\n\
Let me check whether the two entities are distinct.\n\
Luke J Chang presented a poster on April 19, 2026.";
let claims = claim_lines(intent, 8);
assert_eq!(
claims,
vec!["Luke J Chang presented a poster on April 19, 2026."]
);
}
#[test]
fn a_question_must_probe_sources_not_preferences() {
assert_eq!(
usable_question(
"What specific aspect of Luke J Chang's work or background \
are you most interested in?"
),
None
);
assert_eq!(usable_question("Would you like me to dig deeper?"), None);
for q in [
"Are you referring to the Luke J Chang associated with the Chang \
lab at Dartmouth and the 'py-feat' paper?",
"Can you confirm if Luke J Chang is associated with the Chang lab?",
] {
assert!(usable_question(q).is_some(), "rejected a real probe: {q}");
}
}
#[test]
fn a_non_question_never_propagates() {
assert_eq!(
usable_question("tool:kg_search\nargs:{\"query\": \"ljchang\"}"),
None
);
assert_eq!(
usable_question("Based on my searches, here is what I found:"),
None
);
assert_eq!(usable_question(""), None);
assert_eq!(
usable_question("ok?"),
None,
"too short to be a real question"
);
assert_eq!(
usable_question("Who does she collaborate with on the grant?").as_deref(),
Some("Who does she collaborate with on the grant?")
);
assert_eq!(
usable_question("Some preamble.\n- What role does he hold in the lab?").as_deref(),
Some("What role does he hold in the lab?"),
"the question is found past preamble and stripped of its bullet"
);
}
#[test]
fn vantages_prefer_independence_over_volume() {
let c = cov(&[("slack.thread", 400), ("mbox", 300), ("calendar.event", 50)]);
let (a, b) = choose_vantages(&c, 3).unwrap();
assert_eq!(a.sources, vec!["slack.thread"]);
assert_eq!(
b.sources,
vec!["calendar.event"],
"a second family beats a bigger sibling"
);
assert_ne!(a.label, b.label);
}
#[test]
fn a_thin_source_is_not_a_witness() {
let c = cov(&[("slack.thread", 493), ("bee.conversation", 2)]);
assert!(
choose_vantages(&c, 3).is_none(),
"one witness and a silence is not a pair"
);
assert!(choose_vantages(&c, 2).is_some());
}
#[test]
fn same_family_is_better_than_no_pair() {
let c = cov(&[("slack.thread", 40), ("mbox", 30)]);
let (a, b) = choose_vantages(&c, 3).unwrap();
assert_eq!(
(a.sources[0].as_str(), b.sources[0].as_str()),
("slack.thread", "mbox")
);
}
#[test]
fn families_split_the_kinds_of_account_apart() {
assert_eq!(family("bee.conversation"), family("bee.daily"));
assert_ne!(family("calendar.event"), family("slack.thread"));
assert_ne!(family("reflect.note"), family("bee.conversation"));
assert_ne!(family("reflect.note"), family("reflect.daily"));
}
#[test]
fn lensed_search_hides_what_it_pins() {
let schema = LensedSearch::lens_schema();
let props = schema["properties"].as_object().unwrap();
for pinned in ["sources", "since", "until", "include_private", "scope"] {
assert!(
!props.contains_key(pinned),
"{pinned} must not be nameable by the child"
);
}
}
#[test]
fn the_readonly_approver_blocks_rather_than_asks() {
struct Probe {
ro: bool,
}
#[async_trait]
impl Tool for Probe {
fn name(&self) -> &str {
"probe"
}
fn description(&self) -> &str {
"test probe"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn read_only(&self) -> bool {
self.ro
}
async fn call(&self, _input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
Ok(ToolOutput::ok("ok"))
}
}
use crate::tool::Approver as _;
let a = crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
};
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let read = rt.block_on(a.approve(&Probe { ro: true }, &json!({})));
assert!(matches!(read, crate::tool::Decision::Allow));
let write = rt.block_on(a.approve(&Probe { ro: false }, &json!({})));
assert!(matches!(write, crate::tool::Decision::Blocked(_)));
}
#[test]
fn a_reader_declares_private_and_untrusted_but_never_send() {
let caps = LensedSearch::lens_capabilities();
assert!(caps.private_data && caps.untrusted_input);
assert!(
!caps.external_send,
"a gossip reader with a way to send is the leak the interlock exists for"
);
assert!(!caps.destructive);
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Candidate {
pub candidate_id: i64,
pub statement: String,
#[serde(default)]
pub subject: Option<String>,
#[serde(default)]
pub origin_source: Option<String>,
#[serde(default)]
pub subject_ambiguous: bool,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub predicate: Option<String>,
#[serde(default)]
pub evidence: Option<EvidenceClip>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceClip {
pub source: String,
#[serde(default)]
pub occurred_at: String,
pub body: String,
}
pub async fn pending_about(
client: &McpClient,
entity: &str,
limit: usize,
unjudged_by: Option<&str>,
include_evidence: bool,
) -> Result<Vec<Candidate>> {
let out = client
.call_tool(
"kg_pending",
json!({
"entity": entity,
"limit": limit,
"unjudged_by": unjudged_by,
"include_evidence": include_evidence,
}),
)
.await
.context("kg_pending")?;
let body: Value = serde_json::from_str(&out.content)
.with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
if let Some(e) = body.get("error").and_then(Value::as_str) {
anyhow::bail!("kg_pending: {e}");
}
Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
}
pub async fn pending(
client: &McpClient,
proposed_by: &str,
predicate: &str,
limit: usize,
unjudged_by: Option<&str>,
include_evidence: bool,
) -> Result<Vec<Candidate>> {
let out = client
.call_tool(
"kg_pending",
json!({
"proposed_by": proposed_by,
"predicate": predicate,
"limit": limit,
"unjudged_by": unjudged_by,
"include_evidence": include_evidence,
}),
)
.await
.context("kg_pending")?;
let body: Value = serde_json::from_str(&out.content)
.with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
if let Some(e) = body.get("error").and_then(Value::as_str) {
anyhow::bail!("kg_pending: {e}");
}
Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
}
pub async fn file_verdict(
client: &McpClient,
candidate_id: i64,
mechanism: &str,
verdict: &str,
basis: &str,
model: Option<&str>,
) -> Result<()> {
client
.call_tool(
"kg_verdict",
json!({
"candidate_id": candidate_id, "mechanism": mechanism,
"verdict": verdict, "basis": basis, "model": model,
}),
)
.await
.context("kg_verdict")?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Sighting {
Seen,
Unseen,
Contradicted,
Unclear,
}
pub fn parse_sighting(text: &str) -> (Sighting, String) {
let mut sighting = None;
let mut basis = String::new();
for line in text.lines() {
let l = line
.trim()
.trim_start_matches(['*', '-', '#', '>', ' '])
.trim_matches(['*', '`', ' '])
.to_string();
let upper = l.to_uppercase();
let body = upper.strip_prefix("SIGHTING:").map(str::trim);
let word = body.unwrap_or(&upper);
if (body.is_some() || sighting.is_none()) && sighting.is_none() {
let head = word
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())
.unwrap_or_default();
if body.is_some() || word.trim() == head {
sighting = match head {
"SEEN" => Some(Sighting::Seen),
"UNSEEN" => Some(Sighting::Unseen),
"CONTRADICTED" => Some(Sighting::Contradicted),
_ => None,
};
}
}
if let Some(rest) = l.strip_prefix("CITE:").or(l.strip_prefix("Cite:")) {
basis = rest.trim().to_string();
}
}
match sighting {
Some(s) => (s, basis),
None => (
Sighting::Unclear,
format!("not in form; it said: {}", {
let t: String = text.trim().chars().take(160).collect();
t.replace('\n', " ")
}),
),
}
}
pub fn corroboration_verdict(a: Sighting, b: Sighting) -> &'static str {
use Sighting::*;
match (a, b) {
(Contradicted, _) | (_, Contradicted) => "contradicted",
(Seen, Seen) => "corroborated",
(Seen, Unseen) | (Unseen, Seen) => "single_source",
(Unseen, Unseen) => "unseen",
_ => "unclear",
}
}
pub const SIGHT_SYS: &str = "\
You are checking whether a claim about a person shows up in YOUR sources. \
Another assistant is checking DIFFERENT sources.
The claim came from somewhere else entirely; your job is not to judge \
whether it sounds right, but whether your own evidence shows it. Search, \
then answer. 'UNSEEN' is the honest and expected answer for most claims, \
and it is a real contribution — a generalisation drawn from one \
conversation and visible nowhere else is exactly what needs finding.
Reply in exactly this form, two lines:
SIGHTING: SEEN | UNSEEN | CONTRADICTED
CITE: what you found, or 'nothing' — quote or name the episode
CONTRADICTED means your evidence shows the opposite, not merely that it is \
absent. Absence is UNSEEN.";
#[derive(Debug, Clone, Serialize)]
pub struct Corroboration {
pub candidate_id: i64,
pub statement: String,
pub verdict: &'static str,
pub sightings: Vec<(String, Sighting, String)>,
pub rechecked: bool,
pub pre_reveal: Option<(String, Sighting, String)>,
}
pub async fn corroborate(
readers: &[(Vantage, Agent)],
cx: &RunContext,
cand: &Candidate,
) -> Result<Corroboration> {
anyhow::ensure!(readers.len() == 2, "corroboration is a pair");
let ask = format!(
"Claim to check against your sources:\n\n{}\n\n\
Search your sources, then reply with exactly two lines:\n\
SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
CITE: what you found, or 'nothing'",
cand.statement
);
let mut found = Vec::new();
for (v, agent) in readers {
let mut convo = Conversation::user(ask.clone());
let out = agent
.run_in(cx, &mut convo, None)
.await
.with_context(|| format!("{} reader on candidate {}", v.label, cand.candidate_id))?;
let (s, basis) = parse_sighting(&out.text);
found.push((format!("{} [{}]", v.label, v.sources.join(",")), s, basis));
}
let mut rechecked = false;
let mut pre_reveal = None;
let seen_at = found.iter().position(|(_, s, _)| *s == Sighting::Seen);
let unseen_at = found.iter().position(|(_, s, _)| *s == Sighting::Unseen);
if let (Some(hit), Some(miss)) = (seen_at, unseen_at) {
rechecked = true;
pre_reveal = Some(found[miss].clone());
let mut convo = Conversation::user(format!(
"Claim: {}\n\nAnother assistant, reading {}, found this:\n{}\n\n\
Search YOUR sources once more with that in mind. Do not take \
their word for it — report only what your own evidence shows.\n\
SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
CITE: what you found, or 'nothing'",
cand.statement,
readers[hit].0.sources.join(", "),
found[hit].2,
));
let out = readers[miss].1.run_in(cx, &mut convo, None).await?;
let (s, basis) = parse_sighting(&out.text);
found[miss] = (
format!(
"{} [{}]",
readers[miss].0.label,
readers[miss].0.sources.join(",")
),
s,
basis,
);
}
Ok(Corroboration {
candidate_id: cand.candidate_id,
statement: cand.statement.clone(),
verdict: corroboration_verdict(found[0].1, found[1].1),
sightings: found,
rechecked,
pre_reveal,
})
}
pub fn vantages_excluding(
coverage: &[SourceCoverage],
origin: Option<&str>,
min: i64,
) -> Option<(Vantage, Vantage)> {
let barred = match origin {
Some(o) => Some(family_of_origin(o)?),
None => None,
};
let kept: Vec<SourceCoverage> = coverage
.iter()
.filter(|c| barred != Some(family(&c.source)))
.cloned()
.collect();
choose_vantages(&kept, min)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Vet {
Supported,
Unsupported,
Misattributed,
Overreach,
Mistyped,
Unclear,
}
impl Vet {
pub fn as_str(self) -> &'static str {
match self {
Vet::Supported => "supported",
Vet::Unsupported => "unsupported",
Vet::Misattributed => "misattributed",
Vet::Overreach => "overreach",
Vet::Mistyped => "mistyped",
Vet::Unclear => "unclear",
}
}
}
pub const VET_SYS: &str = "\
You judge whether a piece of evidence supports a claim that was extracted \
from it. Only the evidence in front of you counts — no outside knowledge, \
no guessing at what other conversations might show. You will be told the \
exact reply form; keep to it.";
pub fn vet_judge(
provider: Box<dyn crate::provider::Provider>,
tool_ctx: ToolCtx,
agent_cfg: crate::config::AgentConfig,
model: Option<String>,
) -> Result<Agent> {
let approver = Arc::new(crate::tool::ModeApprover {
mode: crate::config::PermissionMode::ReadOnly,
});
let mut cfg = agent_cfg;
cfg.system_prompt = Some(VET_SYS.to_string());
Agent::new(
provider,
crate::tool::Registry::new(),
approver,
tool_ctx,
cfg,
model,
)
}
pub fn vet_question(cand: &Candidate, ev: &EvidenceClip) -> String {
format!(
"Evidence — one episode from {}, {}:\n\n---\n{}\n---\n\n\
Claim extracted from that evidence:\n\n {}\n{}{}\n\
Judge whether THIS evidence supports THAT claim. Absence from the \
evidence is UNSUPPORTED even if the claim sounds plausible. If the \
evidence shows the statement but credits it to a different person \
than the claim's subject, that is MISATTRIBUTED. If the evidence \
shows a weaker or narrower version, that is OVERREACH. If the \
evidence supports the content but the relation label mislabels it \
— a one-time event filed as a durable property, or simply the \
wrong relation — that is MISTYPED.\n\n\
Reply in exactly this form:\n\
VERDICT: SUPPORTED | UNSUPPORTED | MISATTRIBUTED | OVERREACH | MISTYPED\n\
WHO: only for MISATTRIBUTED — who the evidence actually shows\n\
PREDICATE: only for MISTYPED — a better relation name, lowercase_with_underscores\n\
QUOTE: the evidence line that decides it, or 'nothing'",
ev.source,
ev.occurred_at,
ev.body,
cand.statement,
cand.subject
.as_deref()
.map(|s| format!(" (subject: {s})\n"))
.unwrap_or_default(),
cand.predicate
.as_deref()
.map(|p| format!(" (relation label: {p})\n"))
.unwrap_or_default(),
)
}
pub fn parse_vet(text: &str) -> (Vet, Option<String>, Option<String>, String) {
let mut verdict = None;
let mut who = None;
let mut predicate = None;
let mut quote = String::new();
for line in text.lines() {
let l = line
.trim()
.trim_start_matches(['*', '-', '#', '>', ' '])
.trim_matches(['*', '`', ' '])
.to_string();
let upper = l.to_uppercase();
let body = upper.strip_prefix("VERDICT:").map(str::trim);
let word = body.unwrap_or(&upper);
if verdict.is_none() {
let head = word
.split(|c: char| !c.is_ascii_alphabetic())
.find(|w| !w.is_empty())
.unwrap_or_default();
if body.is_some() || word.trim() == head {
verdict = match head {
"SUPPORTED" => Some(Vet::Supported),
"UNSUPPORTED" => Some(Vet::Unsupported),
"MISATTRIBUTED" => Some(Vet::Misattributed),
"OVERREACH" => Some(Vet::Overreach),
"MISTYPED" => Some(Vet::Mistyped),
_ => None,
};
}
}
if let Some(rest) = l.strip_prefix("WHO:").or(l.strip_prefix("Who:")) {
let w = rest.trim();
if !w.is_empty() && !w.eq_ignore_ascii_case("n/a") {
who = Some(w.to_string());
}
}
if let Some(rest) = l
.strip_prefix("PREDICATE:")
.or(l.strip_prefix("Predicate:"))
{
let p = rest
.trim()
.trim_matches('`')
.to_lowercase()
.replace(' ', "_");
if !p.is_empty() && p != "n/a" {
predicate = Some(p);
}
}
if let Some(rest) = l.strip_prefix("QUOTE:").or(l.strip_prefix("Quote:")) {
quote = rest.trim().to_string();
}
}
match verdict {
Some(v) => (v, who, predicate, quote),
None => (
Vet::Unclear,
None,
None,
format!("not in form; it said: {}", {
let t: String = text.trim().chars().take(160).collect();
t.replace('\n', " ")
}),
),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Vetting {
pub candidate_id: i64,
pub statement: String,
pub verdict: Vet,
pub who: Option<String>,
pub predicate: Option<String>,
pub quote: String,
}
pub async fn vet(agent: &Agent, cx: &RunContext, cand: &Candidate) -> Result<Vetting> {
let ev = cand
.evidence
.as_ref()
.context("candidate has no origin evidence to vet against")?;
let mut convo = Conversation::user(vet_question(cand, ev));
let out = agent
.run_in(cx, &mut convo, None)
.await
.with_context(|| format!("vet judge on candidate {}", cand.candidate_id))?;
let (verdict, who, predicate, quote) = parse_vet(&out.text);
Ok(Vetting {
candidate_id: cand.candidate_id,
statement: cand.statement.clone(),
verdict,
who,
predicate,
quote,
})
}
#[cfg(test)]
mod vet_tests {
use super::*;
#[test]
fn a_vet_verdict_is_parsed_or_admitted() {
let (v, who, _, quote) =
parse_vet("VERDICT: MISATTRIBUTED\nWHO: Eunice\nQUOTE: Eunice said she prefers DIY.");
assert_eq!(v, Vet::Misattributed);
assert_eq!(who.as_deref(), Some("Eunice"));
assert!(quote.contains("prefers DIY"));
let (v, _, predicate, _) =
parse_vet("VERDICT: MISTYPED\nPREDICATE: cared for\nQUOTE: was caring for the twins");
assert_eq!(v, Vet::Mistyped);
assert_eq!(predicate.as_deref(), Some("cared_for"));
assert_eq!(
parse_vet("SUPPORTED").0,
Vet::Supported,
"a bare word alone is a format"
);
assert_eq!(parse_vet("**VERDICT:** OVERREACH").0, Vet::Overreach);
let (v, _, _, quote) = parse_vet("I believe this is supported by the transcript.");
assert_eq!(v, Vet::Unclear);
assert!(quote.contains("I believe"), "the rejected text is kept");
}
#[test]
fn the_question_puts_the_imperative_last() {
let cand = Candidate {
candidate_id: 1,
statement: "Luke prefers DIY.".into(),
subject: Some("Luke J Chang".into()),
origin_source: None,
subject_ambiguous: false,
confidence: None,
predicate: Some("related_to".into()),
evidence: Some(EvidenceClip {
source: "bee.conversation".into(),
occurred_at: "2026-08-01".into(),
body: "a long transcript".into(),
}),
};
let q = vet_question(&cand, cand.evidence.as_ref().unwrap());
let ev_at = q.find("a long transcript").unwrap();
let claim_at = q.find("Luke prefers DIY.").unwrap();
let form_at = q.rfind("VERDICT:").unwrap();
assert!(ev_at < claim_at && claim_at < form_at);
}
}