feat(decide): memory rerank and paper triage on the decision tier
deploy / test (push) Successful in 5m23s
deploy / build (push) Successful in 5m27s

mission_memory::recall is two stages when a key is set: BM25 proposes 8
candidates, one Noul per candidate ('this earlier verdict is relevant to
the task') reorders them and drops those under 0.3, three are kept. BM25
measures keyword overlap, and a verdict about MICROVM.md shares words
with every task that names a file. Without a key the BM25 order stands.

continuous_research: topic_tags was written as [] on every manifest line
since the manifest existed. triage_papers asks, per harvested paper, a
Choice over the mission's topics (readable names, the arXiv query as the
description, 'none' offered) and a four-level relevance Score; the tags
(every topic ≥ 0.3) and {score, confidence} land on the line the agents
already read. Probe on PORTICO's abstract: relevance 3.0 at 1.0; topic
'none' 0.59 / verification 0.41 — true, the topic list has no
authority/sandboxing entry. Untriaged papers write the old empty line.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-21 14:09:28 -05:00
co-authored by Claude Opus 5
parent 2656d73def
commit 33560c7fe6
4 changed files with 255 additions and 17 deletions
+161 -7
View File
@@ -128,32 +128,161 @@ pub fn write_manifest(
checkout: &std::path::Path,
papers: &[crate::papers::Paper],
date: &str,
triage: &[PaperTriage],
) -> Result<std::path::PathBuf, String> {
let rel = manifest_path(date);
let abs = checkout.join(&rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let body = manifest_lines(papers, date);
let body = manifest_lines(papers, date, triage);
std::fs::write(&abs, format!("{body}\n")).map_err(|e| format!("write {}: {e}", abs.display()))?;
Ok(abs)
}
/// What the decision model said about one harvested paper. `topic_tags`
/// was written as `[]` on every manifest line from the day the manifest
/// existed — a slot the agents were told to read and nothing filled. This
/// fills it: which of the mission's topics the paper belongs to (a Choice,
/// every topic ≥ 0.3 kept, `none` when it fits none), and how much it
/// matters to an agent platform (a four-level Score), so the ranking phase
/// starts from a calibrated number rather than from the title alone.
#[derive(Debug, Clone, serde::Serialize, Default)]
pub struct PaperTriage {
pub topic_tags: Vec<String>,
/// 0 = unrelated … 3 = directly about what the platform does. `None`
/// when no triage ran (no key, or the call failed).
pub relevance: Option<f64>,
pub relevance_confidence: Option<f64>,
}
const RELEVANCE_LEVELS: [&str; 4] = [
"Unrelated to LLM agents, agent platforms, or their evaluation",
"Adjacent: language models or systems work an agent platform might one day draw on",
"Relevant: about agents, tools, memory, skills, judging, sandboxing, or multi-agent orchestration",
"Directly applicable: a method, measurement, or failure mode a platform running autonomous coding and research agents should act on",
];
/// Triage every harvested paper in one call each. Best-effort: a missing key
/// or a failed call leaves that paper's tags empty and relevance `None`, the
/// state the manifest has always been in.
pub async fn triage_papers(
papers: &[crate::papers::Paper],
topics: &[String],
) -> Vec<PaperTriage> {
use cm_decide::{Answer, Decider as _, Question};
let Some(jev) = cm_decide::jev::Jev::from_env() else {
return vec![PaperTriage::default(); papers.len()];
};
// Topics are arXiv query strings; the option NAME the model sees is the
// readable form (`all:"agent memory" AND all:"long-term"` → `agent memory
// long-term`), the value the query itself for precision.
let mut criteria: std::collections::BTreeMap<String, Option<String>> = topics
.iter()
.map(|t| (readable_topic(t), Some(t.clone())))
.collect();
criteria.insert("none".into(), Some("Fits none of the listed topics".into()));
let questions: std::collections::BTreeMap<String, Question> = [
(
"topic".to_string(),
Question::Choice {
instructions: "Which of these research topics is this paper about?".into(),
criteria,
},
),
(
"relevance".to_string(),
Question::score(
"How relevant is this paper to a platform that runs autonomous LLM coding and research agents?",
RELEVANCE_LEVELS,
),
),
]
.into_iter()
.collect();
let mut out = Vec::with_capacity(papers.len());
for p in papers {
let state = format!("Title: {}\n\nAbstract: {}", p.title, p.summary);
let decided = tokio::time::timeout(
std::time::Duration::from_secs(10),
jev.decide(&state, &questions),
)
.await;
let mut t = PaperTriage::default();
match decided {
Ok(Ok(d)) => {
if let Some(Answer::Choice { probabilities, .. }) = d.answers.get("topic") {
let mut tags: Vec<(String, f64)> = probabilities
.iter()
.filter(|(k, p)| k.as_str() != "none" && **p >= 0.3)
.map(|(k, p)| (k.clone(), *p))
.collect();
tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
t.topic_tags = tags.into_iter().map(|(k, _)| k).collect();
}
if let Some(Answer::Score { score, confidence, .. }) = d.answers.get("relevance") {
t.relevance = Some((*score * 100.0).round() / 100.0);
t.relevance_confidence = Some((*confidence * 100.0).round() / 100.0);
}
}
Ok(Err(e)) => eprintln!("continuous_research: triage of {} failed: {e}", p.arxiv_id),
Err(_) => eprintln!("continuous_research: triage of {} timed out", p.arxiv_id),
}
out.push(t);
}
let tagged = out.iter().filter(|t| !t.topic_tags.is_empty()).count();
eprintln!(
"continuous_research: triaged {} paper(s) with {}: {tagged} tagged, {} scored",
papers.len(),
jev.name(),
out.iter().filter(|t| t.relevance.is_some()).count()
);
out
}
/// `all:"agent memory" AND all:"long-term"` → `agent memory long-term`.
fn readable_topic(query: &str) -> String {
let words: Vec<&str> = query
.split(|c: char| c == '"' || c.is_whitespace() || c == '(' || c == ')')
.filter(|w| !w.is_empty())
.filter(|w| !matches!(*w, "AND" | "OR" | "NOT"))
.map(|w| w.strip_prefix("all:").unwrap_or(w))
.map(|w| w.strip_prefix("ti:").unwrap_or(w))
.map(|w| w.strip_prefix("abs:").unwrap_or(w))
.filter(|w| !w.is_empty())
.collect();
words.join(" ")
}
/// The manifest lines for a set of freshly shelved papers.
///
/// Shape matches what `templates/teams/continuous_research.toml` documents:
/// `{ source, url, title, snippet, first_seen, topic_tags }`.
pub fn manifest_lines(papers: &[crate::papers::Paper], first_seen: &str) -> String {
/// `{ source, url, title, snippet, first_seen, topic_tags }`, plus
/// `relevance` since 2026-09-21 (see [`PaperTriage`]). `triage` is
/// positional with `papers`; shorter means the rest are untriaged.
pub fn manifest_lines(
papers: &[crate::papers::Paper],
first_seen: &str,
triage: &[PaperTriage],
) -> String {
papers
.iter()
.map(|p| {
.enumerate()
.map(|(i, p)| {
let t = triage.get(i).cloned().unwrap_or_default();
json!({
"source": p.source_id(),
"url": format!("https://arxiv.org/abs/{}", p.arxiv_id),
"title": p.title,
"snippet": p.summary.chars().take(400).collect::<String>(),
"first_seen": first_seen,
"topic_tags": [],
"topic_tags": t.topic_tags,
"relevance": t.relevance.map(|r| json!({
"score": r,
"confidence": t.relevance_confidence,
"scale": "0 unrelated … 3 directly applicable",
})),
})
.to_string()
})
@@ -198,6 +327,18 @@ mod tests {
}
}
#[test]
fn arxiv_queries_become_readable_option_names() {
assert_eq!(
readable_topic(r#"all:"agent memory" AND all:"long-term""#),
"agent memory long-term"
);
assert_eq!(
readable_topic(r#"all:"agentic topology" OR all:"multi-agent topology""#),
"agentic topology multi-agent topology"
);
}
#[test]
fn the_date_stamp_is_zero_padded() {
let d = today();
@@ -217,12 +358,25 @@ mod tests {
published: "2026-08-17".into(),
pdf_url: "https://arxiv.org/pdf/2401.12345".into(),
};
let out = manifest_lines(std::slice::from_ref(&p), "2026-08-17");
let out = manifest_lines(std::slice::from_ref(&p), "2026-08-17", &[]);
assert_eq!(out.lines().count(), 1);
let v: serde_json::Value = serde_json::from_str(&out).expect("each line is JSON");
for key in ["source", "url", "title", "snippet", "first_seen", "topic_tags"] {
for key in ["source", "url", "title", "snippet", "first_seen", "topic_tags", "relevance"] {
assert!(v.get(key).is_some(), "missing {key} in {v}");
}
// Untriaged: the slot is there and empty, as it always was.
assert_eq!(v["topic_tags"], serde_json::json!([]));
assert!(v["relevance"].is_null());
// Triaged: the tags and the score land on the line.
let t = PaperTriage {
topic_tags: vec!["agent memory long-term".into()],
relevance: Some(2.4),
relevance_confidence: Some(0.7),
};
let out = manifest_lines(std::slice::from_ref(&p), "2026-08-17", std::slice::from_ref(&t));
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["topic_tags"][0], "agent memory long-term");
assert_eq!(v["relevance"]["score"], 2.4);
assert_eq!(v["source"], "arxiv:2401.12345");
assert!(
v["snippet"].as_str().unwrap().chars().count() <= 400,