Curation v2 step 4: triage replaces the gate

DeepSeek triage over every eligible article (plan §10) with cached
assessments in article_assessments, the union admission with quotas and
exploration slots (§11), hygiene moved to admit.rs with the churn rule
reading assessments, prefilter.rs reduced to hygiene and text heuristic,
prefilter_keep removed in favour of curation.ranking.deep_keep, the
scores table dropped (migration 0003), and --rescore on generate.

Implemented by Codex (gpt-5.4, high effort) from
docs/plans/curation-v2-briefs/step4.md; reviewed against plan §10–§11.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 15:25:04 +00:00
co-authored by Claude Fable 5.1
parent 76527e3821
commit 10f4afd091
18 changed files with 1927 additions and 816 deletions
+41 -1
View File
@@ -15,7 +15,7 @@ use sqlx::Row as _;
use crate::curate::signals::{Neighbour, Signals, TopInterest};
use crate::db::{Db, fmt_ts};
use crate::types::ArticleId;
use crate::types::{ArticleId, Candidate};
/// The stage vocabulary of §7.4, in pipeline order.
pub const STAGES: [&str; 7] = [
@@ -192,6 +192,22 @@ pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
.unwrap_or_else(|_| "{}".into())
}
/// Serialize a full candidate, adding the triage assessment and admission flags
/// that are not cheap-signal fields (§7.5).
pub fn serialize_candidate(candidate: &Candidate) -> String {
let base = serialize_signals(&candidate.signals, candidate.auto_include);
let mut value: SignalsJson = serde_json::from_str(&base).unwrap_or_default();
value.exploration = candidate.exploration;
if let Some(triage) = candidate.assessment.triage.as_ref() {
value.raw.insert("triage".into(), triage.interest);
value
.norm
.insert("triage".into(), (triage.interest / 10.0).clamp(0.0, 1.0));
value.present.insert("triage".into(), true);
}
serde_json::to_string(&value).unwrap_or_else(|_| "{}".into())
}
// ---------------------------------------------------------------------------
// `explain` (§15.2)
// ---------------------------------------------------------------------------
@@ -664,6 +680,30 @@ mod tests {
assert!(typed.blend().is_some());
}
#[test]
fn candidate_json_adds_triage_and_exploration() {
let mut candidate = crate::types::Candidate::new(
crate::curate::prefilter::tests::article(1, "Article", 900),
false,
);
candidate.signals = signals(41.0, 0.55);
candidate.exploration = true;
candidate.assessment.triage = Some(crate::types::Triage {
interest: 7.5,
kind: "essay".into(),
why: "specific".into(),
model: "mock".into(),
prompt_version: 1,
assessed_at: "2026-09-02T05:30:00Z".parse().unwrap(),
});
let parsed: serde_json::Value =
serde_json::from_str(&serialize_candidate(&candidate)).unwrap();
assert_eq!(parsed["raw"]["triage"], 7.5);
assert_eq!(parsed["norm"]["triage"], 0.75);
assert_eq!(parsed["present"]["triage"], true);
assert_eq!(parsed["exploration"], true);
}
#[tokio::test]
async fn rows_are_upserted_with_every_column_replaced() {
let (_dir, db) = db_with_articles(&[1]).await;