blob: 5f17fa33ca62b1dd27ab6c526e4eb6b4f0733c21 [file]
//! The checker: the librarian's independent counterpart. The paper's audit agents
//! verify that a summary entails its body, sampled continuously, on a different base
//! model and prompt lineage than the librarians, sharing no writable state with them.
//! Here the checker samples a domain's entries, asks a model of its own whether each
//! summary entails its body, and writes nothing to the record: a summary found wanting
//! becomes a task for a librarian run, with the checker's model and reason on it.
use dm_egress::Egress;
use dm_server::Memory;
use serde::Serialize;
/// What the checker found about one entry.
#[derive(Clone, Debug, Serialize)]
pub struct Verdict {
pub id: String,
pub title: String,
/// Whether the summary entails the body, in the model's judgement.
pub entails: bool,
/// The model's reason, when it said no.
pub why: String,
}
/// A checking pass.
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub domain: String,
/// The checker's model: by design not the one that wrote the summaries.
pub model: String,
pub checked: Vec<Verdict>,
}
/// The question put to the checker's model, one entry at a time.
#[must_use]
pub fn question(title: &str, summary: &str, body: &str) -> String {
format!(
"You are a checker auditing an index. Below are an entry's body and the one-line summary a search index serves for it. Does the summary entail the body: does it say nothing the body does not say, and leave out nothing a reader deciding whether to open the entry would need? Answer on the first line with exactly `yes` or `no`, and on the second line one sentence of reason.\n\n## Title\n\n{title}\n\n## Summary\n\n{summary}\n\n## Body\n\n{body}"
)
}
/// The model's answer as a verdict: `yes` or `no` on the first line, the reason after.
#[must_use]
pub fn verdict_of(answer: &str) -> (bool, String) {
let mut lines = answer
.trim()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty());
let first = lines.next().unwrap_or("").to_lowercase();
let first = first.trim_matches(|c: char| !c.is_alphanumeric());
let entails = first.starts_with("yes");
let why = lines.collect::<Vec<_>>().join(" ");
(entails, why)
}
/// Check up to `sample` entries of a domain, newest first, through `destination` with
/// `model`. Entries whose body is longer than the model should read in one go are
/// clipped to their first eight thousand characters, which is what a summary is for.
///
/// # Errors
///
/// When the record cannot be read or the model cannot be reached.
pub async fn check(
egress: &Egress,
memory: &Memory,
destination: &str,
model: &str,
domain: Option<&str>,
sample: usize,
spend: Option<&Spend<'_>>,
) -> Result<Report, Box<dyn std::error::Error>> {
let domain = memory.resolve_domain(domain)?;
let hits = sample_of(memory, &domain, sample).await?;
let mut report = Report {
domain,
model: model.to_string(),
checked: Vec::new(),
};
for h in hits {
if spend.is_some_and(Spend::exhausted) {
break;
}
let Ok(e) = memory.get(&h.id).await else {
continue;
};
if e.front.entry_type == dm_core::EntryType::Handoff {
continue;
}
let body: String = e.body.chars().take(8000).collect();
let (path, req) =
crate::mine::request_with(destination, model, &question(&h.title, &h.summary, &body));
let answer = egress.call(destination, &path, &req).await?;
let (entails, why) = verdict_of(&crate::mine::answer_text(destination, &answer));
report.checked.push(Verdict {
id: h.id.clone(),
title: h.title.clone(),
entails,
why,
});
}
Ok(report)
}
/// A token budget for one cycle's model calls, read back from the egress's own
/// metering of this session: what the checker has spent is what the egress saw.
pub struct Spend<'a> {
pub egress: &'a Egress,
pub session: &'a str,
/// Input and output tokens the cycle may spend in all.
pub limit: u64,
}
impl Spend<'_> {
/// Tokens this session has spent so far, input and output, as the egress counted.
#[must_use]
pub fn spent(&self) -> u64 {
self.egress
.telemetry()
.session(self.session)
.ok()
.flatten()
.map_or(0, |t| t.input_tokens + t.output_tokens)
}
/// Whether the budget is spent.
#[must_use]
pub fn exhausted(&self) -> bool {
self.spent() >= self.limit
}
}
/// The newest entries of a domain that are not handoffs: what the checker samples.
///
/// # Errors
///
/// When the record cannot be read.
pub async fn sample_of(
memory: &Memory,
domain: &str,
sample: usize,
) -> Result<Vec<dm_index::Hit>, Box<dyn std::error::Error>> {
// each kind's list is newest first; taking them in turn keeps that order near enough
let mut lists = Vec::new();
for kind in [
dm_core::EntryType::Fact,
dm_core::EntryType::Decision,
dm_core::EntryType::Convention,
dm_core::EntryType::Method,
] {
lists.push(
memory
.list(Some(domain), Some(kind), sample)
.await?
.into_iter(),
);
}
let mut hits = Vec::new();
while hits.len() < sample {
let before = hits.len();
for l in &mut lists {
if let Some(h) = l.next() {
hits.push(h);
}
}
if hits.len() == before {
break;
}
}
hits.truncate(sample);
Ok(hits)
}
/// Two entries the checker found at odds.
#[derive(Clone, Debug, Serialize)]
pub struct Contradiction {
pub a: String,
pub b: String,
pub why: String,
}
/// The question put to the model about two entries that retrieval puts side by side.
#[must_use]
pub fn contradiction_question(a_title: &str, a_body: &str, b_title: &str, b_body: &str) -> String {
format!(
"You are a checker auditing a record. Below are two entries that a search puts side by side. Do they contradict each other: does one state as true what the other states as false, or give a different answer to the same question? Two entries that merely cover different things, or one of which is more detailed, do not contradict. Answer on the first line with exactly `yes` or `no`, and on the second line one sentence naming the claim at odds.\n\n## Entry A: {a_title}\n\n{a_body}\n\n## Entry B: {b_title}\n\n{b_body}"
)
}
/// Hunt contradictions: for each of up to `sample` entries, newest first, the two
/// entries a search for its title ranks beside it, and the model's judgement on each
/// pair. Pairs are asked once whichever way round they come up.
///
/// # Errors
///
/// When the record cannot be read or the model cannot be reached.
pub async fn hunt(
egress: &Egress,
memory: &Memory,
destination: &str,
model: &str,
domain: Option<&str>,
sample: usize,
spend: Option<&Spend<'_>>,
) -> Result<Vec<Contradiction>, Box<dyn std::error::Error>> {
let domain = memory.resolve_domain(domain)?;
let hits = sample_of(memory, &domain, sample).await?;
let mut asked = std::collections::BTreeSet::new();
let mut found = Vec::new();
for h in hits {
if spend.is_some_and(Spend::exhausted) {
break;
}
if h.entry_type == dm_core::EntryType::Handoff {
continue;
}
let Ok(a) = memory.get(&h.id).await else {
continue;
};
let beside = memory.search(&h.title, Some(&domain), 3).await?;
for o in beside.into_iter().filter(|o| o.id != h.id).take(2) {
if o.entry_type == dm_core::EntryType::Handoff {
continue;
}
let mut pair = [h.id.clone(), o.id.clone()];
pair.sort();
if !asked.insert(pair.clone()) {
continue;
}
let Ok(b) = memory.get(&o.id).await else {
continue;
};
let clip = |t: &str| t.chars().take(5000).collect::<String>();
let q = contradiction_question(&h.title, &clip(&a.body), &o.title, &clip(&b.body));
let (path, req) = crate::mine::request_with(destination, model, &q);
let answer = egress.call(destination, &path, &req).await?;
let (yes, why) = verdict_of(&crate::mine::answer_text(destination, &answer));
if yes {
found.push(Contradiction {
a: pair[0].clone(),
b: pair[1].clone(),
why,
});
}
}
}
Ok(found)
}
/// The task that has a curator settle a contradiction the checker found.
fn reconcile_body(model: &str, c: &Contradiction) -> String {
format!(
"The checker, on {model}, found `{}` and `{}` at odds: {}\n\nRead both with `get`. Find out which is right, from the record and the working tree. `write` the corrected entry with `supersedes` naming the wrong one: its body says what is right, and its `source` names what shows it, as `file:<path>` relative to the working tree or as an entry id. If both are right about different things, give each a title that says which. Change no facts you cannot verify. Leave a handoff that names the entry you wrote and the one it supersedes, and says what was wrong and how you know; keep that out of the entry, so whoever checks it judges it on its sources first.",
c.a,
c.b,
if c.why.is_empty() { "no claim named" } else { &c.why }
)
}
/// File a task to reconcile each contradiction, once per pair.
///
/// # Errors
///
/// A ledger error.
pub async fn file_contradictions(
memory: &Memory,
domain: &str,
model: &str,
found: &[Contradiction],
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut ids = Vec::new();
for c in found {
let created = memory
.file_own(dm_ledger::TaskSpec {
domain: domain.to_string(),
title: format!("Librarian: reconcile {} and {}", c.a, c.b),
body: reconcile_body(model, c),
requirements: Vec::new(),
depends_on: Vec::new(),
origin: Some("checker:contradiction".into()),
upstream_event: Some(
dm_core::digest(&format!("{}\n{}", c.a, c.b))[..16].to_string(),
),
created_by: String::new(),
classification: dm_core::Label::Public,
priority: 1,
})
.await?;
if !created.deduplicated {
ids.push(created.task.id);
}
}
Ok(ids)
}
/// File a task for each summary the checker found wanting, with the checker's model
/// and reason, deduplicated on the entry and the summary it judged.
///
/// # Errors
///
/// A ledger error.
pub async fn file(
memory: &Memory,
report: &Report,
summaries: &[(String, String)],
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut ids = Vec::new();
for v in report.checked.iter().filter(|v| !v.entails) {
let summary = summaries
.iter()
.find(|(id, _)| id == &v.id)
.map(|(_, s)| s.as_str())
.unwrap_or("");
let created = memory
.file_own(dm_ledger::TaskSpec {
domain: report.domain.clone(),
title: format!("Librarian: rewrite the summary of {}, which the checker rejected", v.id),
body: format!(
"The checker, on {}, judged that the summary the index serves for `{id}` does not entail its body: {}\n\nRead `{id}` with `get`. Write a one- or two-sentence summary that says what the body says and no more. Then `write` the entry again with the same body and type, your summary as `summary`, and `supersedes: {id}`. Do not change the body. Leave a handoff naming the new entry.",
report.model,
if v.why.is_empty() { "no reason given" } else { &v.why },
id = v.id
),
requirements: Vec::new(),
depends_on: Vec::new(),
origin: Some("checker:summary".into()),
upstream_event: Some(dm_core::digest(&format!("{}\n{summary}", v.id))[..16].to_string()),
created_by: String::new(),
classification: dm_core::Label::Public,
priority: 0,
})
.await?;
if !created.deduplicated {
ids.push(created.task.id);
}
}
Ok(ids)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_verdict_is_the_first_word_and_the_reason_after() {
assert_eq!(
verdict_of("yes\nIt says what the body says."),
(true, "It says what the body says.".into())
);
assert_eq!(
verdict_of("No.\nThe summary claims a decision the body calls open."),
(
false,
"The summary claims a decision the body calls open.".into()
)
);
assert_eq!(verdict_of("**Yes** - fine"), (true, String::new()));
assert_eq!(verdict_of(""), (false, String::new()));
let q = question("T", "S", "B");
assert!(q.contains("## Summary\n\nS") && q.contains("## Body\n\nB"));
let q = contradiction_question("A", "a", "B", "b");
assert!(q.contains("## Entry A: A") && q.contains("## Entry B: B"));
}
#[test]
fn a_reconciled_entry_leaves_the_reasoning_to_the_handoff() {
let c = Contradiction {
a: "mem-t-1".into(),
b: "mem-t-2".into(),
why: "one says MoinMoin stays, the other that it went".into(),
};
crate::librarian::tests::reasoning_goes_to_the_handoff(&reconcile_body("m", &c));
}
}