blob: 4dd1b71b221c950767305de1fb4bebdf22dc741a [file]
//! The librarian's first rung: a survey of the record and its telemetry that proposes
//! curation, and files each proposal as a task for a cheap-model run to do with the
//! memory server's own tools. The paper's librarian verifies stale facts, merges
//! duplicates, writes and maintains the summaries the index serves, splits oversized
//! files, and acts on telemetry: failed queries become authoring tasks. Everything here
//! stays within the paper's containment: the librarian proposes, a task is born
//! proposed unless the domain admits its own filings, every curation write is an entry
//! that `supersedes` another and deletes nothing, and the survey itself writes nothing.
use dm_core::{Entry, EntryType, TrustTier};
use dm_server::reads;
use dm_server::Memory;
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Component, Path};
/// What a proposal asks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
/// A summary the index serves that is missing, a table, a heading, too short or clipped.
Resummarise,
/// Two live entries whose titles are the same once normalised.
Merge,
/// A raw entry past its review date, or older than the window with no review date.
Verify,
/// A body too large for one entry.
Split,
/// A question searched for more than once that found nothing: something to write.
Author,
}
impl Kind {
fn title(self) -> &'static str {
match self {
Kind::Resummarise => "Librarian: rewrite the summary of",
Kind::Merge => "Librarian: merge",
Kind::Verify => "Librarian: verify",
Kind::Split => "Librarian: split",
Kind::Author => "Librarian: write what was asked for and not found",
}
}
fn name(self) -> &'static str {
match self {
Kind::Resummarise => "resummarise",
Kind::Merge => "merge",
Kind::Verify => "verify",
Kind::Split => "split",
Kind::Author => "author",
}
}
}
/// One thing the survey found.
#[derive(Clone, Debug, Serialize)]
pub struct Proposal {
pub kind: Kind,
/// The entries concerned, by id.
pub entries: Vec<String>,
/// Why, in a sentence.
pub why: String,
/// What the task body tells the run to do.
pub body: String,
}
/// The survey's result.
#[derive(Debug, Default, Serialize)]
pub struct Survey {
pub domain: String,
pub entries: usize,
pub proposals: Vec<Proposal>,
}
/// Options for a survey.
#[derive(Clone, Debug)]
pub struct Options {
/// Days after which a raw entry with no review date is due for a look.
pub stale_days: i64,
/// Characters of body above which an entry is split.
pub split_chars: usize,
/// How many entries to read.
pub limit: usize,
/// Where an entry's `file:` sources are resolved: a source whose file changed after
/// the entry was written is the trigger for a verify.
pub source_root: Option<std::path::PathBuf>,
}
impl Default for Options {
fn default() -> Self {
Options {
stale_days: 30,
split_chars: 12_000,
limit: 2000,
source_root: None,
}
}
}
/// A source that names a file: `file:<path>`, or a path that exists under the root.
fn source_file(source: &str, root: &Path) -> Option<std::path::PathBuf> {
// `file:` names a file whatever its name; without it, ids and links are not paths
let rel = match source.strip_prefix("file:") {
Some(rel) => rel,
None if source.contains("://")
|| source.starts_with("transcript:")
|| source.starts_with("task-")
|| source.starts_with("mem-") =>
{
return None
}
None => source,
};
// a source names a file in the working tree, and only the real path, links followed,
// says whether it is still there
let full = root
.join(under_root(Path::new(rel), root)?)
.canonicalize()
.ok()?;
let root = root.canonicalize().ok()?;
(full.starts_with(&root) && full.is_file()).then_some(full)
}
/// A source's path relative to the root: a relative one that never climbs above it, or
/// an absolute one inside it. Decided on the text, so a path that leaves the root is
/// never looked up.
fn under_root(p: &Path, root: &Path) -> Option<std::path::PathBuf> {
let rel = if p.is_absolute() {
match p.strip_prefix(root) {
Ok(r) => r.to_path_buf(),
Err(_) => p
.strip_prefix(root.canonicalize().ok()?)
.ok()?
.to_path_buf(),
}
} else {
p.to_path_buf()
};
let mut depth = 0usize;
for c in rel.components() {
match c {
Component::Normal(_) => depth += 1,
Component::CurDir => {}
Component::ParentDir => depth = depth.checked_sub(1)?,
Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(rel)
}
/// Whether a source file changed after the entry was written: the source-change trigger.
fn source_changed(e: &Entry, root: &Path) -> Option<(String, time::OffsetDateTime)> {
for s in &e.front.source {
let Some(path) = source_file(s, root) else {
continue;
};
let Ok(modified) = path.metadata().and_then(|m| m.modified()) else {
continue;
};
let modified = time::OffsetDateTime::from(modified);
if modified > e.front.created {
return Some((s.clone(), modified));
}
}
None
}
fn normalise(title: &str) -> String {
title
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Whether a summary is one the index should not be serving.
#[must_use]
pub fn poor_summary(summary: &str) -> Option<&'static str> {
let s = summary.trim();
if s.is_empty() {
return Some("it has none");
}
if s.starts_with('|') || s.contains("|---") {
return Some("it is a table, not a sentence");
}
if s.starts_with('#') {
return Some("it is a heading, not a sentence");
}
if s.chars().count() < 25 {
return Some("it is shorter than a sentence");
}
if s.ends_with('…') && s.chars().count() >= 200 {
return Some("it is the first paragraph clipped, not a summary written for the index");
}
None
}
/// Survey a domain: the record for what needs curation, the read log for what was
/// asked and not found.
///
/// # Errors
///
/// When the record cannot be read.
pub async fn survey(
memory: &Memory,
root: &Path,
domain: Option<&str>,
options: &Options,
) -> Result<Survey, Box<dyn std::error::Error>> {
// a working tree that is not there turns off the source-change trigger for every
// entry, which a survey would otherwise report as nothing to verify
if let Some(r) = options.source_root.as_deref().filter(|r| !r.is_dir()) {
return Err(format!(
"the working tree {} is not a directory, so no `file:` source can be checked",
r.display()
)
.into());
}
let domain = memory.resolve_domain(domain)?;
let hits = memory.list(Some(&domain), None, options.limit).await?;
let mut survey = Survey {
domain: domain.clone(),
entries: hits.len(),
proposals: Vec::new(),
};
let today = time::OffsetDateTime::now_utc().date();
let mut by_title: BTreeMap<String, Vec<String>> = BTreeMap::new();
for h in &hits {
let Ok(e) = memory.get(&h.id).await else {
continue;
};
by_title
.entry(normalise(&e.title()))
.or_default()
.push(e.front.id.clone());
survey_entry(&e, &h.summary, options, today, &mut survey.proposals);
}
merges(by_title, &mut survey.proposals);
authoring(root, &mut survey.proposals);
let live: std::collections::BTreeSet<String> = hits.iter().map(|h| h.id.clone()).collect();
signals(root, &live, &mut survey.proposals);
Ok(survey)
}
/// The paper's retrieval signals, from the read log: entries a search keeps returning
/// together are merge candidates, and an entry whose body keeps being opened after its
/// summary was served is one whose summary did not say enough.
fn signals(root: &Path, live: &std::collections::BTreeSet<String>, out: &mut Vec<Proposal>) {
let all = reads::all(root).unwrap_or_default();
let together = reads::co_retrieved(&all, |i| live.contains(i));
let mut opened: BTreeMap<String, usize> = BTreeMap::new();
for r in all.iter().filter(|r| r.tool == "get") {
for id in r.ids.iter().filter(|i| live.contains(*i)) {
*opened.entry(id.clone()).or_default() += 1;
}
}
let already: std::collections::BTreeSet<Vec<String>> =
out.iter().map(|p| p.entries.clone()).collect();
for ((a, b), n) in together.into_iter().filter(|(_, n)| *n >= 3) {
if already.contains(&vec![a.clone(), b.clone()]) {
continue;
}
out.push(Proposal {
kind: Kind::Merge,
entries: vec![a.clone(), b.clone()],
why: format!("`{a}` and `{b}` were retrieved together {n} times"),
body: format!(
"Read `{a}` and `{b}` with `get`. Searches keep returning them together, which is how a split topic looks. If they say the same thing, `write` one entry that holds what both say, with `supersedes` naming the older one; if they differ, give each a title that says how. Change no facts. Leave a handoff."
),
});
}
for (id, n) in opened.into_iter().filter(|(_, n)| *n >= 3) {
if already.contains(&vec![id.clone()]) {
continue;
}
out.push(Proposal {
kind: Kind::Resummarise,
entries: vec![id.clone()],
why: format!("`{id}` was opened {n} times after its summary was served, so the summary did not say enough"),
body: format!(
"Read `{id}` with `get`. Sessions keep opening its body after seeing its summary: write a one- or two-sentence summary that says what they were looking for, the kind a search hit should show. 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."
),
});
}
}
/// The proposals one entry gives rise to: its summary, its size, its age.
fn survey_entry(
e: &Entry,
summary: &str,
options: &Options,
today: time::Date,
out: &mut Vec<Proposal>,
) {
let id = &e.front.id;
// a handoff is a report for the next session, not a fact the index should curate
let handoff = e.front.entry_type == EntryType::Handoff;
if let (false, Some(why)) = (handoff, poor_summary(summary)) {
out.push(Proposal {
kind: Kind::Resummarise,
entries: vec![id.clone()],
why: format!("the summary of `{id}` is not one the index should serve: {why}"),
body: format!(
"Read `{id}` with `get`. Write a one- or two-sentence summary of what it says and why it matters, the kind a search hit should show. 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."
),
});
}
let chars = e.body.chars().count();
if chars > options.split_chars {
out.push(Proposal {
kind: Kind::Split,
entries: vec![id.clone()],
why: format!("`{id}` is {chars} characters, more than one entry should hold"),
body: format!(
"Read `{id}` with `get`. It is too large for one entry. Split it into entries of one topic each, every one with a title and a one-sentence summary, written with `write`; the first carries `supersedes: {id}` and a short body pointing at the others by id. Change no facts. Leave a handoff listing the new ids."
),
});
}
// a canary is planted to be left alone
if e.front.source.iter().any(|s| s == "canary") {
return;
}
let changed = options
.source_root
.as_deref()
.and_then(|r| source_changed(e, r));
let stale = changed.is_some()
|| match e.front.review_by {
Some(d) => d < today,
None => {
e.front.trust_tier == TrustTier::Raw
&& (today - e.front.created.date()).whole_days() > options.stale_days
}
};
if stale && !handoff {
let why = match (&changed, e.front.review_by) {
(Some((source, at)), _) => format!(
"`{id}` cites `{source}`, which changed on {} after the entry was written",
at.date()
),
(None, Some(d)) => format!("`{id}` was due for review on {d}"),
(None, None) => format!(
"`{id}` is raw and older than {} days with no review date",
options.stale_days
),
};
out.push(Proposal {
kind: Kind::Verify,
entries: vec![id.clone()],
why,
body: verify_body(id),
});
}
}
/// The task that has a curator check a stale entry and correct or re-date it.
fn verify_body(id: &str) -> String {
format!(
"Read `{id}` with `get`. Check whether what it says still holds, against the record (`search`) and the working tree. If it holds, `write` it again unchanged with `supersedes: {id}` and a `review_by` a few months out. If it does not, write the corrected entry with `supersedes: {id}`: its body says what holds now, and its `source` names what shows it, as `file:<path>` relative to the working tree or as an entry id. If it holds, also call `promote` on the entry that write made, the id your `write` returned and not `{id}`, which it replaced, with `to: verified` and one sentence of why: that files a proposal a person approves; you never promote to verified yourself. Leave a handoff that names the entry you wrote and the one it supersedes, and says what you found and how you know; keep that out of the entry, so whoever checks it judges it on its sources first."
)
}
/// Live entries that share a title, once normalised, are merge candidates.
fn merges(by_title: BTreeMap<String, Vec<String>>, out: &mut Vec<Proposal>) {
for (title, ids) in by_title {
if ids.len() < 2 || title.is_empty() {
continue;
}
let named = ids
.iter()
.map(|i| format!("`{i}`"))
.collect::<Vec<_>>()
.join(" and ");
out.push(Proposal {
kind: Kind::Merge,
entries: ids.clone(),
why: format!("{} live entries share the title \"{title}\"", ids.len()),
body: format!(
"Read {named} with `get`. They share a title. If they say the same thing, `write` one entry that holds what both say, with `supersedes` naming the older one, and leave the other as it is; if they differ, give each a title that says how. Change no facts. Leave a handoff."
),
});
}
}
/// The sensory plane: what sessions searched for more than once and did not find.
fn authoring(root: &Path, out: &mut Vec<Proposal>) {
let mut misses: BTreeMap<String, usize> = BTreeMap::new();
for r in reads::all(root).unwrap_or_default() {
if r.tool == "search" && r.hits == 0 {
if let Some(q) = r.query.as_deref().map(normalise).filter(|q| q.len() >= 6) {
*misses.entry(q).or_default() += 1;
}
}
}
for (q, n) in misses.into_iter().filter(|(_, n)| *n >= 2) {
out.push(Proposal {
kind: Kind::Author,
entries: Vec::new(),
why: format!("\"{q}\" was searched for {n} times and found nothing"),
body: format!(
"Sessions searched the record for \"{q}\" {n} times and found nothing. Find out the answer from the working tree and the record, and `write` an entry that answers it, titled so that the same search finds it. If the answer cannot be found, write a question entry saying so. Leave a handoff."
),
});
}
}
/// File the proposals as tasks, one each, with `origin: librarian:<kind>` and a hash of
/// what they concern, so a survey run twice files each once. Returns the tasks' ids and
/// how many were already there.
///
/// # Errors
///
/// A ledger error.
pub async fn file(
memory: &Memory,
survey: &Survey,
) -> Result<(Vec<String>, usize), Box<dyn std::error::Error>> {
let mut ids = Vec::new();
let mut already = 0;
for p in &survey.proposals {
let subject = if p.entries.is_empty() {
p.why.clone()
} else {
p.entries.join(",")
};
let title = if p.entries.is_empty() {
p.kind.title().to_string()
} else {
format!("{} {}", p.kind.title(), p.entries.join(", "))
};
let created = memory
.file_own(dm_ledger::TaskSpec {
domain: survey.domain.clone(),
title,
body: format!("{}\n\n_{}_", p.body, p.why),
requirements: Vec::new(),
depends_on: Vec::new(),
origin: Some(format!("librarian:{}", p.kind.name())),
upstream_event: Some(dm_core::digest(&subject)[..16].to_string()),
created_by: String::new(),
classification: dm_core::Label::Public,
priority: -1,
})
.await?;
if created.deduplicated {
already += 1;
} else {
ids.push(created.task.id);
}
}
Ok((ids, already))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn a_summary_the_index_should_not_serve_is_named() {
assert_eq!(poor_summary(""), Some("it has none"));
assert_eq!(
poor_summary("| a | b |"),
Some("it is a table, not a sentence")
);
assert_eq!(
poor_summary("# Heading"),
Some("it is a heading, not a sentence")
);
assert_eq!(
poor_summary("Too short."),
Some("it is shorter than a sentence")
);
let clipped = format!("{}…", "x".repeat(199));
assert!(poor_summary(&clipped).is_some());
assert!(
poor_summary("The wiki stays on MoinMoin until the S2 spike says otherwise.").is_none()
);
assert_eq!(
normalise("DR-19 — Does the wiki stay on MoinMoin?"),
"dr19 does the wiki stay on moinmoin"
);
assert_eq!(Kind::Resummarise.name(), "resummarise");
}
#[test]
fn a_source_names_a_file_in_the_tree_and_nothing_outside_it() {
let dir = tempfile::tempdir().unwrap();
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join("sub")).unwrap();
std::fs::write(tree.join("in.md"), "x").unwrap();
std::fs::write(tree.join("mem-notes.md"), "x").unwrap();
std::fs::create_dir(tree.join("task-runner")).unwrap();
std::fs::write(tree.join("task-runner/x.md"), "x").unwrap();
let outside = dir.path().join("outside.txt");
std::fs::write(&outside, "x").unwrap();
for s in [
"file:in.md".to_string(),
"in.md".to_string(),
"file:./in.md".to_string(),
"file:sub/../in.md".to_string(),
format!("file:{}", tree.join("in.md").display()),
"file:mem-notes.md".to_string(),
"file:task-runner/x.md".to_string(),
] {
assert!(source_file(&s, &tree).is_some(), "{s}");
}
for s in [
outside.display().to_string(),
format!("file:{}", outside.display()),
"file:../outside.txt".to_string(),
"../outside.txt".to_string(),
"file:sub/../../outside.txt".to_string(),
format!("file:{}/../outside.txt", tree.display()),
] {
assert_eq!(source_file(&s, &tree), None, "{s}");
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
// a link out of the tree, as the file, as a directory on the way, and to a
// sibling whose name the tree's is a prefix of
symlink(&outside, tree.join("link.txt")).unwrap();
std::fs::create_dir(dir.path().join("outdir")).unwrap();
std::fs::write(dir.path().join("outdir/o.txt"), "x").unwrap();
symlink(dir.path().join("outdir"), tree.join("ld")).unwrap();
std::fs::create_dir(dir.path().join("tree-evil")).unwrap();
std::fs::write(dir.path().join("tree-evil/x.txt"), "x").unwrap();
symlink(dir.path().join("tree-evil"), tree.join("le")).unwrap();
for s in ["file:link.txt", "file:ld/o.txt", "file:le/x.txt"] {
assert_eq!(source_file(s, &tree), None, "{s}");
}
// a tree reached through a link is still the tree
let alias = dir.path().join("alias");
symlink(&tree, &alias).unwrap();
assert!(
source_file("file:in.md", &alias).is_some(),
"through a link"
);
}
}
#[test]
fn under_the_root_is_decided_on_the_text() {
let dir = tempfile::tempdir().unwrap();
let tree = dir.path().join("tree");
std::fs::create_dir(&tree).unwrap();
let t = tree.display();
for s in [
"../x".to_string(),
"sub/../../x".to_string(),
"/etc/passwd".to_string(),
format!("{t}/../x"),
format!("{t}-evil/x"),
] {
assert_eq!(under_root(Path::new(&s), &tree), None, "{s}");
}
assert_eq!(
under_root(Path::new("sub/../in.md"), &tree),
Some(PathBuf::from("sub/../in.md"))
);
assert_eq!(
under_root(&tree.join("in.md"), &tree),
Some(PathBuf::from("in.md"))
);
#[cfg(unix)]
{
// an absolute source given by the real path, the root by a link to it
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(&tree, &alias).unwrap();
assert_eq!(
under_root(&tree.canonicalize().unwrap().join("in.md"), &alias),
Some(PathBuf::from("in.md"))
);
}
}
#[test]
fn a_source_file_that_changed_after_the_entry_is_the_trigger() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("notes.md");
std::fs::write(&file, "v1").unwrap();
let mut e = dm_core::Entry {
front: dm_core::Frontmatter {
id: "mem-t-1".into(),
entry_type: EntryType::Fact,
domain: "dom-t".into(),
author: "a".into(),
created: time::OffsetDateTime::now_utc() + time::Duration::seconds(5),
source: vec!["file:notes.md".into(), "transcript:/x.jsonl".into()],
confidence: None,
trust_tier: TrustTier::Raw,
review_by: None,
supersedes: None,
policy_context: Vec::new(),
classification: dm_core::Label::Public,
status: None,
origin: None,
summary: None,
title: None,
},
body: "b".into(),
};
assert!(
source_changed(&e, dir.path()).is_none(),
"written after the file"
);
e.front.created = time::OffsetDateTime::now_utc() - time::Duration::days(1);
let (source, _) = source_changed(&e, dir.path()).unwrap();
assert_eq!(source, "file:notes.md");
assert!(source_file("https://x/y", dir.path()).is_none());
assert!(source_file("mem-t-2", dir.path()).is_none());
}
#[test]
fn a_verify_task_promotes_the_entry_it_wrote_not_the_one_it_read() {
let e = dm_core::Entry {
front: dm_core::Frontmatter {
id: "mem-t-1".into(),
entry_type: EntryType::Fact,
domain: "dom-t".into(),
author: "a".into(),
created: time::OffsetDateTime::now_utc(),
source: Vec::new(),
confidence: None,
trust_tier: TrustTier::Raw,
review_by: Some(time::OffsetDateTime::now_utc().date() - time::Duration::days(1)),
supersedes: None,
policy_context: Vec::new(),
classification: dm_core::Label::Public,
status: None,
origin: None,
summary: Some(
"The wiki stays on MoinMoin until the S2 spike says otherwise.".into(),
),
title: Some("Wiki".into()),
},
body: "b".into(),
};
let mut out = Vec::new();
let today = time::OffsetDateTime::now_utc().date();
survey_entry(
&e,
e.front.summary.as_deref().unwrap_or(""),
&Options::default(),
today,
&mut out,
);
let verify = out
.iter()
.find(|p| p.kind == Kind::Verify)
.expect("a verify task");
// the write that confirms it replaces it, so `mem-t-1` is superseded by then
assert!(
verify.body.contains("the id your `write` returned"),
"{}",
verify.body
);
}
#[test]
fn a_corrected_entry_leaves_the_reasoning_to_the_handoff() {
let body = verify_body("mem-t-1");
reasoning_goes_to_the_handoff(&body);
assert!(body.contains("one sentence of why"), "{body}");
}
#[test]
fn the_reasoning_check_refuses_reasons_put_in_the_entry() {
let good = verify_body("mem-t-1");
let held = "the entry is told to hold reasons";
for (from, to, rule) in [
(
"its body says what holds now",
"its body says what holds now and why you believe it",
held,
),
(
"its body says what holds now",
"its body says what holds now and what was wrong with the old one",
held,
),
(
"its body says what holds now",
"the body says what holds now, what you found and how you checked it",
held,
),
(
"its body says what holds now",
"the entry's body explains why the old one was wrong",
held,
),
(
"its body says what holds now",
"its body says what holds now. In the body, say how you know, drawing it out of the tree",
"not sent to the handoff",
),
(
"keep that out of the entry",
"keep that out of the entry; put the same account in the entry's `summary`",
held,
),
(
"keep that out of the entry",
"put the same account in the entry's `summary`",
held,
),
(
"keep that out of the entry",
"keep that out of the entry, and repeat what you found in the body as well",
held,
),
] {
let bad = good.replacen(from, to, 1);
assert_ne!(bad, good, "{to}");
let caught = std::panic::catch_unwind(|| reasoning_goes_to_the_handoff(&bad))
.expect_err(&format!("not caught: {to}"));
let said = caught
.downcast_ref::<String>()
.cloned()
.unwrap_or_default();
assert!(said.contains(rule), "{to}: caught for another reason: {said}");
}
// forbidding the reasons in the body is not asking for them there
let fine = good.replacen(
"keep that out of the entry",
"keep that out of the entry; keep how you know out of the body",
1,
);
reasoning_goes_to_the_handoff(&fine);
}
/// The rule both curation tasks that correct an entry follow: every sentence that asks
/// for the reasoning sends it to the handoff or keeps it out; what the entry is told to
/// hold carries no reasons; the task says to keep them out of the entry; the entry's
/// evidence is its `source`, in a form the librarian resolves; and the handoff names the
/// entry written, so the reasoning can be found from it. A check on wording, so it
/// catches the wordings it knows, and its own test lists them.
pub(crate) fn reasoning_goes_to_the_handoff(body: &str) {
const REASONS: [&str; 6] = [
"how you know",
"why",
"wrong",
"changed",
"what you found",
"account",
];
let sentences: Vec<&str> = body.split_inclusive(['.', ';']).collect();
let asks: Vec<&&str> = sentences
.iter()
.filter(|s| s.contains("how you know"))
.collect();
assert!(!asks.is_empty(), "nothing asks for the reasoning: {body}");
for s in asks {
assert!(
s.contains("handoff")
|| s.contains(" out of the entry")
|| s.contains(" out of the body"),
"the reasoning is not sent to the handoff: {s}"
);
}
// what the entry is told to hold: the rest of the sentence from where it names the
// body or the summary, and the clause that names it; a sentence that keeps the
// reasons out of the entry is not telling it to hold them
let keeps_out = |s: &str| s.contains(" out of the entry") || s.contains(" out of the body");
let told: Vec<String> = sentences
.iter()
.map(|s| s.split_inclusive(',').filter(|c| !keeps_out(c)).collect())
.collect();
for s in &told {
for held in ["body", "`summary`"] {
let Some(i) = s.find(held) else { continue };
let clause = s.split([',', ':']).find(|c| c.contains(held)).unwrap_or("");
assert!(
!REASONS
.iter()
.any(|r| s[i..].contains(r) || clause.contains(r)),
"the entry is told to hold reasons: {s}"
);
}
}
assert!(
body.contains("keep that out of the entry"),
"the task does not keep the reasons out of the entry: {body}"
);
assert!(
body.contains("`source`") && body.contains("`file:<path>`"),
"the evidence and its form are not named: {body}"
);
assert!(
body.contains("names the entry you wrote"),
"the handoff is not tied to the entry: {body}"
);
}
}