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
+50 -17
View File
@@ -49,8 +49,6 @@ pub struct Config {
pub lookback_hours: u32,
/// How many articles the lineup should contain (§3.6 stage B).
pub target_article_count: usize,
/// How many articles survive the heuristic pre-filter (§3.5).
pub prefilter_keep: usize,
/// Days of published EPUBs kept in `publish.epub_dir` (§3.11).
pub retention_days: u32,
/// How many XTC issues to keep in `publish.xtc_dir` (§3.11).
@@ -89,7 +87,6 @@ impl Default for Config {
timezone: "America/New_York".into(),
lookback_hours: 26,
target_article_count: 20,
prefilter_keep: 120,
retention_days: 21,
xtc_retention_count: 5,
max_daily_usd: 2.0,
@@ -142,6 +139,8 @@ pub struct DeepseekConfig {
pub api_key: Option<String>,
/// Articles per stage-A scoring request (§3.6).
pub score_batch_size: usize,
/// Articles per first-pass triage request (§10).
pub triage_batch_size: usize,
pub max_concurrent_requests: usize,
pub score_temperature: f32,
pub editorial_temperature: f32,
@@ -160,6 +159,7 @@ impl Default for DeepseekConfig {
model: "deepseek-v4-flash".into(),
api_key: None,
score_batch_size: 12,
triage_batch_size: 25,
max_concurrent_requests: 4,
score_temperature: 0.3,
editorial_temperature: 0.8,
@@ -269,6 +269,8 @@ impl Default for VoyageConfig {
pub struct CurationConfig {
/// Absolute issue-size ceiling; the editor has no minimum (§13).
pub max_article_count: usize,
pub recent_rejection_days: i64,
pub recent_rejection_floor: f64,
/// Miniflux feed ids or site URLs that can never be dropped (§3.5).
pub always_include_feeds: Vec<String>,
/// Hosts excluded outright (§3.5).
@@ -286,6 +288,8 @@ impl Default for CurationConfig {
fn default() -> Self {
Self {
max_article_count: 28,
recent_rejection_days: 7,
recent_rejection_floor: 3.0,
always_include_feeds: Vec::new(),
blocked_domains: Vec::new(),
paywall_domains: Vec::new(),
@@ -602,6 +606,22 @@ impl Config {
Some(p) => (Some(p.to_path_buf()), true),
None => (Some(PathBuf::from(DEFAULT_CONFIG_FILE)), false),
};
if let Some(path) = path.as_deref().filter(|path| path.exists()) {
let raw = std::fs::read_to_string(path).map_err(|error| {
ConfigError::Invalid(format!("could not inspect {}: {error}", path.display()))
})?;
if raw.lines().any(|line| {
let line = line.trim_start();
!line.starts_with('#')
&& line
.strip_prefix("prefilter_keep")
.is_some_and(|tail| tail.trim_start().starts_with('='))
}) {
return Err(ConfigError::Invalid(
"prefilter_keep was removed; use curation.ranking.deep_keep".into(),
));
}
}
let mut config: Config = Self::figment(path.as_deref(), require)?.extract()?;
// §1 tells the operator to set `DAILY_EPUB_SECRET`; §3.14 calls the key
// `server.hmac_secret`. Accept both, with the explicit key winning.
@@ -624,11 +644,6 @@ impl Config {
"target_article_count must be > 0".into(),
));
}
if self.prefilter_keep < self.target_article_count {
return Err(ConfigError::Invalid(
"prefilter_keep must be >= target_article_count".into(),
));
}
if self.curation.max_article_count < self.target_article_count {
return Err(ConfigError::Invalid(
"curation.max_article_count must be >= target_article_count".into(),
@@ -639,6 +654,11 @@ impl Config {
"deepseek.score_batch_size must be >= 1".into(),
));
}
if self.deepseek.triage_batch_size == 0 {
return Err(ConfigError::Invalid(
"deepseek.triage_batch_size must be >= 1".into(),
));
}
if self.deepseek.max_concurrent_requests == 0 {
return Err(ConfigError::Invalid(
"deepseek.max_concurrent_requests must be >= 1".into(),
@@ -753,11 +773,14 @@ mod tests {
assert_eq!(c.timezone, "America/New_York");
assert_eq!(c.lookback_hours, 26);
assert_eq!(c.target_article_count, 20);
assert_eq!(c.prefilter_keep, 120);
assert_eq!(c.curation.ranking.deep_keep, 120);
assert_eq!(c.retention_days, 21);
assert_eq!(c.max_daily_usd, 2.0);
assert!(c.world_briefing);
assert_eq!(c.deepseek.model, "deepseek-v4-flash");
assert_eq!(c.deepseek.triage_batch_size, 25);
assert_eq!(c.curation.recent_rejection_days, 7);
assert_eq!(c.curation.recent_rejection_floor, 3.0);
assert_eq!(c.profile_path, PathBuf::from("data/profile.md"));
assert_eq!(c.curation.feedback.good_value, 0.35);
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
@@ -844,6 +867,17 @@ mod tests {
assert!(message.contains("epub_dir"), "{message}");
}
#[test]
fn removed_prefilter_keep_fails_loudly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "prefilter_keep = 120\n").unwrap();
let error = Config::load(Some(&path)).expect_err("the stale key must be rejected");
let message = error.to_string();
assert!(message.contains("prefilter_keep"), "{message}");
assert!(message.contains("curation.ranking.deep_keep"), "{message}");
}
#[test]
fn shipped_example_config_parses() {
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
@@ -853,6 +887,7 @@ mod tests {
assert_eq!(c.server.bind, "127.0.0.1:3499");
assert_eq!(c.deepseek.base_url, "https://api.deepseek.com/v1");
assert_eq!(c.deepseek.max_concurrent_requests, 4);
assert_eq!(c.deepseek.triage_batch_size, 25);
assert!(c.anthropic.enabled);
assert_eq!(c.anthropic.model, "claude-opus-5");
assert_eq!(c.anthropic.effort, "high");
@@ -886,6 +921,9 @@ mod tests {
c.deepseek.score_batch_size = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.deepseek.triage_batch_size = 0;
assert!(c.validate().is_err());
let mut c = Config::default();
c.editorial.summary_input_tokens = 0;
assert!(c.validate().is_err());
}
@@ -956,14 +994,9 @@ mod tests {
#[test]
fn validation_rejects_nonsense() {
assert!(
Config {
prefilter_keep: 5,
..Config::default()
}
.validate()
.is_err()
);
let mut too_small = Config::default();
too_small.curation.ranking.deep_keep = 5;
assert!(too_small.validate().is_err());
assert!(
Config {
timezone: "Mars/Olympus_Mons".into(),