Curation v2 step 6: Behind the paper, stats, run report block, lock
Behind-the-paper chapter (behind.xhtml, both editions) built from the run's StageCounts and candidate_runs near misses; daily-epub stats [--days N]; StageCounts gains knn/feed gates and verdicts_in_prompt, timings split into summaries + brief, the four-line §15.4 info block logged once per run and printed by print_report; src/lock.rs flock guard on <database_path>.lock for generate, profile rebuild, features backfill and backfill-social; README updated for the new CLI, env vars and costs. Implemented by a Claude agent from docs/plans/curation-v2-briefs/step6.md; reviewed against plan §5, §15. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
+151
-5
@@ -49,7 +49,7 @@ impl fmt::Display for RunStatus {
|
||||
}
|
||||
|
||||
/// Per-stage article counts as the pipeline narrows the day's feed volume (§2).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StageCounts {
|
||||
/// Entries returned by Miniflux inside the lookback window (§3.1).
|
||||
pub entries_fetched: i64,
|
||||
@@ -73,6 +73,15 @@ pub struct StageCounts {
|
||||
pub embedded: i64,
|
||||
/// Current rated articles with a valid embedding.
|
||||
pub rated_with_embeddings: i64,
|
||||
/// The neighbour signal's gate ramp, 0–1 (§9.2); 0 means the signal is absent.
|
||||
#[serde(default)]
|
||||
pub knn_gate: f64,
|
||||
/// The feed-affinity gate ramp, 0–1 (§9.3).
|
||||
#[serde(default)]
|
||||
pub feed_gate: f64,
|
||||
/// Explicit verdicts rendered into the system prompt (§8.4).
|
||||
#[serde(default)]
|
||||
pub verdicts_in_prompt: i64,
|
||||
/// Articles with a reusable or newly produced triage assessment.
|
||||
pub triaged: i64,
|
||||
/// Articles admitted to close reading.
|
||||
@@ -97,6 +106,10 @@ pub struct StageCounts {
|
||||
pub images_embedded: i64,
|
||||
}
|
||||
|
||||
/// Key under which Voyage usage sits in `provider_costs` (§7.6). Its token count
|
||||
/// is embedding input, so it stays out of the LLM `usage` aggregate.
|
||||
pub const VOYAGE_PROVIDER: &str = "voyage";
|
||||
|
||||
/// Wall-clock milliseconds per pipeline stage.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StageTimings(pub BTreeMap<String, i64>);
|
||||
@@ -186,13 +199,21 @@ impl RunReport {
|
||||
|
||||
/// Stamp the end time, total provider costs (LLM providers plus Voyage) and
|
||||
/// settle the status.
|
||||
///
|
||||
/// Voyage is counted once: through its `provider_costs` entry when the
|
||||
/// pipeline recorded one, else through `voyage_cost_usd`.
|
||||
pub fn finish(&mut self, finished_at: Timestamp) {
|
||||
self.finished_at = Some(finished_at);
|
||||
self.usage = TokenUsage::default();
|
||||
self.cost_usd = self.voyage_cost_usd;
|
||||
for provider in self.provider_costs.values() {
|
||||
self.usage.add(provider.usage);
|
||||
self.cost_usd += provider.cost_usd;
|
||||
self.cost_usd = 0.0;
|
||||
for (provider, usage) in &self.provider_costs {
|
||||
self.cost_usd += usage.cost_usd;
|
||||
if provider != VOYAGE_PROVIDER {
|
||||
self.usage.add(usage.usage);
|
||||
}
|
||||
}
|
||||
if !self.provider_costs.contains_key(VOYAGE_PROVIDER) {
|
||||
self.cost_usd += self.voyage_cost_usd;
|
||||
}
|
||||
if self.status == RunStatus::Running {
|
||||
self.status = if self.warnings.is_empty() {
|
||||
@@ -233,6 +254,68 @@ impl RunReport {
|
||||
)
|
||||
}
|
||||
|
||||
/// "23m12s" / "48s" for the log block and `stats`.
|
||||
pub fn format_duration(secs: i64) -> String {
|
||||
let secs = secs.max(0);
|
||||
if secs >= 60 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// The four-line info block of §15.4 (`curation:`, `admission:`,
|
||||
/// `preference:`, `providers:`), logged once per run and printed by the CLI.
|
||||
pub fn info_block(&self) -> [String; 4] {
|
||||
let c = &self.counts;
|
||||
let admitted = |name: &str| c.admitted_by.get(name).copied().unwrap_or(0);
|
||||
let gate = |value: f64| {
|
||||
if value > 0.0 {
|
||||
format!("{value:.2}")
|
||||
} else {
|
||||
"off".to_string()
|
||||
}
|
||||
};
|
||||
let providers = self
|
||||
.provider_costs
|
||||
.iter()
|
||||
.map(|(provider, usage)| format!("{provider} ${:.2}", usage.cost_usd))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let providers = if providers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
providers
|
||||
};
|
||||
[
|
||||
format!(
|
||||
"curation: {} considered → {} eligible → {} triaged → {} assessed → {} shortlisted → {} selected",
|
||||
c.articles, c.eligible, c.triaged, c.assessed, c.shortlisted, c.selected
|
||||
),
|
||||
format!(
|
||||
"admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}",
|
||||
admitted("triage"),
|
||||
admitted("interest"),
|
||||
admitted("knn"),
|
||||
admitted("exploration"),
|
||||
admitted("blend"),
|
||||
admitted("auto_include"),
|
||||
),
|
||||
format!(
|
||||
"preference: {} rated w/ embeddings → knn {} · feed {} · {} verdicts in prompt",
|
||||
c.rated_with_embeddings,
|
||||
gate(c.knn_gate),
|
||||
gate(c.feed_gate),
|
||||
c.verdicts_in_prompt
|
||||
),
|
||||
format!(
|
||||
"providers: {providers} · total ${:.2} · {}",
|
||||
self.cost_usd,
|
||||
Self::format_duration(self.duration_secs().unwrap_or(0))
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Feeds ordered by entry count, descending — the M1 dry-run breakdown.
|
||||
pub fn top_feeds(&self, limit: usize) -> Vec<(&str, i64)> {
|
||||
let mut v: Vec<(&str, i64)> = self
|
||||
@@ -289,6 +372,69 @@ mod tests {
|
||||
// The legacy aggregate columns are the sum across providers.
|
||||
assert_eq!(r.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
|
||||
assert_eq!(r.duration_secs(), Some(360));
|
||||
|
||||
// Once the pipeline records Voyage as a provider (§7.6) it is counted
|
||||
// there, not twice, and its tokens still stay out of the LLM aggregate.
|
||||
r.provider_costs.insert(
|
||||
VOYAGE_PROVIDER.into(),
|
||||
ProviderUsage {
|
||||
usage: usage(250_000, 0, 0, 0),
|
||||
cost_usd: 0.005,
|
||||
},
|
||||
);
|
||||
r.finish(ts("2026-08-15T05:36:00Z"));
|
||||
assert!((r.cost_usd - 0.4778).abs() < 1e-9);
|
||||
assert_eq!(r.usage, usage(1_000_100, 1_003_000, 2_000, 1_000_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_block_has_the_four_lines_of_the_plan() {
|
||||
let mut r = RunReport::new("2026-08-15".parse().unwrap(), ts("2026-08-15T05:30:00Z"));
|
||||
r.counts.articles = 412;
|
||||
r.counts.eligible = 398;
|
||||
r.counts.triaged = 398;
|
||||
r.counts.assessed = 120;
|
||||
r.counts.shortlisted = 60;
|
||||
r.counts.selected = 17;
|
||||
r.counts.admitted_by = BTreeMap::from([
|
||||
("triage".to_string(), 60),
|
||||
("interest".to_string(), 20),
|
||||
("knn".to_string(), 12),
|
||||
("exploration".to_string(), 5),
|
||||
("blend".to_string(), 23),
|
||||
]);
|
||||
r.counts.rated_with_embeddings = 14;
|
||||
r.counts.knn_gate = 0.35;
|
||||
r.counts.verdicts_in_prompt = 41;
|
||||
for (provider, cost) in [("deepseek", 0.11), ("anthropic", 0.62), ("voyage", 0.02)] {
|
||||
r.provider_costs.insert(
|
||||
provider.into(),
|
||||
ProviderUsage {
|
||||
usage: usage(1, 0, 0, 1),
|
||||
cost_usd: cost,
|
||||
},
|
||||
);
|
||||
}
|
||||
r.finish(ts("2026-08-15T05:53:12Z"));
|
||||
let [curation, admission, preference, providers] = r.info_block();
|
||||
assert_eq!(
|
||||
curation,
|
||||
"curation: 412 considered → 398 eligible → 398 triaged → 120 assessed → 60 shortlisted → 17 selected"
|
||||
);
|
||||
assert_eq!(
|
||||
admission,
|
||||
"admission: triage 60 · interest 20 · knn 12 · exploration 5 · blend 23 · auto 0"
|
||||
);
|
||||
assert_eq!(
|
||||
preference,
|
||||
"preference: 14 rated w/ embeddings → knn 0.35 · feed off · 41 verdicts in prompt"
|
||||
);
|
||||
assert_eq!(
|
||||
providers,
|
||||
"providers: anthropic $0.62 · deepseek $0.11 · voyage $0.02 · total $0.75 · 23m12s"
|
||||
);
|
||||
assert_eq!(RunReport::format_duration(48), "48s");
|
||||
assert_eq!(RunReport::format_duration(3600), "60m00s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user