feat(decide): memory rerank and paper triage on the decision tier
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:
co-authored by
Claude Opus 5
parent
2656d73def
commit
33560c7fe6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user