Skip to main content

mecha_core/
gossip.rs

1//! Gossip: two agents, different sources, generative follow-ups.
2//!
3//! The mechanism the knowledge graph's design has always put at its centre,
4//! and the one every cheaper approximation has failed to substitute for.
5//! What is *not* gossip, established by measurement on 2026-08-13: filling a
6//! template, issuing two filtered retrievals and diffing the answers. That
7//! found zero contradictions in 58 probes, because the split was
8//! facts-versus-evidence — a distillation compared against its own origin,
9//! never two witnesses.
10//!
11//! Here the perspectives are **sources**, which are independent: a calendar
12//! entry is a plan, a Bee transcript is what was said, a Slack thread is what
13//! was written. And the claim under test is not that they disagree. It is
14//! that two readers asking *each other* questions surface things no template
15//! names — "why do you know her?" is a move a slot list cannot make.
16//!
17//! ## Why this is Rust and not a prompt
18//!
19//! Commit-then-reveal is the protocol's load-bearing rule: conformity
20//! corrupts answer *formation*, generativity lives in *follow-up*, so the
21//! phases must be kept apart. A parent model told to "ask B without showing
22//! it A's answer" can simply not comply, and nothing would notice. Here B's
23//! context does not contain A's answer because the code has not put it there
24//! yet. The rule is a property of the program, not an instruction.
25//!
26//! ## The capability boundary
27//!
28//! Each child gets exactly one tool: [`LensedSearch`], which is `kg_search`
29//! with its `sources` and time window nailed shut and removed from the
30//! schema. That is deliberate on two counts. It makes the perspective
31//! structural — a child cannot widen its own lens to see what its partner
32//! sees, which would collapse the two witnesses into one. And it keeps the
33//! interlock disarmed: the children read private material, so handing either
34//! of them anything outbound would arm the trifecta. A web query composed
35//! after reading a private transcript *is* the leak, which is why the web is
36//! a route that fills what gossip finds, in a separate untainted session —
37//! never a third participant here.
38//!
39//! ## It cannot ask the user anything
40//!
41//! A gossip run is a background measurement; a question to the owner would
42//! both block it and spend the scarcest budget in the design on a process
43//! nobody is watching. [`reader`] makes that true three ways over, so no
44//! single mistake restores it:
45//!
46//! 1. the registry holds exactly one tool, so `ask_user` and `message_send`
47//!    are not merely forbidden but absent;
48//! 2. that tool is read-only, so it never reaches the approval gate;
49//! 3. the approver is [`ModeApprover`] in `ReadOnly`, which answers from
50//!    policy without asking anyone — anything non-read-only is *blocked*,
51//!    never prompted.
52//!
53//! Asking the owner remains a real channel in the design, with an attention
54//! budget of its own. It is simply not this mechanism's to spend.
55
56use crate::agent::{Agent, Conversation, RunContext};
57use crate::mcp::McpClient;
58use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
59use anyhow::{Context, Result};
60use async_trait::async_trait;
61use serde::{Deserialize, Serialize};
62use serde_json::{json, Value};
63use std::sync::Arc;
64
65/// `kg_search` with the lens welded on.
66///
67/// The child chooses its query and nothing else: `sources`, `since` and
68/// `until` are injected here and absent from the schema it is shown, so
69/// "only look at the calendar" is enforced rather than requested.
70pub struct LensedSearch {
71    client: Arc<McpClient>,
72    label: String,
73    sources: Vec<String>,
74    since: String,
75    until: String,
76    description: String,
77}
78
79impl LensedSearch {
80    pub fn new(
81        client: Arc<McpClient>,
82        label: &str,
83        sources: Vec<String>,
84        since: &str,
85        until: &str,
86    ) -> Self {
87        let description = format!(
88            "Search the user's knowledge graph. You can see ONLY these sources: {}. \
89             Evidence is limited to {since}..{until}. Another assistant is reading \
90             different sources and can see things you cannot.",
91            sources.join(", ")
92        );
93        LensedSearch {
94            client,
95            label: label.to_string(),
96            sources,
97            since: since.to_string(),
98            until: until.to_string(),
99            description,
100        }
101    }
102
103    /// The schema handed to the model. An associated fn so the boundary
104    /// tests exercise THIS — a test asserting on its own copy of the schema
105    /// keeps passing after the production one grows a hole.
106    pub fn lens_schema() -> Value {
107        // No `sources`, no `since`, no `until`: what the model cannot name,
108        // it cannot widen.
109        json!({
110            "type": "object",
111            "properties": {
112                "query": {"type": "string", "description": "What to look for"},
113                "k": {"type": "integer", "description": "Max results (default 10)"}
114            },
115            "required": ["query"]
116        })
117    }
118
119    /// What a reader declares. Same reasoning as [`Self::lens_schema`].
120    pub fn lens_capabilities() -> Capabilities {
121        Capabilities {
122            // The graph holds the user's own life; episodes are private-tier.
123            private_data: true,
124            // Episode bodies are third-party text — a calendar invite title
125            // or a Slack message is written by someone else.
126            untrusted_input: true,
127            external_send: false,
128            destructive: false,
129        }
130    }
131}
132
133#[async_trait]
134impl Tool for LensedSearch {
135    fn name(&self) -> &str {
136        "kg_search"
137    }
138    fn description(&self) -> &str {
139        &self.description
140    }
141    fn input_schema(&self) -> Value {
142        Self::lens_schema()
143    }
144    fn read_only(&self) -> bool {
145        true
146    }
147    fn capabilities(&self) -> Capabilities {
148        Self::lens_capabilities()
149    }
150    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
151        let Some(query) = input.get("query").and_then(Value::as_str) else {
152            return Ok(ToolOutput::err("missing required string argument `query`"));
153        };
154        let args = json!({
155            "query": query,
156            "k": input.get("k").and_then(Value::as_u64).unwrap_or(10),
157            "include_private": true,
158            "sources": self.sources,
159            "since": self.since,
160            "until": self.until,
161            // EVIDENCE ONLY, and load-bearing rather than tidy. pkg's
162            // source and window filters apply to the episode arm; the
163            // facts arm takes neither. Leave scope at its default and both
164            // readers are served the same distilled layer — the very thing
165            // they are supposed to be independent of — so they agree by
166            // construction and cite it back as their own reading. The
167            // first live run did exactly that: both returned "892 shared
168            // episodes, NPMI 1.00" and meetings from 2016 and 2020,
169            // through a 2024+ window, because those are facts.
170            "scope": "evidence_only",
171            // Instrumentation, not interest. pkg's Selector ranks probe
172            // targets by retrieval demand, and a reader's own searches were
173            // feeding that signal: one probe took its target from 2 touches
174            // to 28 and tripled its score, so the Selector elected the same
175            // person again, harder, out of a pool of nine. A probe that
176            // manufactures its own justification is not selecting anything.
177            // Still logged in pkg's query_log — just not counted as demand.
178            "probe": true,
179        });
180        let mut out = self.client.call_tool("kg_search", args).await?;
181        // Label whose view this was, so a transcript read later says which
182        // reader saw what without re-deriving it from the lens config.
183        out.content = format!("[{} view]\n{}", self.label, out.content);
184        Ok(out)
185    }
186}
187
188/// A graph tool handed through unchanged, for the one participant that is
189/// not a witness.
190///
191/// [`LensedSearch`] exists to *narrow*; this exists to refuse to. The
192/// Verifier is the only role allowed the whole graph — every source, the
193/// full window, and the fact layer the readers are deliberately kept away
194/// from — because an adjudicator restricted to one vantage is just a third
195/// witness with opinions about the other two.
196///
197/// It stays read-only and outbound-free like everything else here, so the
198/// wider view costs nothing in interlock terms: it reads more, and it still
199/// has no way to send.
200pub struct GraphTool {
201    client: Arc<McpClient>,
202    name: String,
203    description: String,
204    schema: Value,
205}
206
207impl GraphTool {
208    pub fn verify(client: Arc<McpClient>) -> Self {
209        GraphTool {
210            client,
211            name: "kg_verify".into(),
212            description: "Check what the graph BELIEVES against what its evidence \
213                 actually says — deterministic, no model in the loop. Give a `node` \
214                 (name or id) for every live claim about it. Verdicts include \
215                 supported, contradicted, denied, stale, residue, unrooted."
216                .into(),
217            schema: json!({
218                "type": "object",
219                "properties": {
220                    "node": {"type": "string", "description": "Entity name, alias or id"},
221                    "fact": {"type": "string", "description": "A single fact uid"},
222                    "limit": {"type": "integer"}
223                }
224            }),
225        }
226    }
227
228    /// Entity metadata: node id, aliases, identifiers, per-source coverage,
229    /// interaction count, last seen.
230    ///
231    /// Added because the audit was inconsistent without it. "Luke was last
232    /// seen on August 13" came back supported while "Luke has 1,212 recorded
233    /// interactions" came back unsupported — the same class of claim, judged
234    /// two ways, because both live in entity metadata and the verifier could
235    /// only reach whichever of them happened to surface in a search result.
236    /// An adjudicator that cannot see a field will call a true claim about
237    /// it unsupported, which is the one verdict that must stay trustworthy.
238    pub fn entity(client: Arc<McpClient>) -> Self {
239        GraphTool {
240            client,
241            name: "kg_entity".into(),
242            description: "Look up an entity's record: node id, aliases, identifiers \
243                 (emails, Slack ids), which sources cover it, interaction count and \
244                 when it was last seen. Use this for claims about the graph's own \
245                 bookkeeping rather than about events."
246                .into(),
247            schema: json!({
248                "type": "object",
249                "properties": {
250                    "name_or_id": {"type": "string", "description": "Entity name, alias or id"}
251                },
252                "required": ["name_or_id"]
253            }),
254        }
255    }
256
257    pub fn search_everything(client: Arc<McpClient>) -> Self {
258        GraphTool {
259            client,
260            name: "kg_search".into(),
261            description: "Search the whole knowledge graph — every source, no time \
262                 limit, facts as well as evidence. Use it to find whether anything \
263                 actually supports a claim."
264                .into(),
265            schema: json!({
266                "type": "object",
267                "properties": {
268                    "query": {"type": "string"},
269                    "k": {"type": "integer", "description": "Max results (default 10)"}
270                },
271                "required": ["query"]
272            }),
273        }
274    }
275}
276
277#[async_trait]
278impl Tool for GraphTool {
279    fn name(&self) -> &str {
280        &self.name
281    }
282    fn description(&self) -> &str {
283        &self.description
284    }
285    fn input_schema(&self) -> Value {
286        self.schema.clone()
287    }
288    fn read_only(&self) -> bool {
289        true
290    }
291    fn capabilities(&self) -> Capabilities {
292        Capabilities {
293            private_data: true,
294            untrusted_input: true,
295            external_send: false,
296            destructive: false,
297        }
298    }
299    async fn call(&self, mut input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
300        if self.name == "kg_search" {
301            if let Some(o) = input.as_object_mut() {
302                o.insert("include_private".into(), json!(true));
303            }
304        }
305        self.client.call_tool(&self.name, input).await
306    }
307}
308
309/// Sources that carry the same kind of account, so two vantages drawn from
310/// one family are two slices of one witness rather than two witnesses.
311///
312/// Reflect is split where Bee is not: `bee.daily` is a machine digest of
313/// `bee.conversation` — one witness wearing two labels — while a Reflect
314/// note and a Reflect daily are separately written accounts that happen to
315/// share an app. Collapsed under one name, two runs could read different
316/// shelves and present as one mechanism contradicting itself.
317pub fn family(source: &str) -> &'static str {
318    match source {
319        s if s.starts_with("bee.") => "spoken",
320        "reflect.note" => "reflected.note",
321        "reflect.daily" => "reflected.daily",
322        s if s.starts_with("reflect.") => "reflected",
323        s if s.starts_with("session.") || s.starts_with("agent:") => "agentic",
324        "calendar.event" => "scheduled",
325        "slack.thread" | "mbox" | "email.thread" => "written",
326        _ => "other",
327    }
328}
329
330/// The family a candidate's ORIGIN belongs to — accepting either a full
331/// source name (`bee.conversation`) or a proposer string (`bee:suggested`).
332///
333/// This must not be reconstructed by re-suffixing the origin's head token:
334/// `slack.thread` round-tripped through `"slack."` lands in `other`, which
335/// bars nothing and leaves the origin itself eligible as its own witness.
336/// A proposer names a family only when the tool itself is the source (Bee's
337/// fact API); an extractor proposer (`llm`, `llm:commitment`) says nothing
338/// about where the evidence lived, and pretending otherwise is how a claim
339/// gets corroborated by its own transcript — so those return None.
340pub fn family_of_origin(origin: &str) -> Option<&'static str> {
341    if origin.starts_with("agent:") {
342        return Some(family(origin));
343    }
344    match origin.split_once(':') {
345        Some(("bee", _)) => Some("spoken"),
346        Some(_) => None,
347        None if origin == "llm" => None,
348        None => Some(family(origin)),
349    }
350}
351
352/// What `kg_entity` reports about one source's coverage of an entity.
353#[derive(Debug, Clone, Deserialize)]
354pub struct SourceCoverage {
355    pub source: String,
356    pub episodes: i64,
357}
358
359/// Pick two vantages from what actually covers this entity.
360///
361/// Deliberately NOT a fixed written-versus-spoken split. Coverage is
362/// lopsided in practice — one person in the live graph has 493 Slack
363/// episodes and 2 Bee conversations — and forcing the tidy split hands one
364/// reader almost nothing, which produces a confident "I don't know" that
365/// reads like a finding rather than like an empty shelf.
366///
367/// So: the best-covered source, then the best-covered source from a
368/// DIFFERENT family, falling back to next-best overall when no second
369/// family clears the floor. Two witnesses of the same kind is still better
370/// than one witness and a silence, but the family preference comes first
371/// because independence is the whole point.
372pub fn choose_vantages(coverage: &[SourceCoverage], min: i64) -> Option<(Vantage, Vantage)> {
373    let mut viable: Vec<&SourceCoverage> = coverage.iter().filter(|c| c.episodes >= min).collect();
374    viable.sort_by_key(|c| -c.episodes);
375    let first = *viable.first()?;
376    let second = viable
377        .iter()
378        .find(|c| family(&c.source) != family(&first.source))
379        .copied()
380        .or_else(|| viable.get(1).copied())?;
381    Some((
382        Vantage {
383            label: family(&first.source).into(),
384            sources: vec![first.source.clone()],
385        },
386        Vantage {
387            label: family(&second.source).into(),
388            sources: vec![second.source.clone()],
389        },
390    ))
391}
392
393/// Ask the graph which sources cover an entity. Keeps `call_tool` crate-
394/// private: a front-end should not be reaching into the MCP client to
395/// hand-roll a graph call.
396pub async fn coverage(
397    client: &McpClient,
398    entity: &str,
399) -> Result<(String, Vec<SourceCoverage>, Vec<String>)> {
400    let out = client
401        .call_tool("kg_entity", json!({ "name_or_id": entity }))
402        .await
403        .context("kg_entity")?;
404    let body: Value = serde_json::from_str(&out.content)
405        .with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
406    if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
407        let names = cands
408            .iter()
409            .map(|c| format!("{} ({})", c["name"], c["id"]))
410            .collect();
411        return Ok((String::new(), vec![], names));
412    }
413    anyhow::ensure!(
414        body["found"] != json!(false),
415        "no entity matching '{entity}'"
416    );
417    let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
418    let sources: Vec<SourceCoverage> =
419        serde_json::from_value(body["sources"].clone()).unwrap_or_default();
420    Ok((name, sources, vec![]))
421}
422
423/// Coverage for an entity, resolving an ambiguous name rather than dying on
424/// it.
425///
426/// [`coverage`] reports ambiguity and stops, which is right for an
427/// interactive command and wrong here. A positive control on the
428/// `llm·uses` class — 78% human acceptance, durable properties that plainly
429/// appear in several sources — returned "no witness" for all eight
430/// candidates, because their subject is the bare string "Luke" and the
431/// graph holds two Luke nodes. Coverage came back empty and every claim
432/// about the graph's owner became unjudgeable.
433///
434/// That is the third distinct thing one duplicate identity has broken
435/// today: `pkg dups` could not see the pair, staging dropped the subject
436/// from 189 candidates, and now coverage cannot be measured at all. So this
437/// picks the candidate with the most interactions, re-asks by id, and
438/// returns whether it had to guess. Merging the nodes remains the real fix.
439pub async fn coverage_best(
440    client: &McpClient,
441    entity: &str,
442) -> Result<(String, Vec<SourceCoverage>, bool)> {
443    let ask = |q: String| async move {
444        let out = client
445            .call_tool("kg_entity", json!({ "name_or_id": q }))
446            .await?;
447        let body: Value = serde_json::from_str(&out.content)
448            .with_context(|| format!("kg_entity returned non-JSON: {}", out.content))?;
449        anyhow::Ok(body)
450    };
451
452    let body = ask(entity.to_string()).await?;
453    if let Some(cands) = body.get("ambiguous").and_then(Value::as_array) {
454        // Most interactions wins. Not arbitrary: a name split across a
455        // dominant node and a stub is the commonest shape of a duplicate,
456        // and the dominant node is the one the sources actually cover.
457        let Some(best) = cands.iter().max_by_key(|c| {
458            c.get("interaction_count")
459                .and_then(Value::as_i64)
460                .unwrap_or(0)
461        }) else {
462            return Ok((String::new(), vec![], true));
463        };
464        // `id`, not `node_id`: kg_entity's ambiguity envelope and its
465        // verify counterpart spell this field differently, and reading the
466        // wrong one fails silently — the re-ask got an empty string, found
467        // nothing, and fell back to the very name that was ambiguous, so
468        // the control run reported "measured on 'Luke'" and no coverage.
469        let Some(id) = best["id"].as_str().filter(|s| !s.is_empty()) else {
470            return Ok((String::new(), vec![], true));
471        };
472        let body = ask(id.to_string()).await?;
473        if body["found"] == json!(false) {
474            return Ok((String::new(), vec![], true));
475        }
476        let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
477        let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
478        return Ok((name, sources, true));
479    }
480    if body["found"] == json!(false) {
481        return Ok((String::new(), vec![], false));
482    }
483    let name = body["node"]["name"].as_str().unwrap_or(entity).to_string();
484    let sources = serde_json::from_value(body["sources"].clone()).unwrap_or_default();
485    Ok((name, sources, false))
486}
487
488/// How many episodes each source holds about this entity WITHIN the window.
489///
490/// `kg_entity` reports all-time coverage, and all-time is a different
491/// number: one person here has 493 Slack episodes since 2015 and two since
492/// 2024. Choosing vantages on the all-time figure picked a pair that was
493/// nearly empty in the window actually read, and both readers correctly
494/// reported knowing almost nothing — a null result manufactured by the
495/// selection rather than found in the graph. Ask the question the run will
496/// actually ask.
497pub async fn windowed_coverage(
498    client: &McpClient,
499    entity: &str,
500    sources: &[SourceCoverage],
501    since: &str,
502    until: &str,
503) -> Result<Vec<SourceCoverage>> {
504    let mut out = Vec::new();
505    for c in sources {
506        let res = client
507            .call_tool(
508                "kg_search",
509                json!({
510                    "query": entity, "k": 25, "include_private": true,
511                    "scope": "evidence_only", "sources": [c.source.clone()],
512                    "since": since, "until": until,
513                }),
514            )
515            .await?;
516        let body: Value = serde_json::from_str(&res.content).unwrap_or_else(|_| json!({}));
517        let n = body["items"].as_array().map(|a| a.len()).unwrap_or(0) as i64;
518        if n > 0 {
519            out.push(SourceCoverage {
520                source: c.source.clone(),
521                episodes: n,
522            });
523        }
524    }
525    Ok(out)
526}
527
528/// Build the ASKER half of a pair: no tools at all.
529///
530/// An asker reasons over the two answers it is shown and produces one
531/// question. Give it `kg_search` and it goes researching instead — the
532/// first live run did exactly that, and round 2's "question" was the other
533/// reader's answer pasted back, because a model holding a search tool and
534/// told to ask something will answer instead. Removing the tool is the
535/// difference between a rule and a hope.
536pub fn asker(
537    provider: Box<dyn crate::provider::Provider>,
538    tool_ctx: ToolCtx,
539    agent_cfg: crate::config::AgentConfig,
540    model: Option<String>,
541) -> Result<Agent> {
542    let approver = Arc::new(crate::tool::ModeApprover {
543        mode: crate::config::PermissionMode::ReadOnly,
544    });
545    let mut cfg = agent_cfg;
546    cfg.system_prompt = Some(FOLLOWUP_SYS.to_string());
547    let mut agent = Agent::new(
548        provider,
549        crate::tool::Registry::new(),
550        approver,
551        tool_ctx,
552        cfg,
553        model,
554    )?;
555    agent.set_cache_contended();
556    Ok(agent)
557}
558
559/// Build the claim EXTRACTOR: no tools, for the same reason the asker has
560/// none. An extractor that can search begins checking as it reads, and
561/// returns a filtered list rather than a faithful one — the claims most
562/// worth auditing are exactly the ones it would quietly drop.
563pub fn extractor(
564    provider: Box<dyn crate::provider::Provider>,
565    tool_ctx: ToolCtx,
566    agent_cfg: crate::config::AgentConfig,
567    model: Option<String>,
568) -> Result<Agent> {
569    let approver = Arc::new(crate::tool::ModeApprover {
570        mode: crate::config::PermissionMode::ReadOnly,
571    });
572    let mut cfg = agent_cfg;
573    cfg.system_prompt = Some(EXTRACT_SYS.to_string());
574    let mut agent = Agent::new(
575        provider,
576        crate::tool::Registry::new(),
577        approver,
578        tool_ctx,
579        cfg,
580        model,
581    )?;
582    agent.set_cache_contended();
583    Ok(agent)
584}
585
586/// Build the VERIFIER: the whole graph, and still no way to send.
587///
588/// The one participant deliberately not given a lens. Readers are narrowed
589/// so that their agreement means something; an adjudicator narrowed the
590/// same way would just be a third witness. It gets `kg_verify` — pkg's
591/// deterministic tier, which dereferences a stored claim to the evidence
592/// cited for it with no model in the loop — and an unrestricted
593/// `kg_search`.
594///
595/// The interlock is unchanged by the wider view: both tools are read-only,
596/// the registry holds nothing outbound, and the approver answers from
597/// policy. It reads more and can still tell nobody.
598pub fn verifier(
599    provider: Box<dyn crate::provider::Provider>,
600    client: Arc<McpClient>,
601    tool_ctx: ToolCtx,
602    agent_cfg: crate::config::AgentConfig,
603    model: Option<String>,
604) -> Result<Agent> {
605    let approver = Arc::new(crate::tool::ModeApprover {
606        mode: crate::config::PermissionMode::ReadOnly,
607    });
608    let mut registry = crate::tool::Registry::new();
609    registry.insert(Arc::new(GraphTool::verify(Arc::clone(&client))));
610    registry.insert(Arc::new(GraphTool::entity(Arc::clone(&client))));
611    registry.insert(Arc::new(GraphTool::search_everything(client)));
612    let mut cfg = agent_cfg;
613    cfg.system_prompt = Some(VERIFY_SYS.to_string());
614    let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
615    // The whole ensemble — readers, askers, extractor, verifier — interleaves
616    // turns on one provider, and on a single-slot server (`-np 1`, the
617    // measured right call for unified memory) each participant's request
618    // evicts the others' prefix. The cache lens would read that as the
619    // invariant failure it exists to catch; marking the agents keeps the
620    // verdict and demotes the alarm.
621    agent.set_cache_contended();
622    Ok(agent)
623}
624
625/// One reader's vantage point.
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct Vantage {
628    /// Short name used in the transcript: "written", "spoken".
629    pub label: String,
630    pub sources: Vec<String>,
631}
632
633/// Build one reader: a lens, one tool, and no way to reach anybody.
634///
635/// The only supported way to construct a gossip agent, because the
636/// guarantees are properties of *how it is assembled* — a caller who built
637/// the `Agent` themselves could hand it a mailbox, an outbox route, or an
638/// interactive approver and quietly undo all three.
639pub struct ReaderSetup {
640    pub client: Arc<McpClient>,
641    pub vantage: Vantage,
642    /// Both readers must be given the SAME window, or a difference between
643    /// them is the world having changed rather than the sources disagreeing.
644    pub since: String,
645    pub until: String,
646    pub tool_ctx: ToolCtx,
647    pub agent_cfg: crate::config::AgentConfig,
648    pub model: Option<String>,
649    pub system_prompt: String,
650}
651
652pub fn reader(provider: Box<dyn crate::provider::Provider>, setup: ReaderSetup) -> Result<Agent> {
653    let ReaderSetup {
654        client,
655        vantage,
656        since,
657        until,
658        tool_ctx,
659        agent_cfg,
660        model,
661        system_prompt,
662    } = setup;
663    let mut registry = crate::tool::Registry::new();
664    registry.insert(Arc::new(LensedSearch::new(
665        client,
666        &vantage.label,
667        vantage.sources.clone(),
668        &since,
669        &until,
670    )) as Arc<dyn Tool>);
671
672    // Reads are allowed and nothing else is; a non-read-only tool would be
673    // BLOCKED with a reason rather than raised as a question to a terminal
674    // nobody is sitting at.
675    let approver = Arc::new(crate::tool::ModeApprover {
676        mode: crate::config::PermissionMode::ReadOnly,
677    });
678
679    let mut cfg = agent_cfg;
680    cfg.system_prompt = Some(system_prompt);
681    let mut agent = Agent::new(provider, registry, approver, tool_ctx, cfg, model)?;
682    agent.set_cache_contended();
683    Ok(agent)
684}
685
686/// What one round produced.
687#[derive(Debug, Clone, Serialize)]
688pub struct Round {
689    pub n: u32,
690    /// The question each reader was asked, keyed by vantage label.
691    pub asked: Vec<(String, String)>,
692    /// What each committed, before seeing the other.
693    pub answered: Vec<(String, String)>,
694    /// Readers whose asker produced nothing usable, paired with what it did
695    /// emit. Recorded rather than hidden: a repeated round reads like a
696    /// reader that changed its mind, when in fact the dialogue stalled and
697    /// the orchestration papered over it — the first run with this field
698    /// showed every asker failing in every round, which the transcript had
699    /// been quietly presenting as three rounds of conversation.
700    ///
701    /// The rejected text is kept because "produced no question" is not a
702    /// diagnosis. Empty output, a refusal and a paragraph of prose that
703    /// happens to end in a full stop are three different bugs.
704    #[serde(skip_serializing_if = "Vec::is_empty")]
705    pub stalled: Vec<(String, String)>,
706}
707
708/// The whole exchange about one entity.
709#[derive(Debug, Clone, Serialize)]
710pub struct Exchange {
711    pub entity: String,
712    pub vantages: Vec<Vantage>,
713    pub rounds: Vec<Round>,
714}
715
716/// System prompt for a reader answering from its own sources.
717pub const ANSWER_SYS: &str = "\
718You and another assistant are each reading DIFFERENT sources about one \
719person, comparing notes. Search your sources and answer from what you find.
720
721You are talking to the other assistant, not to a user. Never address the \
722user, never offer to look something up, never ask what they need — there is \
723nobody there to answer, and an offer is a wasted turn.
724
725At most three sentences. Say plainly and briefly when your sources do not \
726cover it: the other assistant may see what you cannot, and 'my sources show \
727nothing about that' is a real contribution. Report only what you read — do \
728not speculate, and do not pad a thin answer by listing what you would need \
729in order to answer.";
730
731/// System prompt for a reader generating a question for the other.
732pub const FOLLOWUP_SYS: &str = "\
733You and another assistant each read DIFFERENT sources about one person, so \
734each of you can see things the other cannot.
735
736You have both just answered and you can see their answer. Ask ONE question \
737that THEIR sources might answer and yours cannot — aim at what they seem to \
738have seen and you did not. Prefer relationships, roles, commitments and the \
739reasons behind things over dates and logistics. If their sources turned up \
740nothing, ask instead about something yours hinted at and could not settle.
741
742Output the question and nothing else: one interrogative sentence. Do not \
743answer it yourself, do not summarise what was said, do not explain your \
744reasoning. You have no tools and nothing to look up — the question IS your \
745whole output.";
746
747/// Run one exchange. Deterministic orchestration: the code decides who is
748/// asked what and when, so commit-then-reveal cannot be skipped.
749///
750/// `ask` is the seed both readers start from. Each subsequent round asks
751/// each reader the question the *other* generated after seeing its answer.
752pub async fn exchange(
753    answerers: &[(Vantage, Agent)],
754    askers: &[(Vantage, Agent)],
755    cx: &RunContext,
756    entity: &str,
757    seed: &str,
758    rounds: u32,
759) -> Result<Exchange> {
760    anyhow::ensure!(
761        answerers.len() == 2,
762        "gossip is a pair; got {}",
763        answerers.len()
764    );
765    anyhow::ensure!(askers.len() == 2, "one asker per reader");
766    let agents = answerers;
767    let mut questions: Vec<String> = vec![seed.to_string(), seed.to_string()];
768    let mut out = Exchange {
769        entity: entity.to_string(),
770        vantages: agents.iter().map(|(v, _)| v.clone()).collect(),
771        rounds: vec![],
772    };
773
774    // What each reader has already said, so a round can build on the last
775    // one. Its OWN answers only. The leak commit-then-reveal exists to
776    // prevent is seeing the other's answer before committing; a reader kept
777    // blind to itself does not hold a conversation, it draws three
778    // independent samples — which is what the third live run produced, the
779    // same reader answering the same seed twice with different facts and no
780    // sign it had noticed.
781    let mut said: Vec<Vec<(String, String)>> = vec![vec![], vec![]];
782    let mut stalled: Vec<(String, String)> = vec![];
783
784    for n in 1..=rounds {
785        // COMMIT. Both answer before either sees the other.
786        let mut answers = Vec::new();
787        for (i, (vantage, agent)) in agents.iter().enumerate() {
788            let mut prior = String::new();
789            for (q, a) in &said[i] {
790                prior.push_str(&format!("\nEarlier you were asked: {q}\nYou said: {a}\n"));
791            }
792            // Framed as a peer speaking, not a user querying an assistant.
793            // "Question:" on its own reads as a user prompt, and the model
794            // answered it as one — bulleted, exhaustive, closing with an
795            // offer of further help.
796            let mut convo = Conversation::user(format!(
797                "The person is {entity}.{prior}\nThe other assistant asks you: {}",
798                questions[i]
799            ));
800            let outcome = agent
801                .run_in(cx, &mut convo, None)
802                .await
803                .with_context(|| format!("{} reader, round {n}", vantage.label))?;
804            let answer = strip_user_directed(outcome.text.trim());
805            said[i].push((questions[i].clone(), answer.clone()));
806            answers.push(answer);
807        }
808
809        out.rounds.push(Round {
810            stalled: std::mem::take(&mut stalled),
811            n,
812            asked: agents
813                .iter()
814                .enumerate()
815                .map(|(i, (v, _))| (v.label.clone(), questions[i].clone()))
816                .collect(),
817            answered: agents
818                .iter()
819                .enumerate()
820                .map(|(i, (v, _))| (v.label.clone(), answers[i].clone()))
821                .collect(),
822        });
823
824        if n == rounds {
825            break;
826        }
827
828        // REVEAL, and only now. Each reader sees the other's answer and asks
829        // it something its own sources cannot settle.
830        let mut next = questions.clone();
831        for (i, (vantage, _)) in agents.iter().enumerate() {
832            let other = 1 - i;
833            // Trimmed, and the imperative goes LAST. A system prompt
834            // followed by two twenty-line answers is overwhelmed by the
835            // shape of its own input: labelled answers read as "summarise
836            // these", and the asker duly returned a consolidated profile
837            // of the person instead of a question. Instruction position
838            // beats instruction strength.
839            let brief = |s: &String| -> String { s.chars().take(700).collect() };
840            let reveal = format!(
841                "The person is {entity}.\n\nYou read: {}\nYou answered: {}\n\n\
842                 They read: {}\nThey answered: {}\n\n\
843                 Now ask them ONE question. Do not summarise either answer. \
844                 Your entire output is a single sentence ending in a question \
845                 mark.",
846                vantage.sources.join(", "),
847                brief(&answers[i]),
848                agents[other].0.sources.join(", "),
849                brief(&answers[other]),
850            );
851            // A DIFFERENT agent does the asking: same lens, different
852            // system prompt. One agent cannot hold two roles, and giving
853            // the answerer the asking prompt would mean whichever ran last
854            // decided what it was.
855            let mut convo = Conversation::user(reveal);
856            let mut outcome = askers[i].1.run_in(cx, &mut convo, None).await?;
857            // One retry, stripped to the bone. Every asker failed in every
858            // round of the run before this, so a single cheap retry is
859            // worth more than a round silently repeating its question —
860            // and if the bare form fails too, that is a finding rather
861            // than a flake.
862            if usable_question(&outcome.text).is_none() {
863                let mut bare = Conversation::user(format!(
864                    "They said this about {entity}: {}\n\n\
865                     Ask them one question about it. Output only the question.",
866                    brief(&answers[other]),
867                ));
868                outcome = askers[i].1.run_in(cx, &mut bare, None).await?;
869            }
870            // A non-question must not propagate. Keeping the previous
871            // question repeats a round; feeding garbage forward corrupts
872            // every round after it, and the reader answers the garbage
873            // earnestly because it cannot tell it was never asked anything.
874            match usable_question(&outcome.text) {
875                Some(q) => next[other] = q,
876                None => stalled.push((
877                    agents[other].0.label.clone(),
878                    outcome.text.trim().chars().take(300).collect(),
879                )),
880            }
881        }
882        questions = next;
883    }
884    Ok(out)
885}
886
887/// Drop trailing lines where the answerer stops reporting and starts
888/// serving a user.
889///
890/// `ANSWER_SYS` already forbids this in as many words, and a 35B local model
891/// ignored it in every live round: answers ran to twenty lines and closed
892/// with "Would you like me to dig deeper?". The tic is not merely untidy.
893/// An answer is the asker's entire input, and in the third live run one
894/// reader's closing offer to the user became the other's question verbatim
895/// — a politeness reflex promoted to the next agent's task. So it is cut
896/// here rather than asked for once more in a prompt the model overrides.
897///
898/// Only the tail is cut. An answerer never has a legitimate reason to close
899/// on a question: asking is the other role, and it has nobody to ask.
900pub fn strip_user_directed(text: &str) -> String {
901    let serves_a_user = |l: &str| {
902        let lower = l.to_lowercase();
903        l.ends_with('?')
904            || lower.starts_with("let me know")
905            || lower.starts_with("would you")
906            || lower.starts_with("if you'd like")
907            || lower.starts_with("i can look")
908            || lower.starts_with("i can dig")
909    };
910    let mut lines: Vec<&str> = text.lines().collect();
911    while let Some(last) = lines.last() {
912        let t = last.trim().trim_start_matches(['*', '-', '#', '>', ' ']);
913        if t.is_empty() || serves_a_user(t) {
914            lines.pop();
915        } else {
916            break;
917        }
918    }
919    let cut = lines.join("\n").trim().to_string();
920    // A model that answers with nothing BUT an offer has said nothing. Say
921    // that, rather than passing an empty string on as if it were a silence
922    // the sources produced.
923    if cut.is_empty() {
924        "(no answer — the reader only offered to look things up)".to_string()
925    } else {
926        cut
927    }
928}
929
930/// A question aimed at what the addressee *wants* rather than what its
931/// sources *hold*.
932///
933/// The last shape of the assistant reflex to survive into the question
934/// slot. A live round-2 asked "What specific aspect of Dana Whitfield's work
935/// or background are you most interested in?" — grammatically a question,
936/// addressed to a peer, and worthless: the other reader has no preferences,
937/// only sources, so it burned a whole round explaining that it could not
938/// choose. A probe of someone's evidence is the only question worth asking
939/// here.
940fn elicits_a_preference(line: &str) -> bool {
941    let l = line.to_lowercase();
942    [
943        "interested in",
944        "would you like",
945        "do you want",
946        "should i",
947        "can i help",
948        "what would you",
949        "how can i",
950        "anything else",
951    ]
952    .iter()
953    .any(|p| l.contains(p))
954}
955
956/// The first line of `text` that is actually a question, or `None`.
957///
958/// A model with no tools still emits tool syntax when it wants to look
959/// something up — a live run produced `tool:kg_search args:{...}` in
960/// the question slot, which the next reader then answered earnestly,
961/// because a reader cannot tell it was never asked anything. Removing the
962/// asker's tools stopped it *researching*; only validation stops the
963/// wreckage of the attempt from propagating.
964pub fn usable_question(text: &str) -> Option<String> {
965    text.lines()
966        .map(str::trim)
967        .find(|l| {
968            l.ends_with('?')
969                && l.len() > 10
970                // Tool syntax and JSON fragments are the failure mode, not
971                // stray punctuation.
972                && !l.contains("tool:")
973                && !l.contains("args:")
974                && !l.starts_with('{')
975                && !elicits_a_preference(l)
976        })
977        .map(|l| l.trim_start_matches(['*', '-', '#', ' ']).to_string())
978}
979
980/// System prompt for the claim extractor. Tool-less on purpose: an
981/// extractor that can search starts checking as it reads, and what comes
982/// back is a filtered list rather than a faithful one.
983pub const EXTRACT_SYS: &str = "\
984You are given a transcript in which two assistants discussed one person. \
985List the factual claims they made about that person or about the graph's \
986records of them.
987
988One claim per line, each a single short sentence that stands on its own — \
989resolve pronouns and back-references so a line can be checked without the \
990transcript. Include claims you suspect are wrong; judging them is not your \
991job. Exclude questions, hedges about what a source failed to contain, and \
992statements about the assistants themselves.
993
994Output only the list. No numbering, no headings, no commentary.";
995
996/// System prompt for the adjudicator.
997pub const VERIFY_SYS: &str = "\
998You check one claim against a knowledge graph. You can see everything: all \
999sources, all time, facts as well as evidence.
1000
1001Use kg_search to look for evidence, and kg_verify to see what the graph \
1002already believes about an entity and whether its own evidence holds up.
1003
1004Then answer in exactly this form, two lines:
1005VERDICT: supported | unsupported | contradicted
1006BASIS: one sentence, naming what you found
1007
1008'supported' means you found evidence that actually says this. 'contradicted' \
1009means the graph or its evidence says otherwise. 'unsupported' means you \
1010looked and found nothing either way — which is the verdict for anything the \
1011assistant knew from outside the graph, however true it may be in the world. \
1012Absence of evidence is 'unsupported', never 'contradicted'.";
1013
1014/// One claim, checked.
1015#[derive(Debug, Clone, Serialize)]
1016pub struct ClaimVerdict {
1017    pub claim: String,
1018    pub verdict: String,
1019    pub basis: String,
1020}
1021
1022/// Parse the adjudicator's two-line reply.
1023///
1024/// Unparseable output becomes `unchecked` rather than a guess. A verdict
1025/// invented from prose that merely mentions "supported" is worse than an
1026/// admitted gap: it launders a model's mood into an audit result.
1027pub fn parse_verdict(text: &str) -> (String, String) {
1028    const WORDS: [&str; 3] = ["supported", "unsupported", "contradicted"];
1029    let word_at = |s: &str| -> Option<String> {
1030        let v = s.trim().to_lowercase();
1031        let head = v
1032            .split(|c: char| !c.is_ascii_alphabetic())
1033            .find(|w| !w.is_empty())?;
1034        WORDS.contains(&head).then(|| head.to_string())
1035    };
1036
1037    let mut verdict = String::new();
1038    let mut basis = String::new();
1039    for line in text.lines() {
1040        let l = line
1041            .trim()
1042            .trim_start_matches(['*', '-', '#', '>', ' '])
1043            .trim_matches(['*', '`', ' '])
1044            .to_string();
1045        let upper = l.to_uppercase();
1046        if upper.starts_with("VERDICT:") {
1047            if let Some(w) = word_at(&l[8..]) {
1048                verdict = w;
1049            }
1050        } else if upper.starts_with("BASIS:") {
1051            basis = l[6..].trim().to_string();
1052        } else if verdict.is_empty() && WORDS.contains(&l.to_lowercase().as_str()) {
1053            // A bare verdict alone on its line. Accepting this is not the
1054            // guessing forbidden above: the entire line is the word, so
1055            // there is no prose to misread. Prose stays rejected.
1056            verdict = l.to_lowercase();
1057        }
1058    }
1059    if verdict.is_empty() {
1060        // Keep what it actually said. "Did not answer in form" is not a
1061        // diagnosis, and a run in which all eight claims came back
1062        // unchecked left nothing whatever to work from — exactly the
1063        // mistake the asker's stall record had already corrected once.
1064        let said: String = text.trim().chars().take(200).collect();
1065        return (
1066            "unchecked".into(),
1067            if said.is_empty() {
1068                "the adjudicator said nothing at all".into()
1069            } else {
1070                format!("not in form; it said: {}", said.replace('\n', " "))
1071            },
1072        );
1073    }
1074    (verdict, basis)
1075}
1076
1077/// Split the extractor's output into claims.
1078///
1079/// The filter earns its place. A tool-less extractor handed a transcript of
1080/// two agents searching carries on searching: a live run produced
1081/// "search_query: U0EXAMPLE01" and "I will search the knowledge graph
1082/// for..." in the claim slot, and all three were dutifully sent to the
1083/// adjudicator. A claim is a statement about the person; an announcement of
1084/// what the model is about to do is not one, and passing it on wastes a
1085/// verification call to conclude nothing.
1086pub fn claim_lines(text: &str, max: usize) -> Vec<String> {
1087    let is_intent = |l: &str| {
1088        let lower = l.to_lowercase();
1089        lower.contains("search_query")
1090            || lower.contains("tool:")
1091            || lower.starts_with("i will ")
1092            || lower.starts_with("i'll ")
1093            || lower.starts_with("let me ")
1094            || lower.starts_with("i need to ")
1095            || lower.starts_with("first, i")
1096    };
1097    text.lines()
1098        .map(|l| {
1099            l.trim()
1100                .trim_start_matches(['*', '-', '#', '•', ' '])
1101                .trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == ')')
1102                .trim()
1103                .to_string()
1104        })
1105        .filter(|l| l.len() > 15 && !l.ends_with(':') && !l.ends_with('?') && !is_intent(l))
1106        .take(max)
1107        .collect()
1108}
1109
1110/// pkg's deterministic verdicts on its own stored claims about an entity.
1111///
1112/// No model in the loop: `kg_verify` dereferences each live claim to the
1113/// evidence cited for it. This is the one part of an audit that cannot
1114/// hallucinate, which is why it is reported alongside the model tier rather
1115/// than folded into it — and why it lives here rather than in the CLI,
1116/// where reaching into the MCP client to hand-roll a graph call would put a
1117/// second, unaudited path to the graph in the front end.
1118pub async fn graph_findings(client: &McpClient, entity: &str) -> Result<String> {
1119    let out = client
1120        .call_tool("kg_verify", json!({ "node": entity, "limit": 20 }))
1121        .await
1122        .context("kg_verify")?;
1123    Ok(out.content)
1124}
1125
1126/// Audit an exchange: extract what was claimed, then check each claim
1127/// against the whole graph.
1128///
1129/// Runs after the rounds rather than during them, deliberately. A verifier
1130/// speaking mid-exchange would be a third voice the readers accommodate,
1131/// and the readers' independence is the only thing making the exchange
1132/// worth auditing.
1133pub async fn audit(
1134    extractor: &Agent,
1135    verifier: &Agent,
1136    cx: &RunContext,
1137    exchange: &Exchange,
1138    max_claims: usize,
1139) -> Result<Vec<ClaimVerdict>> {
1140    // The imperative goes LAST, after the transcript. The same lesson the
1141    // asker taught and this call initially ignored: a system prompt in
1142    // front of a long input loses to the shape of that input. The
1143    // transcript ends with two agents searching, so the extractor carried
1144    // on searching — it has no tools, and still emitted "search_query:
1145    // U0EXAMPLE01" where a claim belonged.
1146    let mut convo = Conversation::user(format!(
1147        "The person is {}.\n\n{}\n\n\
1148         Now list the factual claims made about {} in the transcript above. \
1149         One per line. Do not search, do not comment, do not explain — you \
1150         have no tools and the list is your whole output.",
1151        exchange.entity,
1152        render(exchange),
1153        exchange.entity,
1154    ));
1155    let listed = extractor
1156        .run_in(cx, &mut convo, None)
1157        .await
1158        .context("extracting claims from the exchange")?;
1159
1160    let mut out = Vec::new();
1161    for claim in claim_lines(&listed.text, max_claims) {
1162        // Imperative last, again. This is the third role to need it: the
1163        // required form lived only in VERIFY_SYS, and after a few tool
1164        // calls the model was far enough from it to answer in prose. Every
1165        // claim of one run came back unchecked for that reason alone.
1166        let mut convo = Conversation::user(format!(
1167            "The person is {}.\n\nClaim to check: {claim}\n\n\
1168             Search first, then reply with exactly two lines:\n\
1169             VERDICT: supported | unsupported | contradicted\n\
1170             BASIS: one sentence naming what you found",
1171            exchange.entity
1172        ));
1173        let res = verifier
1174            .run_in(cx, &mut convo, None)
1175            .await
1176            .with_context(|| format!("checking claim: {claim}"))?;
1177        let (verdict, basis) = parse_verdict(&res.text);
1178        out.push(ClaimVerdict {
1179            claim,
1180            verdict,
1181            basis,
1182        });
1183    }
1184    Ok(out)
1185}
1186
1187/// Render an audit, findings first — an audit read top-down should hit
1188/// what is wrong before what is fine.
1189pub fn render_audit(verdicts: &[ClaimVerdict]) -> String {
1190    let rank = |v: &str| match v {
1191        "contradicted" => 0,
1192        "unsupported" => 1,
1193        "unchecked" => 2,
1194        _ => 3,
1195    };
1196    let mut sorted: Vec<&ClaimVerdict> = verdicts.iter().collect();
1197    sorted.sort_by_key(|c| rank(&c.verdict));
1198
1199    let mut s = String::from("\nAudit\n");
1200    for c in &sorted {
1201        s.push_str(&format!(
1202            "  [{}] {}\n      {}\n",
1203            c.verdict, c.claim, c.basis
1204        ));
1205    }
1206    let n = |v: &str| verdicts.iter().filter(|c| c.verdict == v).count();
1207    s.push_str(&format!(
1208        "  — {} claim(s): {} supported, {} unsupported, {} contradicted, {} unchecked\n",
1209        verdicts.len(),
1210        n("supported"),
1211        n("unsupported"),
1212        n("contradicted"),
1213        n("unchecked"),
1214    ));
1215    s
1216}
1217
1218/// Render an exchange for a distiller or a reader.
1219pub fn render(x: &Exchange) -> String {
1220    let mut s = format!("Gossip about {} \n", x.entity);
1221    for v in &x.vantages {
1222        s.push_str(&format!("  {} reads: {}\n", v.label, v.sources.join(", ")));
1223    }
1224    for r in &x.rounds {
1225        s.push_str(&format!("\nRound {}\n", r.n));
1226        for ((who, q), (_, a)) in r.asked.iter().zip(r.answered.iter()) {
1227            if let Some((_, raw)) = r.stalled.iter().find(|(l, _)| l == who) {
1228                let raw = if raw.is_empty() {
1229                    "(nothing at all)".to_string()
1230                } else {
1231                    raw.replace('\n', " ")
1232                };
1233                s.push_str(&format!(
1234                    "  ! {who}'s asker produced no question. It emitted: {raw}\n"
1235                ));
1236            }
1237            s.push_str(&format!("  {who} was asked: {q}\n  {who} said: {a}\n"));
1238        }
1239    }
1240    s
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246
1247    fn cov(pairs: &[(&str, i64)]) -> Vec<SourceCoverage> {
1248        pairs
1249            .iter()
1250            .map(|(s, n)| SourceCoverage {
1251                source: s.to_string(),
1252                episodes: *n,
1253            })
1254            .collect()
1255    }
1256
1257    #[test]
1258    fn an_offer_to_the_user_never_reaches_the_other_reader() {
1259        // The live failure: reader A closed with an offer of further help,
1260        // and that offer became reader B's question in the next round.
1261        let answered = "Slack shows he ran a hyperscanning practice with Rutgers.\n\
1262             His birthday is May 28.\n\n\
1263             Would you like me to dig deeper into one of these workstreams?";
1264        let cut = strip_user_directed(answered);
1265        assert!(cut.ends_with("His birthday is May 28."));
1266        assert!(!cut.contains("dig deeper"));
1267        // And what survives must still be answerable material, not a stub.
1268        assert!(cut.contains("hyperscanning"));
1269
1270        // A real silence is preserved — it is a contribution, not a defect.
1271        assert_eq!(
1272            strip_user_directed("My sources show nothing about that."),
1273            "My sources show nothing about that."
1274        );
1275        // An answer that is ONLY an offer is not silence, and must not be
1276        // passed on as though the sources had been consulted.
1277        assert!(strip_user_directed("Would you like me to search?").starts_with("(no answer"));
1278    }
1279
1280    #[test]
1281    fn a_verdict_is_computed_from_two_sightings_not_asked_for() {
1282        use Sighting::*;
1283        assert_eq!(corroboration_verdict(Seen, Seen), "corroborated");
1284        assert_eq!(corroboration_verdict(Seen, Unseen), "single_source");
1285        assert_eq!(corroboration_verdict(Unseen, Seen), "single_source");
1286        assert_eq!(corroboration_verdict(Unseen, Unseen), "unseen");
1287        // A contradiction outranks agreement in both directions: one source
1288        // actively denying it matters more than another merely echoing it.
1289        assert_eq!(corroboration_verdict(Seen, Contradicted), "contradicted");
1290        assert_eq!(corroboration_verdict(Contradicted, Seen), "contradicted");
1291        // A mangled reply is our failure, and must never be counted as
1292        // absence of evidence — that would convert harness bugs into
1293        // rejections of true claims.
1294        assert_eq!(corroboration_verdict(Seen, Unclear), "unclear");
1295        assert_eq!(corroboration_verdict(Unclear, Unseen), "unclear");
1296    }
1297
1298    #[test]
1299    fn a_sighting_is_parsed_or_admitted() {
1300        let (s, cite) = parse_sighting("SIGHTING: SEEN\nCITE: slack #random, 2026-05-28");
1301        assert_eq!(s, Sighting::Seen);
1302        assert_eq!(cite, "slack #random, 2026-05-28");
1303        assert_eq!(
1304            parse_sighting("SIGHTING: UNSEEN\nCITE: nothing").0,
1305            Sighting::Unseen
1306        );
1307        assert_eq!(
1308            parse_sighting("**SIGHTING:** CONTRADICTED").0,
1309            Sighting::Contradicted
1310        );
1311        // A bare word alone on a line is a format.
1312        assert_eq!(parse_sighting("UNSEEN").0, Sighting::Unseen);
1313        // Prose that merely contains the word is not.
1314        let (s, basis) = parse_sighting("I have not seen anything like this claim.");
1315        assert_eq!(s, Sighting::Unclear);
1316        assert!(
1317            basis.contains("I have not seen"),
1318            "the rejected text is kept"
1319        );
1320    }
1321
1322    #[test]
1323    fn corroboration_never_reads_the_source_it_came_from() {
1324        // A reader that can see the episode a claim was extracted from will
1325        // find the claim there and call it corroborated: the same witness
1326        // twice, which is the facts-versus-evidence failure one level up.
1327        let spread = cov(&[
1328            ("bee.conversation", 40),
1329            ("bee.daily", 35),
1330            ("slack.thread", 30),
1331            ("reflect.daily", 20),
1332        ]);
1333        // The whole FAMILY goes, not just the one source. bee.daily is a
1334        // summary of bee.conversation: excluding only the exact origin
1335        // would let a claim be corroborated by a digest of itself.
1336        let (a, b) = vantages_excluding(&spread, Some("bee.conversation"), 3).unwrap();
1337        for v in [&a, &b] {
1338            assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
1339        }
1340        // A proposer works in place of a source, because Bee's fact API
1341        // stages 200 candidates with no originating episode at all and the
1342        // prefix is then the only honest record of where they came from.
1343        let (a, b) = vantages_excluding(&spread, Some("bee:suggested"), 3).unwrap();
1344        for v in [&a, &b] {
1345            assert!(!v.sources.iter().any(|s| s.starts_with("bee.")), "{v:?}");
1346        }
1347        // With nothing else covering the subject there is no pair — better
1348        // no verdict than a self-corroborating one.
1349        let only = cov(&[("bee.conversation", 40), ("bee.daily", 9)]);
1350        assert!(vantages_excluding(&only, Some("bee:suggested"), 3).is_none());
1351    }
1352
1353    #[test]
1354    fn every_origin_bars_its_own_family_not_a_reconstruction() {
1355        // Regression: the origin's family used to be rebuilt from its head
1356        // token (`slack.thread` → "slack." → "other"), which barred nothing
1357        // and left the origin itself eligible as its own witness for every
1358        // Slack, calendar, mail, and llm-proposed candidate.
1359        let spread = cov(&[
1360            ("slack.thread", 40),
1361            ("bee.conversation", 30),
1362            ("calendar.event", 20),
1363        ]);
1364        for origin in ["slack.thread", "mbox", "email.thread"] {
1365            let (a, b) = vantages_excluding(&spread, Some(origin), 3).unwrap();
1366            for v in [&a, &b] {
1367                assert!(
1368                    !v.sources.iter().any(|s| s == "slack.thread"),
1369                    "origin {origin} left its own family eligible: {v:?}"
1370                );
1371            }
1372        }
1373        let (a, b) = vantages_excluding(&spread, Some("calendar.event"), 3).unwrap();
1374        for v in [&a, &b] {
1375            assert!(!v.sources.iter().any(|s| s == "calendar.event"), "{v:?}");
1376        }
1377        // An extractor proposer names no source at all. With the origin
1378        // unknowable, refusing is the only answer that cannot let a claim
1379        // vote for itself.
1380        assert!(vantages_excluding(&spread, Some("llm:commitment"), 3).is_none());
1381        assert!(vantages_excluding(&spread, Some("llm"), 3).is_none());
1382        // agent:mecha is a real source whose name happens to hold a colon.
1383        assert_eq!(family_of_origin("agent:mecha"), Some("agentic"));
1384    }
1385
1386    #[test]
1387    fn an_unparseable_verdict_is_never_guessed() {
1388        assert_eq!(
1389            parse_verdict("VERDICT: contradicted\nBASIS: the graph lists one node."),
1390            ("contradicted".into(), "the graph lists one node.".into())
1391        );
1392        // Prose that merely mentions a verdict word must not become one:
1393        // laundering a model's mood into an audit result is worse than an
1394        // admitted gap.
1395        let (v, basis) = parse_verdict("I think this is probably supported by the Slack thread.");
1396        assert_eq!(v, "unchecked");
1397        // And the rejected text is kept: "did not answer in form" is not a
1398        // diagnosis, as a run of eight unchecked claims demonstrated.
1399        assert!(basis.contains("I think this is probably supported"));
1400
1401        // A bare verdict alone on its line is a format, not prose.
1402        assert_eq!(parse_verdict("supported").0, "supported");
1403        assert_eq!(parse_verdict("**VERDICT:** contradicted").0, "contradicted");
1404        assert_eq!(
1405            parse_verdict("Verdict: unsupported\nBasis: nothing found").0,
1406            "unsupported"
1407        );
1408        assert_eq!(parse_verdict("").0, "unchecked");
1409        // A verdict outside the vocabulary is not a verdict.
1410        assert_eq!(
1411            parse_verdict("VERDICT: mostly true\nBASIS: x").0,
1412            "unchecked"
1413        );
1414    }
1415
1416    #[test]
1417    fn claim_extraction_drops_scaffolding() {
1418        let listed = "**Claims:**\n\
1419             1. Dana Whitfield works at Dartmouth.\n\
1420             - py-feat is a tool for fNIRS analysis.\n\
1421             Is she the lab PI?\n\
1422             short\n\
1423             She maintains the /srv/example/Git directory.";
1424        let claims = claim_lines(listed, 8);
1425        assert_eq!(claims.len(), 3, "got {claims:?}");
1426        assert!(claims[0].starts_with("Dana Whitfield works"));
1427        assert!(
1428            !claims.iter().any(|c| c.ends_with('?')),
1429            "questions are not claims"
1430        );
1431        assert!(!claims.iter().any(|c| c.contains("Claims:")));
1432        // The cap is a cap, not a suggestion.
1433        assert_eq!(claim_lines(listed, 2).len(), 2);
1434
1435        // The live failure: a tool-less extractor announcing searches, and
1436        // every one of them sent to the adjudicator as a claim.
1437        let intent = "I will search the knowledge graph for the Slack handle U0EXAMPLE01.\n\
1438             search_query: dana@email.example.edu\n\
1439             Let me check whether the two entities are distinct.\n\
1440             Dana Whitfield presented a poster on April 19, 2026.";
1441        let claims = claim_lines(intent, 8);
1442        assert_eq!(
1443            claims,
1444            vec!["Dana Whitfield presented a poster on April 19, 2026."]
1445        );
1446    }
1447
1448    #[test]
1449    fn a_question_must_probe_sources_not_preferences() {
1450        // Live round 2. Grammatical, addressed to the peer, and worthless:
1451        // the other reader has no interests, only evidence.
1452        assert_eq!(
1453            usable_question(
1454                "What specific aspect of Dana Whitfield's work or background \
1455                 are you most interested in?"
1456            ),
1457            None
1458        );
1459        assert_eq!(usable_question("Would you like me to dig deeper?"), None);
1460        // The good ones from the same run must survive. Both are second
1461        // person, so the rule cannot simply reject "you".
1462        for q in [
1463            "Are you referring to the Dana Whitfield associated with the Chang \
1464             lab at Dartmouth and the 'py-feat' paper?",
1465            "Can you confirm if Dana Whitfield is associated with the Chang lab?",
1466        ] {
1467            assert!(usable_question(q).is_some(), "rejected a real probe: {q}");
1468        }
1469    }
1470
1471    #[test]
1472    fn a_non_question_never_propagates() {
1473        // The live failure: an asker with no tools still emitted tool
1474        // syntax, which became the next round's "question" and was
1475        // answered earnestly.
1476        assert_eq!(
1477            usable_question("tool:kg_search\nargs:{\"query\": \"ljchang\"}"),
1478            None
1479        );
1480        assert_eq!(
1481            usable_question("Based on my searches, here is what I found:"),
1482            None
1483        );
1484        assert_eq!(usable_question(""), None);
1485        assert_eq!(
1486            usable_question("ok?"),
1487            None,
1488            "too short to be a real question"
1489        );
1490
1491        // A real question survives, and decoration is trimmed.
1492        assert_eq!(
1493            usable_question("Who does she collaborate with on the grant?").as_deref(),
1494            Some("Who does she collaborate with on the grant?")
1495        );
1496        assert_eq!(
1497            usable_question("Some preamble.\n- What role does he hold in the lab?").as_deref(),
1498            Some("What role does he hold in the lab?"),
1499            "the question is found past preamble and stripped of its bullet"
1500        );
1501    }
1502
1503    #[test]
1504    fn vantages_prefer_independence_over_volume() {
1505        // Slack and mbox are both "written" — two slices of one witness.
1506        // The calendar is a different kind of account, so it wins the second
1507        // seat despite having fewer episodes.
1508        let c = cov(&[("slack.thread", 400), ("mbox", 300), ("calendar.event", 50)]);
1509        let (a, b) = choose_vantages(&c, 3).unwrap();
1510        assert_eq!(a.sources, vec!["slack.thread"]);
1511        assert_eq!(
1512            b.sources,
1513            vec!["calendar.event"],
1514            "a second family beats a bigger sibling"
1515        );
1516        assert_ne!(a.label, b.label);
1517    }
1518
1519    #[test]
1520    fn a_thin_source_is_not_a_witness() {
1521        // The live shape that motivated this: 493 slack episodes and 2 bee
1522        // conversations. Handing a reader the 2 produces a confident "I do
1523        // not know" that reads like a finding rather than an empty shelf.
1524        let c = cov(&[("slack.thread", 493), ("bee.conversation", 2)]);
1525        assert!(
1526            choose_vantages(&c, 3).is_none(),
1527            "one witness and a silence is not a pair"
1528        );
1529        // Lower the floor and it becomes a legitimate, if lopsided, pair.
1530        assert!(choose_vantages(&c, 2).is_some());
1531    }
1532
1533    #[test]
1534    fn same_family_is_better_than_no_pair() {
1535        // Independence is preferred, not required: two written sources still
1536        // beat refusing to run.
1537        let c = cov(&[("slack.thread", 40), ("mbox", 30)]);
1538        let (a, b) = choose_vantages(&c, 3).unwrap();
1539        assert_eq!(
1540            (a.sources[0].as_str(), b.sources[0].as_str()),
1541            ("slack.thread", "mbox")
1542        );
1543    }
1544
1545    #[test]
1546    fn families_split_the_kinds_of_account_apart() {
1547        assert_eq!(family("bee.conversation"), family("bee.daily"));
1548        assert_ne!(family("calendar.event"), family("slack.thread"));
1549        assert_ne!(family("reflect.note"), family("bee.conversation"));
1550        // bee.daily is a digest of bee.conversation — one witness. A Reflect
1551        // note and a Reflect daily are separately written accounts, so they
1552        // may serve as each other's witness.
1553        assert_ne!(family("reflect.note"), family("reflect.daily"));
1554    }
1555
1556    #[test]
1557    fn lensed_search_hides_what_it_pins() {
1558        // The point of the wrapper: a child cannot widen its own lens,
1559        // because the schema it is shown has no way to name the lens.
1560        // Asserts on the PRODUCTION schema — an earlier version asserted on
1561        // its own copy, which would keep passing after the real one grew a
1562        // hole.
1563        let schema = LensedSearch::lens_schema();
1564        let props = schema["properties"].as_object().unwrap();
1565        for pinned in ["sources", "since", "until", "include_private", "scope"] {
1566            assert!(
1567                !props.contains_key(pinned),
1568                "{pinned} must not be nameable by the child"
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn the_readonly_approver_blocks_rather_than_asks() {
1575        // The guarantee the owner asked for: a gossip run cannot put a
1576        // question to them. ModeApprover in ReadOnly answers from policy —
1577        // anything not read-only is Blocked with a reason, and nothing is
1578        // ever raised to a terminal nobody is sitting at. Exercises
1579        // approve() itself; an earlier version only inspected the mode
1580        // field, which a broken approve() would have sailed past.
1581        struct Probe {
1582            ro: bool,
1583        }
1584        #[async_trait]
1585        impl Tool for Probe {
1586            fn name(&self) -> &str {
1587                "probe"
1588            }
1589            fn description(&self) -> &str {
1590                "test probe"
1591            }
1592            fn input_schema(&self) -> Value {
1593                json!({"type": "object"})
1594            }
1595            fn read_only(&self) -> bool {
1596                self.ro
1597            }
1598            async fn call(&self, _input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
1599                Ok(ToolOutput::ok("ok"))
1600            }
1601        }
1602        use crate::tool::Approver as _;
1603        let a = crate::tool::ModeApprover {
1604            mode: crate::config::PermissionMode::ReadOnly,
1605        };
1606        let rt = tokio::runtime::Builder::new_current_thread()
1607            .build()
1608            .unwrap();
1609        let read = rt.block_on(a.approve(&Probe { ro: true }, &json!({})));
1610        assert!(matches!(read, crate::tool::Decision::Allow));
1611        let write = rt.block_on(a.approve(&Probe { ro: false }, &json!({})));
1612        // Blocked, not Deny, and never a question raised to a terminal
1613        // nobody is sitting at.
1614        assert!(matches!(write, crate::tool::Decision::Blocked(_)));
1615    }
1616
1617    #[test]
1618    fn a_reader_declares_private_and_untrusted_but_never_send() {
1619        // Both halves of the trifecta enter a gossip child by design — it
1620        // reads the user's life, and episode bodies are third-party text.
1621        // The third leg must therefore never be handed over, or the pair
1622        // becomes an exfiltration path. Asserts on the PRODUCTION
1623        // declaration, not a literal copy of it.
1624        let caps = LensedSearch::lens_capabilities();
1625        assert!(caps.private_data && caps.untrusted_input);
1626        assert!(
1627            !caps.external_send,
1628            "a gossip reader with a way to send is the leak the interlock exists for"
1629        );
1630        assert!(!caps.destructive);
1631    }
1632}
1633
1634// ─── Corroboration: is a generalisation more than its one transcript? ────────
1635
1636/// A pending fact candidate, as the queue hands it over.
1637#[derive(Debug, Clone, Deserialize)]
1638pub struct Candidate {
1639    pub candidate_id: i64,
1640    pub statement: String,
1641    #[serde(default)]
1642    pub subject: Option<String>,
1643    #[serde(default)]
1644    pub origin_source: Option<String>,
1645    /// The subject was guessed from an ambiguous name match. Carried
1646    /// through rather than hidden: the guess is usually right and the
1647    /// ambiguity is usually a duplicate identity worth fixing at the root.
1648    #[serde(default)]
1649    pub subject_ambiguous: bool,
1650    #[serde(default)]
1651    pub confidence: Option<f64>,
1652    #[serde(default)]
1653    pub predicate: Option<String>,
1654    /// The origin episode, when `pending` was asked for evidence — what
1655    /// the verification mechanism judges the claim against.
1656    #[serde(default)]
1657    pub evidence: Option<EvidenceClip>,
1658}
1659
1660/// The origin episode as `kg_pending include_evidence` hands it over.
1661#[derive(Debug, Clone, Serialize, Deserialize)]
1662pub struct EvidenceClip {
1663    pub source: String,
1664    #[serde(default)]
1665    pub occurred_at: String,
1666    pub body: String,
1667}
1668
1669/// Pending candidates MENTIONING an entity, across every class.
1670///
1671/// The other axis of the same queue. [`pending`] works a class, which is
1672/// right when the shared history is the thing being judged; this works a
1673/// person, which is right when a reader has just spent real effort
1674/// understanding one. A probe finishes holding two vantages, three rounds of
1675/// dialogue and cited evidence, and that context is worth more against the
1676/// claims already pending about that person than against the next N items of
1677/// some predicate.
1678///
1679/// It is also the only route by which a probe can make the queue *smaller*.
1680/// Everything else gossip could write — a new claim, an unsupported reader
1681/// assertion — adds to it.
1682pub async fn pending_about(
1683    client: &McpClient,
1684    entity: &str,
1685    limit: usize,
1686    unjudged_by: Option<&str>,
1687    include_evidence: bool,
1688) -> Result<Vec<Candidate>> {
1689    let out = client
1690        .call_tool(
1691            "kg_pending",
1692            json!({
1693                "entity": entity,
1694                "limit": limit,
1695                "unjudged_by": unjudged_by,
1696                "include_evidence": include_evidence,
1697            }),
1698        )
1699        .await
1700        .context("kg_pending")?;
1701    let body: Value = serde_json::from_str(&out.content)
1702        .with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
1703    if let Some(e) = body.get("error").and_then(Value::as_str) {
1704        anyhow::bail!("kg_pending: {e}");
1705    }
1706    Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
1707}
1708
1709/// One class of the review queue, oldest first.
1710/// `unjudged_by`: name the mechanism to skip candidates it has already
1711/// filed a verdict on — a batch run then extends coverage instead of
1712/// re-judging the same oldest N (pkg keeps verdict history, so re-judging
1713/// duplicates opinions).
1714pub async fn pending(
1715    client: &McpClient,
1716    proposed_by: &str,
1717    predicate: &str,
1718    limit: usize,
1719    unjudged_by: Option<&str>,
1720    include_evidence: bool,
1721) -> Result<Vec<Candidate>> {
1722    let out = client
1723        .call_tool(
1724            "kg_pending",
1725            json!({
1726                "proposed_by": proposed_by,
1727                "predicate": predicate,
1728                "limit": limit,
1729                "unjudged_by": unjudged_by,
1730                "include_evidence": include_evidence,
1731            }),
1732        )
1733        .await
1734        .context("kg_pending")?;
1735    let body: Value = serde_json::from_str(&out.content)
1736        .with_context(|| format!("kg_pending returned non-JSON: {}", out.content))?;
1737    if let Some(e) = body.get("error").and_then(Value::as_str) {
1738        anyhow::bail!("kg_pending: {e}");
1739    }
1740    Ok(serde_json::from_value(body["items"].clone()).unwrap_or_default())
1741}
1742
1743/// File an opinion beside a candidate. Decides nothing.
1744pub async fn file_verdict(
1745    client: &McpClient,
1746    candidate_id: i64,
1747    mechanism: &str,
1748    verdict: &str,
1749    basis: &str,
1750    model: Option<&str>,
1751) -> Result<()> {
1752    client
1753        .call_tool(
1754            "kg_verdict",
1755            json!({
1756                "candidate_id": candidate_id, "mechanism": mechanism,
1757                "verdict": verdict, "basis": basis, "model": model,
1758            }),
1759        )
1760        .await
1761        .context("kg_verdict")?;
1762    Ok(())
1763}
1764
1765/// What one reader found in its own sources.
1766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1767pub enum Sighting {
1768    Seen,
1769    Unseen,
1770    Contradicted,
1771    /// The reader did not answer in form. Not the same as `Unseen`: a
1772    /// mangled reply is our failure, and counting it as absence of evidence
1773    /// would quietly convert harness bugs into rejections.
1774    Unclear,
1775}
1776
1777pub fn parse_sighting(text: &str) -> (Sighting, String) {
1778    let mut sighting = None;
1779    let mut basis = String::new();
1780    for line in text.lines() {
1781        let l = line
1782            .trim()
1783            .trim_start_matches(['*', '-', '#', '>', ' '])
1784            .trim_matches(['*', '`', ' '])
1785            .to_string();
1786        let upper = l.to_uppercase();
1787        let body = upper.strip_prefix("SIGHTING:").map(str::trim);
1788        let word = body.unwrap_or(&upper);
1789        if (body.is_some() || sighting.is_none()) && sighting.is_none() {
1790            let head = word
1791                .split(|c: char| !c.is_ascii_alphabetic())
1792                .find(|w| !w.is_empty())
1793                .unwrap_or_default();
1794            // A bare word alone on its line counts, exactly as a bare
1795            // verdict does; prose containing the word does not.
1796            if body.is_some() || word.trim() == head {
1797                sighting = match head {
1798                    "SEEN" => Some(Sighting::Seen),
1799                    "UNSEEN" => Some(Sighting::Unseen),
1800                    "CONTRADICTED" => Some(Sighting::Contradicted),
1801                    _ => None,
1802                };
1803            }
1804        }
1805        if let Some(rest) = l.strip_prefix("CITE:").or(l.strip_prefix("Cite:")) {
1806            basis = rest.trim().to_string();
1807        }
1808    }
1809    match sighting {
1810        Some(s) => (s, basis),
1811        None => (
1812            Sighting::Unclear,
1813            format!("not in form; it said: {}", {
1814                let t: String = text.trim().chars().take(160).collect();
1815                t.replace('\n', " ")
1816            }),
1817        ),
1818    }
1819}
1820
1821/// The verdict, computed from two sightings rather than asked for.
1822///
1823/// Deliberately code and not a third model call. The whole value of
1824/// commit-then-reveal is that two independent judgements were formed; a
1825/// model asked to "summarise the verdict" can and will overrule them, and
1826/// then the independence bought nothing.
1827pub fn corroboration_verdict(a: Sighting, b: Sighting) -> &'static str {
1828    use Sighting::*;
1829    match (a, b) {
1830        // A contradiction anywhere outranks agreement: one source actively
1831        // denying it matters more than another merely echoing it.
1832        (Contradicted, _) | (_, Contradicted) => "contradicted",
1833        (Seen, Seen) => "corroborated",
1834        (Seen, Unseen) | (Unseen, Seen) => "single_source",
1835        (Unseen, Unseen) => "unseen",
1836        // Anything touching Unclear is not a finding about the world.
1837        _ => "unclear",
1838    }
1839}
1840
1841pub const SIGHT_SYS: &str = "\
1842You are checking whether a claim about a person shows up in YOUR sources. \
1843Another assistant is checking DIFFERENT sources.
1844
1845The claim came from somewhere else entirely; your job is not to judge \
1846whether it sounds right, but whether your own evidence shows it. Search, \
1847then answer. 'UNSEEN' is the honest and expected answer for most claims, \
1848and it is a real contribution — a generalisation drawn from one \
1849conversation and visible nowhere else is exactly what needs finding.
1850
1851Reply in exactly this form, two lines:
1852SIGHTING: SEEN | UNSEEN | CONTRADICTED
1853CITE: what you found, or 'nothing' — quote or name the episode
1854
1855CONTRADICTED means your evidence shows the opposite, not merely that it is \
1856absent. Absence is UNSEEN.";
1857
1858/// One candidate, judged by two readers on sources that exclude its origin.
1859#[derive(Debug, Clone, Serialize)]
1860pub struct Corroboration {
1861    pub candidate_id: i64,
1862    pub statement: String,
1863    pub verdict: &'static str,
1864    pub sightings: Vec<(String, Sighting, String)>,
1865    pub rechecked: bool,
1866    /// The dissenter's answer BEFORE the reveal, when one happened. A flip
1867    /// from Unseen after being shown the other's citation and an independent
1868    /// Seen are different findings — overwriting the first look erased the
1869    /// distinction, and it is exactly the datum rule-derivation needs.
1870    pub pre_reveal: Option<(String, Sighting, String)>,
1871}
1872
1873/// Commit, then reveal, then compute.
1874///
1875/// The reveal is narrower than in an open exchange, and deliberately: only
1876/// a lone dissenter is shown the other's citation, and only to look again
1877/// at its OWN sources. Showing both readers everything would let the one
1878/// with nothing simply agree, which is the conformity commit-then-reveal
1879/// exists to prevent.
1880pub async fn corroborate(
1881    readers: &[(Vantage, Agent)],
1882    cx: &RunContext,
1883    cand: &Candidate,
1884) -> Result<Corroboration> {
1885    anyhow::ensure!(readers.len() == 2, "corroboration is a pair");
1886    let ask = format!(
1887        "Claim to check against your sources:\n\n{}\n\n\
1888         Search your sources, then reply with exactly two lines:\n\
1889         SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
1890         CITE: what you found, or 'nothing'",
1891        cand.statement
1892    );
1893
1894    let mut found = Vec::new();
1895    for (v, agent) in readers {
1896        let mut convo = Conversation::user(ask.clone());
1897        let out = agent
1898            .run_in(cx, &mut convo, None)
1899            .await
1900            .with_context(|| format!("{} reader on candidate {}", v.label, cand.candidate_id))?;
1901        let (s, basis) = parse_sighting(&out.text);
1902        // The SOURCE, not just the family label. Two readers both labelled
1903        // "reflected" read reflect.note (2,263 episodes) and reflect.daily
1904        // (118) — different sources entirely — and a live run showed one
1905        // "reflected" seeing Flowmail and another not, which reads as a
1906        // mechanism contradicting itself until you can see it was never
1907        // the same shelf.
1908        found.push((format!("{} [{}]", v.label, v.sources.join(",")), s, basis));
1909    }
1910
1911    // REVEAL, only on a split, and only to the dissenter. Being pointed at
1912    // something is how a second look differs from a first — the reader may
1913    // hold the same episode under a wording its own query never reached.
1914    let mut rechecked = false;
1915    let mut pre_reveal = None;
1916    let seen_at = found.iter().position(|(_, s, _)| *s == Sighting::Seen);
1917    let unseen_at = found.iter().position(|(_, s, _)| *s == Sighting::Unseen);
1918    if let (Some(hit), Some(miss)) = (seen_at, unseen_at) {
1919        rechecked = true;
1920        pre_reveal = Some(found[miss].clone());
1921        let mut convo = Conversation::user(format!(
1922            "Claim: {}\n\nAnother assistant, reading {}, found this:\n{}\n\n\
1923             Search YOUR sources once more with that in mind. Do not take \
1924             their word for it — report only what your own evidence shows.\n\
1925             SIGHTING: SEEN | UNSEEN | CONTRADICTED\n\
1926             CITE: what you found, or 'nothing'",
1927            cand.statement,
1928            readers[hit].0.sources.join(", "),
1929            found[hit].2,
1930        ));
1931        let out = readers[miss].1.run_in(cx, &mut convo, None).await?;
1932        let (s, basis) = parse_sighting(&out.text);
1933        found[miss] = (
1934            format!(
1935                "{} [{}]",
1936                readers[miss].0.label,
1937                readers[miss].0.sources.join(",")
1938            ),
1939            s,
1940            basis,
1941        );
1942    }
1943
1944    Ok(Corroboration {
1945        candidate_id: cand.candidate_id,
1946        statement: cand.statement.clone(),
1947        verdict: corroboration_verdict(found[0].1, found[1].1),
1948        sightings: found,
1949        rechecked,
1950        pre_reveal,
1951    })
1952}
1953
1954/// Sources that may serve as a vantage for a candidate: everything the
1955/// subject is covered by, minus the FAMILY the claim came from.
1956///
1957/// Family, not source. `bee.conversation` and `bee.daily` are one witness
1958/// wearing two labels, and excluding only the exact origin would let a
1959/// claim taken from a Bee transcript be corroborated by the Bee daily
1960/// summary of that same transcript.
1961///
1962/// `origin` takes either a source (`bee.conversation`) or a proposer
1963/// (`bee:suggested`), because many candidates have no originating episode
1964/// at all — Bee's fact API stages 200 with a null episode_id — and the
1965/// proposer prefix is then the only honest record of where they came from.
1966/// An origin whose family cannot be determined refuses outright: no verdict
1967/// is strictly better than one the origin may have voted in.
1968pub fn vantages_excluding(
1969    coverage: &[SourceCoverage],
1970    origin: Option<&str>,
1971    min: i64,
1972) -> Option<(Vantage, Vantage)> {
1973    let barred = match origin {
1974        // An unknowable origin family refuses outright (the `?`): no
1975        // verdict beats one the origin may have voted in.
1976        Some(o) => Some(family_of_origin(o)?),
1977        None => None,
1978    };
1979    let kept: Vec<SourceCoverage> = coverage
1980        .iter()
1981        .filter(|c| barred != Some(family(&c.source)))
1982        .cloned()
1983        .collect();
1984    choose_vantages(&kept, min)
1985}
1986
1987// ─── Verification: does the evidence a claim cites actually say it? ──────────
1988//
1989// Corroboration asks whether a claim holds BEYOND its origin; this asks the
1990// prior question — whether the origin ever said it. Complementary by
1991// construction: bee:suggested candidates cite no episode and can only be
1992// corroborated, llm-extracted candidates cite exactly one and can be vetted
1993// against it. No search, no tools, one model call per candidate: the
1994// evidence is handed over, not hunted for.
1995
1996/// What vetting a claim against its own origin can conclude.
1997#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1998pub enum Vet {
1999    /// The evidence says what the claim says.
2000    Supported,
2001    /// The evidence does not contain it. Judged against THIS evidence only —
2002    /// the claim may still be true; that question belongs to corroboration.
2003    Unsupported,
2004    /// The evidence shows the statement — about someone else. The wearable's
2005    /// diarization credits unknown speakers to the owner, so a claim wearing
2006    /// the wrong name is a distinct and common failure, and it is repaired
2007    /// by rebinding, not rejection.
2008    Misattributed,
2009    /// The evidence shows something weaker or narrower than the claim.
2010    Overreach,
2011    /// The evidence supports the content but the predicate mislabels the
2012    /// relationship — an event recorded as a durable property is the
2013    /// commonest shape (llm·has_role ran 2% accepted on TRUE sentences).
2014    /// The repair is a retype, not a rejection; `predicate` names it.
2015    Mistyped,
2016    /// The judge did not answer in form. A harness failure, never a finding.
2017    Unclear,
2018}
2019
2020impl Vet {
2021    pub fn as_str(self) -> &'static str {
2022        match self {
2023            Vet::Supported => "supported",
2024            Vet::Unsupported => "unsupported",
2025            Vet::Misattributed => "misattributed",
2026            Vet::Overreach => "overreach",
2027            Vet::Mistyped => "mistyped",
2028            Vet::Unclear => "unclear",
2029        }
2030    }
2031}
2032
2033pub const VET_SYS: &str = "\
2034You judge whether a piece of evidence supports a claim that was extracted \
2035from it. Only the evidence in front of you counts — no outside knowledge, \
2036no guessing at what other conversations might show. You will be told the \
2037exact reply form; keep to it.";
2038
2039/// Build the vet judge: no tools, no graph, nothing but the handed evidence.
2040///
2041/// Deliberately blind for the same reason the corroboration readers are
2042/// lensed: a judge that can search the graph will find the claim there —
2043/// extraction put it there — and call that support.
2044pub fn vet_judge(
2045    provider: Box<dyn crate::provider::Provider>,
2046    tool_ctx: ToolCtx,
2047    agent_cfg: crate::config::AgentConfig,
2048    model: Option<String>,
2049) -> Result<Agent> {
2050    let approver = Arc::new(crate::tool::ModeApprover {
2051        mode: crate::config::PermissionMode::ReadOnly,
2052    });
2053    let mut cfg = agent_cfg;
2054    cfg.system_prompt = Some(VET_SYS.to_string());
2055    Agent::new(
2056        provider,
2057        crate::tool::Registry::new(),
2058        approver,
2059        tool_ctx,
2060        cfg,
2061        model,
2062    )
2063}
2064
2065/// The question, evidence first and the imperative LAST — with a long input
2066/// a local model keeps the instruction it read most recently.
2067pub fn vet_question(cand: &Candidate, ev: &EvidenceClip) -> String {
2068    format!(
2069        "Evidence — one episode from {}, {}:\n\n---\n{}\n---\n\n\
2070         Claim extracted from that evidence:\n\n  {}\n{}{}\n\
2071         Judge whether THIS evidence supports THAT claim. Absence from the \
2072         evidence is UNSUPPORTED even if the claim sounds plausible. If the \
2073         evidence shows the statement but credits it to a different person \
2074         than the claim's subject, that is MISATTRIBUTED. If the evidence \
2075         shows a weaker or narrower version, that is OVERREACH. If the \
2076         evidence supports the content but the relation label mislabels it \
2077         — a one-time event filed as a durable property, or simply the \
2078         wrong relation — that is MISTYPED.\n\n\
2079         Reply in exactly this form:\n\
2080         VERDICT: SUPPORTED | UNSUPPORTED | MISATTRIBUTED | OVERREACH | MISTYPED\n\
2081         WHO: only for MISATTRIBUTED — who the evidence actually shows\n\
2082         PREDICATE: only for MISTYPED — a better relation name, lowercase_with_underscores\n\
2083         QUOTE: the evidence line that decides it, or 'nothing'",
2084        ev.source,
2085        ev.occurred_at,
2086        ev.body,
2087        cand.statement,
2088        cand.subject
2089            .as_deref()
2090            .map(|s| format!("  (subject: {s})\n"))
2091            .unwrap_or_default(),
2092        cand.predicate
2093            .as_deref()
2094            .map(|p| format!("  (relation label: {p})\n"))
2095            .unwrap_or_default(),
2096    )
2097}
2098
2099/// Parse the judge's reply; an out-of-form reply is `Unclear` and the
2100/// rejected text is kept — \"did not answer in form\" is not a diagnosis.
2101pub fn parse_vet(text: &str) -> (Vet, Option<String>, Option<String>, String) {
2102    let mut verdict = None;
2103    let mut who = None;
2104    let mut predicate = None;
2105    let mut quote = String::new();
2106    for line in text.lines() {
2107        let l = line
2108            .trim()
2109            .trim_start_matches(['*', '-', '#', '>', ' '])
2110            .trim_matches(['*', '`', ' '])
2111            .to_string();
2112        let upper = l.to_uppercase();
2113        let body = upper.strip_prefix("VERDICT:").map(str::trim);
2114        let word = body.unwrap_or(&upper);
2115        if verdict.is_none() {
2116            let head = word
2117                .split(|c: char| !c.is_ascii_alphabetic())
2118                .find(|w| !w.is_empty())
2119                .unwrap_or_default();
2120            // A bare word alone on its line counts; prose containing the
2121            // word does not.
2122            if body.is_some() || word.trim() == head {
2123                verdict = match head {
2124                    "SUPPORTED" => Some(Vet::Supported),
2125                    "UNSUPPORTED" => Some(Vet::Unsupported),
2126                    "MISATTRIBUTED" => Some(Vet::Misattributed),
2127                    "OVERREACH" => Some(Vet::Overreach),
2128                    "MISTYPED" => Some(Vet::Mistyped),
2129                    _ => None,
2130                };
2131            }
2132        }
2133        if let Some(rest) = l.strip_prefix("WHO:").or(l.strip_prefix("Who:")) {
2134            let w = rest.trim();
2135            if !w.is_empty() && !w.eq_ignore_ascii_case("n/a") {
2136                who = Some(w.to_string());
2137            }
2138        }
2139        if let Some(rest) = l
2140            .strip_prefix("PREDICATE:")
2141            .or(l.strip_prefix("Predicate:"))
2142        {
2143            let p = rest
2144                .trim()
2145                .trim_matches('`')
2146                .to_lowercase()
2147                .replace(' ', "_");
2148            if !p.is_empty() && p != "n/a" {
2149                predicate = Some(p);
2150            }
2151        }
2152        if let Some(rest) = l.strip_prefix("QUOTE:").or(l.strip_prefix("Quote:")) {
2153            quote = rest.trim().to_string();
2154        }
2155    }
2156    match verdict {
2157        Some(v) => (v, who, predicate, quote),
2158        None => (
2159            Vet::Unclear,
2160            None,
2161            None,
2162            format!("not in form; it said: {}", {
2163                let t: String = text.trim().chars().take(160).collect();
2164                t.replace('\n', " ")
2165            }),
2166        ),
2167    }
2168}
2169
2170/// One candidate, judged against the evidence it cites.
2171#[derive(Debug, Clone, Serialize)]
2172pub struct Vetting {
2173    pub candidate_id: i64,
2174    pub statement: String,
2175    pub verdict: Vet,
2176    /// For `Misattributed`: who the evidence actually shows. The repair is
2177    /// a rebind (review `b`), so the name is the finding.
2178    pub who: Option<String>,
2179    /// For `Mistyped`: the better relation name. The repair is a retype
2180    /// (review `e`), so the predicate is the finding.
2181    pub predicate: Option<String>,
2182    pub quote: String,
2183}
2184
2185/// Judge one candidate against its origin evidence. Errors when the
2186/// candidate carries none — the caller should have skipped it.
2187pub async fn vet(agent: &Agent, cx: &RunContext, cand: &Candidate) -> Result<Vetting> {
2188    let ev = cand
2189        .evidence
2190        .as_ref()
2191        .context("candidate has no origin evidence to vet against")?;
2192    let mut convo = Conversation::user(vet_question(cand, ev));
2193    let out = agent
2194        .run_in(cx, &mut convo, None)
2195        .await
2196        .with_context(|| format!("vet judge on candidate {}", cand.candidate_id))?;
2197    let (verdict, who, predicate, quote) = parse_vet(&out.text);
2198    Ok(Vetting {
2199        candidate_id: cand.candidate_id,
2200        statement: cand.statement.clone(),
2201        verdict,
2202        who,
2203        predicate,
2204        quote,
2205    })
2206}
2207
2208#[cfg(test)]
2209mod vet_tests {
2210    use super::*;
2211
2212    #[test]
2213    fn a_vet_verdict_is_parsed_or_admitted() {
2214        let (v, who, _, quote) =
2215            parse_vet("VERDICT: MISATTRIBUTED\nWHO: Mara\nQUOTE: Mara said she prefers DIY.");
2216        assert_eq!(v, Vet::Misattributed);
2217        assert_eq!(who.as_deref(), Some("Mara"));
2218        assert!(quote.contains("prefers DIY"));
2219
2220        // A mistype carries its repair, normalized to vocabulary shape.
2221        let (v, _, predicate, _) =
2222            parse_vet("VERDICT: MISTYPED\nPREDICATE: cared for\nQUOTE: was caring for the twins");
2223        assert_eq!(v, Vet::Mistyped);
2224        assert_eq!(predicate.as_deref(), Some("cared_for"));
2225
2226        assert_eq!(
2227            parse_vet("SUPPORTED").0,
2228            Vet::Supported,
2229            "a bare word alone is a format"
2230        );
2231        assert_eq!(parse_vet("**VERDICT:** OVERREACH").0, Vet::Overreach);
2232
2233        // Prose containing a verdict word is not a verdict, and the
2234        // rejected text is kept.
2235        let (v, _, _, quote) = parse_vet("I believe this is supported by the transcript.");
2236        assert_eq!(v, Vet::Unclear);
2237        assert!(quote.contains("I believe"), "the rejected text is kept");
2238    }
2239
2240    #[test]
2241    fn the_question_puts_the_imperative_last() {
2242        // The harness lesson that cost the most reruns: with a long input a
2243        // 35B keeps the instruction it read most recently, so the evidence
2244        // must come first and the reply form last.
2245        let cand = Candidate {
2246            candidate_id: 1,
2247            statement: "Luke prefers DIY.".into(),
2248            subject: Some("Dana Whitfield".into()),
2249            origin_source: None,
2250            subject_ambiguous: false,
2251            confidence: None,
2252            predicate: Some("related_to".into()),
2253            evidence: Some(EvidenceClip {
2254                source: "bee.conversation".into(),
2255                occurred_at: "2026-08-01".into(),
2256                body: "a long transcript".into(),
2257            }),
2258        };
2259        let q = vet_question(&cand, cand.evidence.as_ref().unwrap());
2260        let ev_at = q.find("a long transcript").unwrap();
2261        let claim_at = q.find("Luke prefers DIY.").unwrap();
2262        let form_at = q.rfind("VERDICT:").unwrap();
2263        assert!(ev_at < claim_at && claim_at < form_at);
2264    }
2265}