Job catalogue and daily-epub job run, the systemd job unit and polkit rule, SystemdRunner/MockRunner, the Jobs pages with status and journal tail, the stats_data refactor with a byte-identical CLI, the stats page and the overview sparklines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
1947 lines
69 KiB
Rust
1947 lines
69 KiB
Rust
//! Per-run candidate telemetry: the `candidate_runs` writer, `signals_json`,
|
||
//! the `explain` command and feature retention (plan §7.4–7.5, §15.2, §16).
|
||
//!
|
||
//! One row per considered article per run says where it stopped and why. Rows
|
||
//! are upserted on every stage transition with every column set (never
|
||
//! `COALESCE`), so the last write for a run is the whole truth.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::fmt::Write as _;
|
||
|
||
use jiff::Timestamp;
|
||
use jiff::civil::Date;
|
||
use serde::{Deserialize, Serialize};
|
||
use sqlx::Row as _;
|
||
|
||
use crate::curate::signals::{Neighbour, Signals, TopInterest};
|
||
use crate::db::{Db, fmt_ts};
|
||
use crate::report::RunReport;
|
||
use crate::types::{ArticleId, Candidate, NearMiss};
|
||
|
||
/// Signal names rendered by `explain`, in the order of §7.5.
|
||
const RENDERED_SIGNALS: [&str; 8] = [
|
||
"interest",
|
||
"knn",
|
||
"feed",
|
||
"social",
|
||
"heuristic",
|
||
"triage",
|
||
"quality",
|
||
"fit",
|
||
];
|
||
|
||
/// One `candidate_runs` row (§7.4).
|
||
#[derive(Debug, Clone)]
|
||
pub struct CandidateRun<'a> {
|
||
pub run_id: i64,
|
||
pub article_id: ArticleId,
|
||
pub stage: &'a str,
|
||
pub excluded_reason: Option<&'a str>,
|
||
/// JSON array of retriever names, first = the one that admitted it.
|
||
pub admitted_by: Option<&'a str>,
|
||
pub signals_json: &'a str,
|
||
pub utility: Option<f64>,
|
||
pub rank_utility: Option<i64>,
|
||
pub cluster_id: Option<i64>,
|
||
pub cluster_rank: Option<i64>,
|
||
pub editor_why: Option<&'a str>,
|
||
}
|
||
|
||
/// Upsert one row, setting every column (§7.4).
|
||
pub async fn write(db: &Db, row: &CandidateRun<'_>) -> Result<(), sqlx::Error> {
|
||
sqlx::query(
|
||
"INSERT INTO candidate_runs
|
||
(run_id, article_id, stage, excluded_reason, admitted_by, signals_json,
|
||
utility, rank_utility, cluster_id, cluster_rank, editor_why)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(run_id, article_id) DO UPDATE SET
|
||
stage = excluded.stage,
|
||
excluded_reason = excluded.excluded_reason,
|
||
admitted_by = excluded.admitted_by,
|
||
signals_json = excluded.signals_json,
|
||
utility = excluded.utility,
|
||
rank_utility = excluded.rank_utility,
|
||
cluster_id = excluded.cluster_id,
|
||
cluster_rank = excluded.cluster_rank,
|
||
editor_why = excluded.editor_why",
|
||
)
|
||
.bind(row.run_id)
|
||
.bind(row.article_id)
|
||
.bind(row.stage)
|
||
.bind(row.excluded_reason)
|
||
.bind(row.admitted_by)
|
||
.bind(row.signals_json)
|
||
.bind(row.utility)
|
||
.bind(row.rank_utility)
|
||
.bind(row.cluster_id)
|
||
.bind(row.cluster_rank)
|
||
.bind(row.editor_why)
|
||
.execute(db.pool())
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The thin row of a hygiene exclusion: keys, `stage = 'excluded'`, the reason
|
||
/// and `signals_json = '{}'` (§8.1).
|
||
pub async fn thin_excluded(
|
||
db: &Db,
|
||
run_id: i64,
|
||
article_id: ArticleId,
|
||
reason: &str,
|
||
) -> Result<(), sqlx::Error> {
|
||
write(
|
||
db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id,
|
||
stage: "excluded",
|
||
excluded_reason: Some(reason),
|
||
admitted_by: None,
|
||
signals_json: "{}",
|
||
utility: None,
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// `signals_json` (§7.5). Missing signals are absent from `raw`/`norm` and
|
||
/// `false` in `present`; `weights` are the effective weights.
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct SignalsJson {
|
||
pub v: i64,
|
||
#[serde(default)]
|
||
pub raw: BTreeMap<String, f64>,
|
||
#[serde(default)]
|
||
pub norm: BTreeMap<String, f64>,
|
||
#[serde(default)]
|
||
pub present: BTreeMap<String, bool>,
|
||
#[serde(default)]
|
||
pub weights: BTreeMap<String, f64>,
|
||
#[serde(default)]
|
||
pub top_interests: Vec<TopInterest>,
|
||
#[serde(default)]
|
||
pub neighbours: Vec<Neighbour>,
|
||
#[serde(default)]
|
||
pub exploration: bool,
|
||
#[serde(default)]
|
||
pub auto_include: bool,
|
||
#[serde(default)]
|
||
pub notes: Vec<String>,
|
||
}
|
||
|
||
impl SignalsJson {
|
||
/// The blend implied by the stored effective weights, 0–100.
|
||
pub fn blend(&self) -> Option<f64> {
|
||
let mut score = 0.0;
|
||
let mut any = false;
|
||
for (name, weight) in &self.weights {
|
||
if let Some(value) = self.norm.get(name) {
|
||
score += weight * value;
|
||
any = true;
|
||
}
|
||
}
|
||
any.then_some(score * 100.0)
|
||
}
|
||
}
|
||
|
||
/// Serialize the signals of §7.5 for one article.
|
||
pub fn serialize_signals(signals: &Signals, auto_include: bool) -> String {
|
||
let mut raw = BTreeMap::new();
|
||
for name in [
|
||
"interest",
|
||
"interest_top1_cos",
|
||
"knn",
|
||
"feed",
|
||
"social",
|
||
"heuristic",
|
||
] {
|
||
if let Some(value) = signals.raw(name) {
|
||
raw.insert(name.to_string(), value);
|
||
}
|
||
}
|
||
let present = RENDERED_SIGNALS
|
||
.into_iter()
|
||
.map(|name| (name.to_string(), signals.present(name)))
|
||
.collect();
|
||
serde_json::to_string(&SignalsJson {
|
||
v: 1,
|
||
raw,
|
||
norm: signals.norm.clone(),
|
||
present,
|
||
weights: signals.weights.clone(),
|
||
top_interests: signals.top_interests.clone(),
|
||
neighbours: signals.neighbours.clone(),
|
||
exploration: false,
|
||
auto_include,
|
||
notes: signals.notes.clone(),
|
||
})
|
||
.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);
|
||
}
|
||
if let Some(deep) = candidate.assessment.deep.as_ref() {
|
||
value.raw.insert("quality".into(), deep.quality);
|
||
value.raw.insert("fit".into(), deep.fit);
|
||
value
|
||
.norm
|
||
.insert("quality".into(), (deep.quality / 10.0).clamp(0.0, 1.0));
|
||
value
|
||
.norm
|
||
.insert("fit".into(), (deep.fit / 10.0).clamp(0.0, 1.0));
|
||
value.present.insert("quality".into(), true);
|
||
value.present.insert("fit".into(), true);
|
||
}
|
||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".into())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `explain` (§15.2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// A `candidate_runs` row joined to its article title.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ExplainRow {
|
||
pub run_id: i64,
|
||
pub article_id: ArticleId,
|
||
pub title: String,
|
||
/// The best entry's feed, for the paper's near-miss list.
|
||
pub feed_title: String,
|
||
pub stage: String,
|
||
pub excluded_reason: Option<String>,
|
||
pub admitted_by: Option<String>,
|
||
pub signals_json: String,
|
||
pub utility: Option<f64>,
|
||
pub rank_utility: Option<i64>,
|
||
pub cluster_id: Option<i64>,
|
||
pub cluster_rank: Option<i64>,
|
||
pub editor_why: Option<String>,
|
||
}
|
||
|
||
impl ExplainRow {
|
||
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Self {
|
||
Self {
|
||
run_id: row.get("run_id"),
|
||
article_id: row.get("article_id"),
|
||
title: row.get("title"),
|
||
feed_title: row.get("feed_title"),
|
||
stage: row.get("stage"),
|
||
excluded_reason: row.get("excluded_reason"),
|
||
admitted_by: row.get("admitted_by"),
|
||
signals_json: row.get("signals_json"),
|
||
utility: row.get("utility"),
|
||
rank_utility: row.get("rank_utility"),
|
||
cluster_id: row.get("cluster_id"),
|
||
cluster_rank: row.get("cluster_rank"),
|
||
editor_why: row.get("editor_why"),
|
||
}
|
||
}
|
||
|
||
pub fn signals(&self) -> Option<SignalsJson> {
|
||
serde_json::from_str(&self.signals_json).ok()
|
||
}
|
||
|
||
/// Utility once the deep set has been ranked, else the preliminary blend.
|
||
pub fn score(&self) -> Option<f64> {
|
||
self.utility
|
||
.or_else(|| self.signals().and_then(|signals| signals.blend()))
|
||
}
|
||
}
|
||
|
||
/// The run `explain` reads: `--run-id` when given (and of that date), else the
|
||
/// latest non-dry run of the date.
|
||
pub async fn resolve_run(
|
||
db: &Db,
|
||
date: Date,
|
||
requested: Option<i64>,
|
||
) -> Result<Option<i64>, sqlx::Error> {
|
||
let row = match requested {
|
||
Some(run_id) => {
|
||
sqlx::query("SELECT id FROM runs WHERE id = ? AND date = ?")
|
||
.bind(run_id)
|
||
.bind(date.to_string())
|
||
.fetch_optional(db.pool())
|
||
.await?
|
||
}
|
||
None => {
|
||
sqlx::query(
|
||
"SELECT id FROM runs WHERE date = ? AND status != 'dry_run'
|
||
ORDER BY id DESC LIMIT 1",
|
||
)
|
||
.bind(date.to_string())
|
||
.fetch_optional(db.pool())
|
||
.await?
|
||
}
|
||
};
|
||
Ok(row.map(|row| row.get("id")))
|
||
}
|
||
|
||
pub async fn explain_row(
|
||
db: &Db,
|
||
run_id: i64,
|
||
article_id: ArticleId,
|
||
) -> Result<Option<ExplainRow>, sqlx::Error> {
|
||
let row = sqlx::query(
|
||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||
COALESCE(e.feed_title, '') AS feed_title,
|
||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||
WHERE cr.run_id = ? AND cr.article_id = ?",
|
||
)
|
||
.bind(run_id)
|
||
.bind(article_id)
|
||
.fetch_optional(db.pool())
|
||
.await?;
|
||
Ok(row.as_ref().map(ExplainRow::from_row))
|
||
}
|
||
|
||
/// The top `limit` rows that were not selected, by utility, falling back to
|
||
/// the preliminary blend for rows the ranker never reached (§15.2).
|
||
pub async fn near_misses(
|
||
db: &Db,
|
||
run_id: i64,
|
||
limit: usize,
|
||
) -> Result<Vec<ExplainRow>, sqlx::Error> {
|
||
let rows = sqlx::query(
|
||
"SELECT cr.run_id, cr.article_id, COALESCE(a.title, '') AS title,
|
||
COALESCE(e.feed_title, '') AS feed_title,
|
||
cr.stage, cr.excluded_reason, cr.admitted_by, cr.signals_json,
|
||
cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank, cr.editor_why
|
||
FROM candidate_runs cr JOIN articles a ON a.id = cr.article_id
|
||
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||
WHERE cr.run_id = ? AND cr.stage != 'selected' AND cr.stage != 'excluded'",
|
||
)
|
||
.bind(run_id)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
let mut output = rows.iter().map(ExplainRow::from_row).collect::<Vec<_>>();
|
||
output.sort_by(|left, right| {
|
||
right
|
||
.score()
|
||
.unwrap_or(f64::NEG_INFINITY)
|
||
.total_cmp(&left.score().unwrap_or(f64::NEG_INFINITY))
|
||
.then_with(|| left.article_id.cmp(&right.article_id))
|
||
});
|
||
output.truncate(limit);
|
||
Ok(output)
|
||
}
|
||
|
||
fn fmt_opt(value: Option<f64>) -> String {
|
||
value
|
||
.map(|v| format!("{v:.3}"))
|
||
.unwrap_or_else(|| "—".into())
|
||
}
|
||
|
||
/// Render one persisted row the way §15.2 lists it.
|
||
pub async fn render_explain(db: &Db, row: &ExplainRow) -> Result<String, sqlx::Error> {
|
||
let mut out = String::new();
|
||
let _ = writeln!(out, "article {}: {}", row.article_id, row.title);
|
||
let _ = write!(out, "run {} · stage: {}", row.run_id, row.stage);
|
||
if let Some(reason) = &row.excluded_reason {
|
||
let _ = write!(out, " · reason: {reason}");
|
||
}
|
||
let _ = writeln!(out);
|
||
if let Some(signals) = row.signals() {
|
||
let _ = writeln!(out, "signals (raw · norm · weight):");
|
||
for name in RENDERED_SIGNALS {
|
||
let present = signals.present.get(name).copied().unwrap_or(false);
|
||
if present {
|
||
let _ = writeln!(
|
||
out,
|
||
" {name:<10} {:>8} · {:>6} · {:>6}",
|
||
fmt_opt(signals.raw.get(name).copied()),
|
||
fmt_opt(signals.norm.get(name).copied()),
|
||
fmt_opt(signals.weights.get(name).copied()),
|
||
);
|
||
} else {
|
||
let _ = writeln!(out, " {name:<10} absent");
|
||
}
|
||
}
|
||
if let Some(blend) = signals.blend() {
|
||
let _ = writeln!(out, "preliminary blend: {blend:.1}");
|
||
}
|
||
if let Some(cos) = signals.raw.get("interest_top1_cos") {
|
||
let _ = writeln!(out, "interest top-1 cosine: {cos:.3}");
|
||
}
|
||
if !signals.top_interests.is_empty() {
|
||
let _ = writeln!(out, "top interests:");
|
||
for interest in &signals.top_interests {
|
||
let _ = writeln!(
|
||
out,
|
||
" {} · z {:.2} · cos {:.3}",
|
||
interest.name, interest.z, interest.cos
|
||
);
|
||
}
|
||
}
|
||
if !signals.neighbours.is_empty() {
|
||
let _ = writeln!(out, "nearest rated neighbours:");
|
||
for neighbour in &signals.neighbours {
|
||
let _ = writeln!(
|
||
out,
|
||
" {} · cos {:.3} · article {} · {}",
|
||
neighbour.label, neighbour.cos, neighbour.article_id, neighbour.title
|
||
);
|
||
}
|
||
}
|
||
if signals.exploration || signals.auto_include {
|
||
let _ = writeln!(
|
||
out,
|
||
"flags: exploration={} auto_include={}",
|
||
signals.exploration, signals.auto_include
|
||
);
|
||
}
|
||
for note in &signals.notes {
|
||
let _ = writeln!(out, "note: {note}");
|
||
}
|
||
}
|
||
|
||
let assessments = sqlx::query(
|
||
"SELECT stage, model, score, fit, kind, facets_json, rationale, category,
|
||
paywalled_guess, assessed_at
|
||
FROM article_assessments WHERE article_id = ? ORDER BY stage",
|
||
)
|
||
.bind(row.article_id)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
if !assessments.is_empty() {
|
||
let _ = writeln!(out, "assessments:");
|
||
for assessment in assessments {
|
||
let stage = assessment.get::<String, _>("stage");
|
||
let model = assessment.get::<String, _>("model");
|
||
let score = fmt_opt(assessment.get::<Option<f64>, _>("score"));
|
||
let rationale = assessment
|
||
.get::<Option<String>, _>("rationale")
|
||
.unwrap_or_default();
|
||
if assessment.get::<Option<String>, _>("kind").as_deref()
|
||
== Some(super::triage::PROVIDER_REJECTED)
|
||
{
|
||
let _ = writeln!(out, " {stage}: rejected by provider — {rationale}");
|
||
continue;
|
||
}
|
||
if stage == "deep" {
|
||
let _ = writeln!(
|
||
out,
|
||
" deep · {model} · quality {score} · fit {} · format {} · category {} · paywalled={} · {rationale}",
|
||
fmt_opt(assessment.get::<Option<f64>, _>("fit")),
|
||
assessment
|
||
.get::<Option<String>, _>("kind")
|
||
.unwrap_or_else(|| "—".into()),
|
||
assessment
|
||
.get::<Option<String>, _>("category")
|
||
.unwrap_or_else(|| "—".into()),
|
||
assessment.get::<i64, _>("paywalled_guess") != 0,
|
||
);
|
||
} else {
|
||
let _ = writeln!(
|
||
out,
|
||
" triage · {model} · interest {score} · kind {} · {rationale}",
|
||
assessment
|
||
.get::<Option<String>, _>("kind")
|
||
.unwrap_or_else(|| "—".into()),
|
||
);
|
||
}
|
||
if let Some(facets) = assessment.get::<Option<String>, _>("facets_json") {
|
||
let _ = writeln!(out, " facets: {facets}");
|
||
}
|
||
}
|
||
}
|
||
if row.utility.is_some() || row.rank_utility.is_some() {
|
||
let _ = writeln!(
|
||
out,
|
||
"utility: {} · rank {}",
|
||
fmt_opt(row.utility),
|
||
row.rank_utility
|
||
.map(|r| r.to_string())
|
||
.unwrap_or_else(|| "—".into())
|
||
);
|
||
}
|
||
if let Some(cluster) = row.cluster_id {
|
||
let _ = writeln!(
|
||
out,
|
||
"cluster: {cluster} · rank {}",
|
||
row.cluster_rank
|
||
.map(|r| r.to_string())
|
||
.unwrap_or_else(|| "—".into())
|
||
);
|
||
}
|
||
if let Some(admitted_by) = &row.admitted_by {
|
||
let _ = writeln!(out, "admitted by: {admitted_by}");
|
||
}
|
||
if let Some(why) = &row.editor_why {
|
||
let _ = writeln!(out, "editor: {why}");
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// What `explain` was asked about.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum ExplainTarget {
|
||
Article(ArticleId),
|
||
Url(String),
|
||
}
|
||
|
||
/// `explain --date D (--article ID | --url URL) [--run-id N]` as text (§15.2).
|
||
pub async fn explain(
|
||
db: &Db,
|
||
date: Date,
|
||
run_id: Option<i64>,
|
||
target: &ExplainTarget,
|
||
) -> anyhow::Result<String> {
|
||
let article_id = match target {
|
||
ExplainTarget::Article(id) => *id,
|
||
ExplainTarget::Url(url) => {
|
||
let canonical = crate::dedupe::canonical_url(url)
|
||
.ok_or_else(|| anyhow::anyhow!("invalid article URL {url:?}"))?;
|
||
match db.article_id_for_url(&canonical).await? {
|
||
Some(id) => id,
|
||
None => {
|
||
return Ok(format!(
|
||
"{canonical} was never ingested: it is not in `articles`, so this is a feed problem, not a ranking problem."
|
||
));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
if db.get_article(article_id).await?.is_none() {
|
||
return Ok(format!(
|
||
"article {article_id} was never ingested: it is not in `articles`."
|
||
));
|
||
}
|
||
let Some(run_id) = resolve_run(db, date, run_id).await? else {
|
||
return Ok(match run_id {
|
||
Some(id) => format!("run {id} is not a run for {date}"),
|
||
None => format!("no non-dry run recorded for {date}"),
|
||
});
|
||
};
|
||
match explain_row(db, run_id, article_id).await? {
|
||
Some(row) => Ok(render_explain(db, &row).await?),
|
||
None => Ok(format!(
|
||
"article {article_id} was not considered by run {run_id} for {date} (outside its ingest window, or telemetry pruned)."
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// `explain --date D --near-misses [N]` as text (§15.2).
|
||
pub async fn explain_near_misses(
|
||
db: &Db,
|
||
date: Date,
|
||
run_id: Option<i64>,
|
||
limit: usize,
|
||
) -> anyhow::Result<String> {
|
||
let Some(run_id) = resolve_run(db, date, run_id).await? else {
|
||
return Ok(format!("no non-dry run recorded for {date}"));
|
||
};
|
||
let rows = near_misses(db, run_id, limit).await?;
|
||
let mut out = String::new();
|
||
let _ = writeln!(
|
||
out,
|
||
"run {run_id} · {date} · top {} not selected, by {}:",
|
||
rows.len(),
|
||
if rows.iter().any(|row| row.utility.is_some()) {
|
||
"utility"
|
||
} else {
|
||
"preliminary blend"
|
||
}
|
||
);
|
||
for (index, row) in rows.iter().enumerate() {
|
||
let reason = row
|
||
.excluded_reason
|
||
.as_deref()
|
||
.map(|reason| format!(", {reason}"))
|
||
.unwrap_or_default();
|
||
let _ = writeln!(
|
||
out,
|
||
"{:>3}. {:>6} · {} · {}{} · article {}",
|
||
index + 1,
|
||
row.score()
|
||
.map(|score| format!("{score:.1}"))
|
||
.unwrap_or_else(|| "—".into()),
|
||
row.title,
|
||
row.stage,
|
||
reason,
|
||
row.article_id
|
||
);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Behind the paper (§15.1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The `limit` highest-utility articles the run did not select, shaped for
|
||
/// the "Behind the paper" chapter: the same query as `explain --near-misses`.
|
||
pub async fn paper_near_misses(
|
||
db: &Db,
|
||
run_id: i64,
|
||
limit: usize,
|
||
) -> Result<Vec<NearMiss>, sqlx::Error> {
|
||
Ok(near_misses(db, run_id, limit)
|
||
.await?
|
||
.iter()
|
||
.map(|row| {
|
||
let signals = row.signals();
|
||
let raw = |name: &str| signals.as_ref().and_then(|s| s.raw.get(name).copied());
|
||
NearMiss {
|
||
article_id: row.article_id,
|
||
title: row.title.clone(),
|
||
feed_title: row.feed_title.clone(),
|
||
quality: raw("quality"),
|
||
fit: raw("fit"),
|
||
stage: row.stage.clone(),
|
||
reason: row.excluded_reason.clone(),
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `stats` (§15.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `admitted_by[0]`: the retriever that admitted a pick (§11).
|
||
fn first_retriever(admitted_by: Option<&str>) -> String {
|
||
admitted_by
|
||
.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
|
||
.and_then(|names| names.into_iter().next())
|
||
.unwrap_or_else(|| "unknown".to_string())
|
||
}
|
||
|
||
/// Rated picks by admitting retriever: how many were rated, up, down.
|
||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||
pub struct UpDown {
|
||
pub rated: i64,
|
||
pub up: i64,
|
||
pub down: i64,
|
||
}
|
||
|
||
impl UpDown {
|
||
/// `"NN% up"`, or `n/a` with nothing rated.
|
||
pub fn ratio(&self) -> String {
|
||
if self.rated > 0 {
|
||
format!("{:.0}% up", 100.0 * self.up as f64 / self.rated as f64)
|
||
} else {
|
||
"n/a".to_string()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One finished, non-dry run as a point of the per-run series (dashboard
|
||
/// plan §9.1, §12).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct RunPoint {
|
||
pub run_id: i64,
|
||
pub date: String,
|
||
pub started_at: String,
|
||
pub status: String,
|
||
pub cost_usd: f64,
|
||
pub selected: i64,
|
||
pub duration_secs: Option<i64>,
|
||
}
|
||
|
||
/// Everything `daily-epub stats` prints, as data (dashboard plan §12): the
|
||
/// CLI renders it with [`render_stats_text`], the stats page as tables and
|
||
/// sparklines.
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
pub struct StatsData {
|
||
pub days: i64,
|
||
/// First UTC date of the window.
|
||
pub since_date: String,
|
||
/// UTC date of `now`.
|
||
pub today: String,
|
||
pub issues: i64,
|
||
pub published: i64,
|
||
/// Explicit ratings per label, label order; `cleared` included.
|
||
pub ratings_by_label: Vec<(String, i64)>,
|
||
/// Explicit ratings excluding `cleared`.
|
||
pub total_ratings: i64,
|
||
pub per_retriever: BTreeMap<String, UpDown>,
|
||
pub exploration_admitted: i64,
|
||
pub exploration_selected: i64,
|
||
pub exploration_positive: i64,
|
||
/// Provider → spend over the whole window.
|
||
pub provider_totals: BTreeMap<String, f64>,
|
||
/// UTC date → provider → spend.
|
||
pub cost_by_day: BTreeMap<String, BTreeMap<String, f64>>,
|
||
/// Seconds of every finished run in the window (dry runs included), for
|
||
/// the mean generation time.
|
||
pub durations: Vec<i64>,
|
||
/// Finished non-dry runs in the window, oldest first.
|
||
pub runs: Vec<RunPoint>,
|
||
/// Issue date → picks, oldest first.
|
||
pub selected_per_issue: Vec<(String, i64)>,
|
||
/// Week (Monday, UTC) → label → explicit ratings.
|
||
pub ratings_per_week: BTreeMap<String, BTreeMap<String, i64>>,
|
||
}
|
||
|
||
impl StatsData {
|
||
/// `n` per issue with one decimal, or `n/a` without issues.
|
||
pub fn per_issue(&self, n: i64) -> String {
|
||
if self.issues > 0 {
|
||
format!("{:.1}", n as f64 / self.issues as f64)
|
||
} else {
|
||
"n/a".to_string()
|
||
}
|
||
}
|
||
|
||
pub fn mean_issue_size(&self) -> String {
|
||
self.per_issue(self.published)
|
||
}
|
||
|
||
pub fn ratings_per_issue(&self) -> String {
|
||
self.per_issue(self.total_ratings)
|
||
}
|
||
|
||
/// Spend per day over the window for one provider total.
|
||
pub fn per_day(&self, total: f64) -> f64 {
|
||
total / self.days as f64
|
||
}
|
||
|
||
/// Every provider's window spend added up, in provider order.
|
||
pub fn grand_total(&self) -> f64 {
|
||
let mut grand = 0.0;
|
||
for total in self.provider_totals.values() {
|
||
grand += total;
|
||
}
|
||
grand
|
||
}
|
||
|
||
/// Integer mean of the finished runs' seconds.
|
||
pub fn mean_generation_secs(&self) -> Option<i64> {
|
||
if self.durations.is_empty() {
|
||
None
|
||
} else {
|
||
Some(self.durations.iter().sum::<i64>() / self.durations.len() as i64)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `daily-epub stats [--days N]` as text: the whole evaluation framework
|
||
/// (§15.3). One fact per line, nothing wider than 80 columns.
|
||
pub async fn stats(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<String> {
|
||
Ok(render_stats_text(&stats_data(db, days, now).await?))
|
||
}
|
||
|
||
/// Finished non-dry runs as sparkline points, oldest first: those started
|
||
/// at or after `since` (RFC3339) and/or the newest `limit`.
|
||
pub async fn run_series(
|
||
db: &Db,
|
||
since: Option<&str>,
|
||
limit: Option<i64>,
|
||
) -> Result<Vec<RunPoint>, sqlx::Error> {
|
||
let rows = sqlx::query(
|
||
"SELECT id, date, started_at, finished_at, status, cost_usd, selected FROM runs
|
||
WHERE status != 'dry_run' AND finished_at IS NOT NULL
|
||
AND (? IS NULL OR started_at >= ?)
|
||
ORDER BY id DESC LIMIT ?",
|
||
)
|
||
.bind(since)
|
||
.bind(since)
|
||
.bind(limit.unwrap_or(i64::MAX))
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
let mut points: Vec<RunPoint> = rows
|
||
.iter()
|
||
.map(|row| {
|
||
let started_at: String = row.get("started_at");
|
||
let finished_at: Option<String> = row.get("finished_at");
|
||
let duration_secs = match (
|
||
started_at.parse::<Timestamp>(),
|
||
finished_at.as_deref().map(str::parse::<Timestamp>),
|
||
) {
|
||
(Ok(started), Some(Ok(finished))) => {
|
||
Some((finished.as_second() - started.as_second()).max(0))
|
||
}
|
||
_ => None,
|
||
};
|
||
RunPoint {
|
||
run_id: row.get("id"),
|
||
date: row.get("date"),
|
||
started_at,
|
||
status: row.get("status"),
|
||
cost_usd: row.get("cost_usd"),
|
||
selected: row.get("selected"),
|
||
duration_secs,
|
||
}
|
||
})
|
||
.collect();
|
||
points.reverse();
|
||
Ok(points)
|
||
}
|
||
|
||
/// The Monday (UTC) of the week containing `ts`, as `YYYY-MM-DD`.
|
||
pub fn week_start(ts: Timestamp) -> String {
|
||
let date = ts.to_zoned(jiff::tz::TimeZone::UTC).date();
|
||
let offset = i64::from(date.weekday().to_monday_zero_offset());
|
||
date.checked_sub(jiff::Span::new().days(offset))
|
||
.unwrap_or(date)
|
||
.to_string()
|
||
}
|
||
|
||
/// Gather every figure of [`StatsData`] for the last `days` days.
|
||
pub async fn stats_data(db: &Db, days: i64, now: Timestamp) -> anyhow::Result<StatsData> {
|
||
let days = days.max(1);
|
||
let since_ts = now
|
||
.checked_sub(jiff::Span::new().hours(days.saturating_mul(24)))
|
||
.unwrap_or(Timestamp::UNIX_EPOCH);
|
||
let since = fmt_ts(since_ts);
|
||
let utc = jiff::tz::TimeZone::UTC;
|
||
let since_date = since_ts.to_zoned(utc.clone()).date().to_string();
|
||
let today = now.to_zoned(utc.clone()).date().to_string();
|
||
let mut data = StatsData {
|
||
days,
|
||
since_date: since_date.clone(),
|
||
today,
|
||
..StatsData::default()
|
||
};
|
||
|
||
// --- issues and articles ---
|
||
data.issues = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?")
|
||
.bind(&since_date)
|
||
.fetch_one(db.pool())
|
||
.await?;
|
||
data.published =
|
||
sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?")
|
||
.bind(&since_date)
|
||
.fetch_one(db.pool())
|
||
.await?;
|
||
let per_issue_rows = sqlx::query(
|
||
"SELECT issue_date, COUNT(*) AS n FROM issue_articles
|
||
WHERE issue_date >= ? GROUP BY issue_date ORDER BY issue_date",
|
||
)
|
||
.bind(&since_date)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
data.selected_per_issue = per_issue_rows
|
||
.iter()
|
||
.map(|row| (row.get::<String, _>("issue_date"), row.get::<i64, _>("n")))
|
||
.collect();
|
||
|
||
// --- explicit ratings by label ---
|
||
let labels = sqlx::query(
|
||
"SELECT label, COUNT(*) AS n FROM rating_events
|
||
WHERE kind = 'explicit' AND event_at >= ? GROUP BY label ORDER BY label",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
for row in &labels {
|
||
let label = row.get::<String, _>("label");
|
||
let n = row.get::<i64, _>("n");
|
||
if label != "cleared" {
|
||
data.total_ratings += n;
|
||
}
|
||
data.ratings_by_label.push((label, n));
|
||
}
|
||
let events = sqlx::query(
|
||
"SELECT label, event_at FROM rating_events
|
||
WHERE kind = 'explicit' AND event_at >= ? ORDER BY event_at",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
for row in &events {
|
||
let Ok(event_at) = row.get::<String, _>("event_at").parse::<Timestamp>() else {
|
||
continue;
|
||
};
|
||
*data
|
||
.ratings_per_week
|
||
.entry(week_start(event_at))
|
||
.or_default()
|
||
.entry(row.get::<String, _>("label"))
|
||
.or_insert(0) += 1;
|
||
}
|
||
|
||
// --- up/down per admitting retriever, from rated picks ---
|
||
let rated_picks = sqlx::query(
|
||
"WITH latest AS (
|
||
SELECT re.article_id, re.issue_date, re.label, re.value,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY re.article_id
|
||
ORDER BY re.event_at DESC, re.id DESC
|
||
) AS rn
|
||
FROM rating_events re
|
||
WHERE re.kind = 'explicit' AND re.event_at >= ?
|
||
)
|
||
SELECT l.article_id, l.value, cr.admitted_by, cr.signals_json
|
||
FROM latest l
|
||
JOIN candidate_runs cr ON cr.article_id = l.article_id AND cr.stage = 'selected'
|
||
JOIN runs r ON r.id = cr.run_id AND r.status != 'dry_run'
|
||
WHERE l.rn = 1 AND l.label != 'cleared'
|
||
AND (l.issue_date IS NULL OR r.date = l.issue_date)
|
||
ORDER BY l.article_id, cr.run_id DESC",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
let mut seen: Option<ArticleId> = None;
|
||
for row in &rated_picks {
|
||
let article_id = row.get::<ArticleId, _>("article_id");
|
||
if seen == Some(article_id) {
|
||
continue; // a rerun of the date: keep the latest run only
|
||
}
|
||
seen = Some(article_id);
|
||
let value = row.get::<f64, _>("value");
|
||
let retriever = first_retriever(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||
let entry = data.per_retriever.entry(retriever).or_default();
|
||
entry.rated += 1;
|
||
if value > 0.0 {
|
||
entry.up += 1;
|
||
} else if value < 0.0 {
|
||
entry.down += 1;
|
||
}
|
||
let exploration =
|
||
serde_json::from_str::<SignalsJson>(&row.get::<String, _>("signals_json"))
|
||
.map(|signals| signals.exploration)
|
||
.unwrap_or(false);
|
||
if exploration && value > 0.0 {
|
||
data.exploration_positive += 1;
|
||
}
|
||
}
|
||
|
||
// --- exploration yield ---
|
||
let exploration_rows = sqlx::query(
|
||
"SELECT cr.stage FROM candidate_runs cr
|
||
JOIN runs r ON r.id = cr.run_id
|
||
WHERE r.status != 'dry_run' AND r.started_at >= ?
|
||
AND cr.signals_json LIKE '%\"exploration\":true%'",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
for row in &exploration_rows {
|
||
match row.get::<String, _>("stage").as_str() {
|
||
"selected" => {
|
||
data.exploration_admitted += 1;
|
||
data.exploration_selected += 1;
|
||
}
|
||
"admitted" | "assessed" | "shortlisted" => data.exploration_admitted += 1,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// --- cost per day per provider (§7.6) ---
|
||
let cost_rows = sqlx::query(
|
||
"SELECT started_at, provider_costs_json FROM runs
|
||
WHERE started_at >= ? AND provider_costs_json IS NOT NULL",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
for row in &cost_rows {
|
||
let raw = row.get::<String, _>("provider_costs_json");
|
||
let Ok(providers) =
|
||
serde_json::from_str::<BTreeMap<String, crate::report::ProviderUsage>>(&raw)
|
||
else {
|
||
continue;
|
||
};
|
||
let day = row
|
||
.get::<String, _>("started_at")
|
||
.parse::<Timestamp>()
|
||
.map(|ts| ts.to_zoned(utc.clone()).date().to_string())
|
||
.unwrap_or_else(|_| since_date.clone());
|
||
for (provider, usage) in providers {
|
||
*data.provider_totals.entry(provider.clone()).or_insert(0.0) += usage.cost_usd;
|
||
*data
|
||
.cost_by_day
|
||
.entry(day.clone())
|
||
.or_default()
|
||
.entry(provider)
|
||
.or_insert(0.0) += usage.cost_usd;
|
||
}
|
||
}
|
||
|
||
// --- mean generation time ---
|
||
let run_rows = sqlx::query(
|
||
"SELECT started_at, finished_at FROM runs
|
||
WHERE started_at >= ? AND finished_at IS NOT NULL",
|
||
)
|
||
.bind(&since)
|
||
.fetch_all(db.pool())
|
||
.await?;
|
||
for row in &run_rows {
|
||
let started = row.get::<String, _>("started_at").parse::<Timestamp>();
|
||
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
|
||
if let (Ok(started), Ok(finished)) = (started, finished) {
|
||
data.durations
|
||
.push((finished.as_second() - started.as_second()).max(0));
|
||
}
|
||
}
|
||
|
||
data.runs = run_series(db, Some(&since), None).await?;
|
||
Ok(data)
|
||
}
|
||
|
||
/// The CLI text of [`StatsData`], one fact per line.
|
||
pub fn render_stats_text(data: &StatsData) -> String {
|
||
let mut out = String::new();
|
||
let _ = writeln!(
|
||
out,
|
||
"stats: last {} days ({} → {})",
|
||
data.days, data.since_date, data.today
|
||
);
|
||
let _ = writeln!(out, "issues: {}", data.issues);
|
||
let _ = writeln!(out, "articles published: {}", data.published);
|
||
let _ = writeln!(out, "mean issue size: {} articles", data.mean_issue_size());
|
||
let _ = writeln!(out, "explicit ratings: {}", data.total_ratings);
|
||
for (label, n) in &data.ratings_by_label {
|
||
let _ = writeln!(out, "explicit ratings ({label}): {n}");
|
||
}
|
||
let _ = writeln!(out, "ratings per issue: {}", data.ratings_per_issue());
|
||
if data.per_retriever.is_empty() {
|
||
let _ = writeln!(out, "rated picks by admitting retriever: none");
|
||
}
|
||
for (retriever, counts) in &data.per_retriever {
|
||
let _ = writeln!(
|
||
out,
|
||
"admitted by {retriever}: {} rated · {} up · {} down · {}",
|
||
counts.rated,
|
||
counts.up,
|
||
counts.down,
|
||
counts.ratio()
|
||
);
|
||
}
|
||
let _ = writeln!(out, "exploration admitted: {}", data.exploration_admitted);
|
||
let _ = writeln!(out, "exploration selected: {}", data.exploration_selected);
|
||
let _ = writeln!(
|
||
out,
|
||
"exploration rated positively: {}",
|
||
data.exploration_positive
|
||
);
|
||
for (provider, total) in &data.provider_totals {
|
||
let _ = writeln!(
|
||
out,
|
||
"cost per day ({provider}): ${:.3}",
|
||
data.per_day(*total)
|
||
);
|
||
}
|
||
let _ = writeln!(
|
||
out,
|
||
"cost per day (total): ${:.3}",
|
||
data.per_day(data.grand_total())
|
||
);
|
||
match data.mean_generation_secs() {
|
||
None => {
|
||
let _ = writeln!(out, "mean generation time: n/a (0 runs)");
|
||
}
|
||
Some(mean) => {
|
||
let _ = writeln!(
|
||
out,
|
||
"mean generation time: {} ({} runs)",
|
||
RunReport::format_duration(mean),
|
||
data.durations.len()
|
||
);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `features prune` (§7.1, §7.4)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Rows removed by one [`prune`] pass.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct Pruned {
|
||
/// `article_embeddings` of unrated, unpublished articles past
|
||
/// `embedding_retention_days`.
|
||
pub embeddings: u64,
|
||
/// `candidate_runs` rows of runs past `telemetry_retention_days`.
|
||
pub telemetry: u64,
|
||
/// `article_assessments` assessed more than `telemetry_retention_days` ago.
|
||
pub assessments: u64,
|
||
}
|
||
|
||
/// Delete `article_embeddings` for articles neither rated nor published that
|
||
/// are older than `embedding_retention_days`, `candidate_runs` rows whose run
|
||
/// started more than `telemetry_retention_days` ago, and `article_assessments`
|
||
/// older than the same window (§7.1, §7.4). Runs from `features prune` and
|
||
/// once per `generate` after publishing.
|
||
pub async fn prune(
|
||
db: &Db,
|
||
embedding_retention_days: i64,
|
||
telemetry_retention_days: i64,
|
||
now: Timestamp,
|
||
) -> Result<Pruned, sqlx::Error> {
|
||
let cutoff = |days: i64| {
|
||
now.checked_sub(jiff::Span::new().hours(days.max(0).saturating_mul(24)))
|
||
.unwrap_or(Timestamp::UNIX_EPOCH)
|
||
};
|
||
let embeddings = sqlx::query(
|
||
"DELETE FROM article_embeddings
|
||
WHERE article_id IN (
|
||
SELECT ae.article_id
|
||
FROM article_embeddings ae JOIN articles a ON a.id = ae.article_id
|
||
WHERE a.first_seen < ?
|
||
AND NOT EXISTS (SELECT 1 FROM rating_events re WHERE re.article_id = ae.article_id)
|
||
AND NOT EXISTS (SELECT 1 FROM issue_articles ia WHERE ia.article_id = ae.article_id)
|
||
)",
|
||
)
|
||
.bind(fmt_ts(cutoff(embedding_retention_days)))
|
||
.execute(db.pool())
|
||
.await?
|
||
.rows_affected();
|
||
|
||
let telemetry = sqlx::query(
|
||
"DELETE FROM candidate_runs
|
||
WHERE run_id IN (SELECT id FROM runs WHERE started_at < ?)",
|
||
)
|
||
.bind(fmt_ts(cutoff(telemetry_retention_days)))
|
||
.execute(db.pool())
|
||
.await?
|
||
.rows_affected();
|
||
|
||
let assessments = sqlx::query("DELETE FROM article_assessments WHERE assessed_at < ?")
|
||
.bind(fmt_ts(cutoff(telemetry_retention_days)))
|
||
.execute(db.pool())
|
||
.await?
|
||
.rows_affected();
|
||
Ok(Pruned {
|
||
embeddings,
|
||
telemetry,
|
||
assessments,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::curate::embedding::encode_blob;
|
||
|
||
async fn db_with_articles(ids: &[ArticleId]) -> (tempfile::TempDir, Db) {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let db = Db::open_and_migrate(&dir.path().join("telemetry.db"))
|
||
.await
|
||
.unwrap();
|
||
for id in ids {
|
||
sqlx::query(
|
||
"INSERT INTO articles (id, canonical_url, title, first_seen)
|
||
VALUES (?, ?, ?, '2026-08-15T00:00:00Z')",
|
||
)
|
||
.bind(id)
|
||
.bind(format!("https://example.com/{id}"))
|
||
.bind(format!("Article {id}"))
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
}
|
||
(dir, db)
|
||
}
|
||
|
||
fn date() -> Date {
|
||
"2026-09-02".parse().unwrap()
|
||
}
|
||
|
||
fn signals(heuristic: f64, norm: f64) -> Signals {
|
||
Signals {
|
||
interest: Some(1.2),
|
||
interest_top1_cos: Some(0.61),
|
||
heuristic: Some(heuristic),
|
||
norm: BTreeMap::from([("heuristic".into(), norm), ("interest".into(), 0.9)]),
|
||
weights: BTreeMap::from([
|
||
("heuristic".into(), 0.2 / 0.55),
|
||
("interest".into(), 0.35 / 0.55),
|
||
]),
|
||
top_interests: vec![TopInterest {
|
||
name: "Gaussian Splatting".into(),
|
||
z: 3.4,
|
||
cos: 0.61,
|
||
}],
|
||
neighbours: vec![Neighbour {
|
||
article_id: 812,
|
||
label: "loved".into(),
|
||
cos: 0.71,
|
||
title: "A rated piece".into(),
|
||
}],
|
||
notes: vec!["knn gate 0.60 (n=14 rated with embeddings)".into()],
|
||
..Signals::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn signals_json_follows_the_plan_shape() {
|
||
let json = serialize_signals(&signals(41.0, 0.55), false);
|
||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed["v"], 1);
|
||
assert_eq!(parsed["raw"]["heuristic"], 41.0);
|
||
assert_eq!(parsed["raw"]["interest_top1_cos"], 0.61);
|
||
assert_eq!(parsed["present"]["heuristic"], true);
|
||
assert_eq!(parsed["present"]["knn"], false);
|
||
assert_eq!(parsed["present"]["quality"], false);
|
||
assert!(parsed["raw"].get("knn").is_none());
|
||
assert!(parsed["norm"].get("knn").is_none());
|
||
assert_eq!(parsed["exploration"], false);
|
||
assert_eq!(parsed["auto_include"], false);
|
||
assert_eq!(parsed["top_interests"][0]["name"], "Gaussian Splatting");
|
||
assert_eq!(parsed["neighbours"][0]["article_id"], 812);
|
||
assert_eq!(
|
||
parsed["notes"][0],
|
||
"knn gate 0.60 (n=14 rated with embeddings)"
|
||
);
|
||
let typed: SignalsJson = serde_json::from_str(&json).unwrap();
|
||
let weights: f64 = typed.weights.values().sum();
|
||
assert!((weights - 1.0).abs() < 1e-9);
|
||
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 explain_names_provider_rejections_instead_of_scores() {
|
||
let (_dir, db) = db_with_articles(&[1]).await;
|
||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 1,
|
||
stage: "admitted",
|
||
excluded_reason: None,
|
||
admitted_by: Some("[\"interest\"]"),
|
||
signals_json: "{}",
|
||
utility: None,
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO article_assessments
|
||
(article_id, stage, model, prompt_version, score, fit, kind, rationale, assessed_at)
|
||
VALUES (1, 'triage', 'deepseek-v4-flash', 1, NULL, NULL, 'provider_rejected',
|
||
'deepseek: 400 Bad Request: Content Exists Risk', '2026-09-02T04:00:00Z'),
|
||
(1, 'deep', 'deepseek-v4-flash', 1, NULL, NULL, 'provider_rejected',
|
||
'deepseek: returned a refusal', '2026-09-02T04:00:00Z')",
|
||
)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let text = explain(&db, date(), None, &ExplainTarget::Article(1))
|
||
.await
|
||
.unwrap();
|
||
assert!(
|
||
text.contains(
|
||
" triage: rejected by provider — deepseek: 400 Bad Request: Content Exists Risk\n"
|
||
),
|
||
"{text}"
|
||
);
|
||
assert!(
|
||
text.contains(" deep: rejected by provider — deepseek: returned a refusal\n"),
|
||
"{text}"
|
||
);
|
||
assert!(!text.contains("interest —"), "{text}");
|
||
assert!(!text.contains("quality —"), "{text}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn rows_are_upserted_with_every_column_replaced() {
|
||
let (_dir, db) = db_with_articles(&[1]).await;
|
||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 1,
|
||
stage: "eligible",
|
||
excluded_reason: Some("not_admitted"),
|
||
admitted_by: None,
|
||
signals_json: "{}",
|
||
utility: Some(1.0),
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 1,
|
||
stage: "selected",
|
||
excluded_reason: None,
|
||
admitted_by: Some("[\"prefilter\"]"),
|
||
signals_json: "{\"v\":1}",
|
||
utility: None,
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: Some("because"),
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let row = explain_row(&db, run_id, 1).await.unwrap().unwrap();
|
||
assert_eq!(row.stage, "selected");
|
||
assert_eq!(row.excluded_reason, None, "no COALESCE");
|
||
assert_eq!(row.utility, None);
|
||
assert_eq!(row.admitted_by.as_deref(), Some("[\"prefilter\"]"));
|
||
assert_eq!(row.editor_why.as_deref(), Some("because"));
|
||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM candidate_runs")
|
||
.fetch_one(db.pool())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn explain_renders_persisted_rows_and_reports_never_ingested() {
|
||
let (_dir, db) = db_with_articles(&[1, 2]).await;
|
||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
thin_excluded(&db, run_id, 2, "blocked").await.unwrap();
|
||
let json = serialize_signals(&signals(41.0, 0.55), true);
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 1,
|
||
stage: "shortlisted",
|
||
excluded_reason: Some("not_selected"),
|
||
admitted_by: Some("[\"prefilter\"]"),
|
||
signals_json: &json,
|
||
utility: None,
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
// A later dry run must not shadow the real one.
|
||
let dry = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
sqlx::query("UPDATE runs SET status = 'dry_run' WHERE id = ?")
|
||
.bind(dry)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(resolve_run(&db, date(), None).await.unwrap(), Some(run_id));
|
||
assert_eq!(
|
||
resolve_run(&db, date(), Some(dry)).await.unwrap(),
|
||
Some(dry)
|
||
);
|
||
assert_eq!(
|
||
resolve_run(&db, "2026-01-01".parse().unwrap(), Some(dry))
|
||
.await
|
||
.unwrap(),
|
||
None
|
||
);
|
||
|
||
let text = explain(&db, date(), None, &ExplainTarget::Article(1))
|
||
.await
|
||
.unwrap();
|
||
assert!(text.contains("article 1: Article 1"), "{text}");
|
||
assert!(
|
||
text.contains("stage: shortlisted · reason: not_selected"),
|
||
"{text}"
|
||
);
|
||
let squashed = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
assert!(
|
||
squashed.contains("heuristic 41.000 · 0.550 · 0.364"),
|
||
"{text}"
|
||
);
|
||
assert!(squashed.contains("knn absent"), "{text}");
|
||
assert!(squashed.contains("quality absent"), "{text}");
|
||
assert!(
|
||
text.contains("Gaussian Splatting · z 3.40 · cos 0.610"),
|
||
"{text}"
|
||
);
|
||
assert!(
|
||
text.contains("loved · cos 0.710 · article 812 · A rated piece"),
|
||
"{text}"
|
||
);
|
||
assert!(text.contains("admitted by: [\"prefilter\"]"), "{text}");
|
||
assert!(text.contains("auto_include=true"), "{text}");
|
||
assert!(text.contains("preliminary blend:"), "{text}");
|
||
assert!(text.contains("note: knn gate"), "{text}");
|
||
|
||
let by_url = explain(
|
||
&db,
|
||
date(),
|
||
None,
|
||
&ExplainTarget::Url("https://example.com/1?utm_source=x".into()),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(by_url, text, "--url canonicalizes and finds the same row");
|
||
|
||
let thin = explain(&db, date(), None, &ExplainTarget::Article(2))
|
||
.await
|
||
.unwrap();
|
||
assert!(thin.contains("stage: excluded · reason: blocked"), "{thin}");
|
||
|
||
let missing = explain(
|
||
&db,
|
||
date(),
|
||
None,
|
||
&ExplainTarget::Url("https://nowhere.example/post".into()),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert!(missing.contains("never ingested"), "{missing}");
|
||
let missing_id = explain(&db, date(), None, &ExplainTarget::Article(99))
|
||
.await
|
||
.unwrap();
|
||
assert!(missing_id.contains("never ingested"), "{missing_id}");
|
||
|
||
let no_run = explain(
|
||
&db,
|
||
"2026-01-01".parse().unwrap(),
|
||
None,
|
||
&ExplainTarget::Article(1),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert!(no_run.contains("no non-dry run"), "{no_run}");
|
||
let not_considered = explain(&db, date(), Some(dry), &ExplainTarget::Article(1))
|
||
.await
|
||
.unwrap();
|
||
assert!(
|
||
not_considered.contains("was not considered by run"),
|
||
"{not_considered}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn near_misses_rank_by_utility_then_blend_and_skip_selected_and_excluded() {
|
||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4, 5, 6]).await;
|
||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
// Utility decides wherever the ranker wrote one; the preliminary blend
|
||
// only stands in for rows the deep set never reached. Article 4's blend
|
||
// would put it first, but its utility is the lowest; article 3 never
|
||
// got a utility and ranks on its blend.
|
||
let rows = [
|
||
(1, "selected", None, 0.9, Some(90.0)),
|
||
(2, "shortlisted", Some("not_selected"), 0.1, Some(70.0)),
|
||
(3, "eligible", Some("not_admitted"), 0.95, None),
|
||
(4, "shortlisted", Some("not_selected"), 0.99, Some(10.0)),
|
||
(6, "assessed", Some("cluster_suppressed"), 0.5, Some(40.0)),
|
||
];
|
||
for (id, stage, reason, norm, utility) in rows {
|
||
let json = serialize_signals(&signals(10.0, norm), false);
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: id,
|
||
stage,
|
||
excluded_reason: reason,
|
||
admitted_by: None,
|
||
signals_json: &json,
|
||
utility,
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
thin_excluded(&db, run_id, 5, "published_before")
|
||
.await
|
||
.unwrap();
|
||
let misses = near_misses(&db, run_id, 10).await.unwrap();
|
||
assert_eq!(
|
||
misses.iter().map(|row| row.article_id).collect::<Vec<_>>(),
|
||
vec![3, 2, 6, 4]
|
||
);
|
||
let text = explain_near_misses(&db, date(), None, 2).await.unwrap();
|
||
assert!(text.contains("top 2 not selected, by utility"), "{text}");
|
||
assert!(
|
||
text.contains("Article 3 · eligible, not_admitted"),
|
||
"{text}"
|
||
);
|
||
assert!(
|
||
text.contains("Article 2 · shortlisted, not_selected"),
|
||
"{text}"
|
||
);
|
||
assert!(!text.contains("Article 4"), "{text}");
|
||
|
||
// Without any utility the listing says so and orders by the blend.
|
||
sqlx::query("UPDATE candidate_runs SET utility = NULL WHERE run_id = ?")
|
||
.bind(run_id)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let misses = near_misses(&db, run_id, 10).await.unwrap();
|
||
assert_eq!(
|
||
misses.iter().map(|row| row.article_id).collect::<Vec<_>>(),
|
||
vec![4, 3, 6, 2]
|
||
);
|
||
let text = explain_near_misses(&db, date(), None, 1).await.unwrap();
|
||
assert!(
|
||
text.contains("top 1 not selected, by preliminary blend"),
|
||
"{text}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn paper_near_misses_carry_feed_quality_fit_and_stage() {
|
||
let (_dir, db) = db_with_articles(&[1, 2]).await;
|
||
sqlx::query(
|
||
"INSERT INTO entries (id, feed_id, feed_title, title, url, raw_content, fetched_at)
|
||
VALUES (11, 5, 'Example Feed', 'Article 1', 'https://example.com/1', '', '2026-08-15T00:00:00Z')",
|
||
)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
sqlx::query("UPDATE articles SET best_entry_id = 11 WHERE id = 1")
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let run_id = db.start_run(date(), Timestamp::now()).await.unwrap();
|
||
let mut candidate = crate::types::Candidate::new(
|
||
crate::curate::prefilter::tests::article(1, "Article 1", 900),
|
||
false,
|
||
);
|
||
candidate.signals = signals(41.0, 0.55);
|
||
candidate.assessment.deep = Some(crate::types::Deep {
|
||
quality: 8.0,
|
||
fit: 6.5,
|
||
category: None,
|
||
rationale: String::new(),
|
||
paywalled_guess: false,
|
||
facets: Default::default(),
|
||
model: "mock".into(),
|
||
prompt_version: 1,
|
||
assessed_at: "2026-09-02T05:30:00Z".parse().unwrap(),
|
||
});
|
||
let json = serialize_candidate(&candidate);
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 1,
|
||
stage: "shortlisted",
|
||
excluded_reason: Some("not_selected"),
|
||
admitted_by: Some("[\"triage\"]"),
|
||
signals_json: &json,
|
||
utility: Some(71.0),
|
||
rank_utility: Some(3),
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id: 2,
|
||
stage: "selected",
|
||
excluded_reason: None,
|
||
admitted_by: Some("[\"triage\"]"),
|
||
signals_json: "{}",
|
||
utility: Some(90.0),
|
||
rank_utility: Some(1),
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: Some("because"),
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let misses = paper_near_misses(&db, run_id, 10).await.unwrap();
|
||
assert_eq!(misses.len(), 1, "selected picks are not near misses");
|
||
let miss = &misses[0];
|
||
assert_eq!(miss.article_id, 1);
|
||
assert_eq!(miss.title, "Article 1");
|
||
assert_eq!(miss.feed_title, "Example Feed");
|
||
assert_eq!(miss.quality, Some(8.0));
|
||
assert_eq!(miss.fit, Some(6.5));
|
||
assert_eq!(miss.stage, "shortlisted");
|
||
assert_eq!(miss.reason.as_deref(), Some("not_selected"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn stats_prints_every_fact_from_runs_issues_and_ratings() {
|
||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
||
let now: Timestamp = "2026-09-02T12:00:00Z".parse().unwrap();
|
||
// Two issues inside the window, one outside it.
|
||
sqlx::query(
|
||
"INSERT INTO issues (date, issue_number, generated_at) VALUES
|
||
('2026-08-01', 1, '2026-08-01T10:00:00Z'),
|
||
('2026-08-30', 30, '2026-08-30T10:00:00Z'),
|
||
('2026-09-01', 32, '2026-09-01T10:00:00Z');
|
||
INSERT INTO issue_articles (issue_date, article_id, section) VALUES
|
||
('2026-08-01', 4, 'Top Stories'),
|
||
('2026-08-30', 1, 'Top Stories'),
|
||
('2026-08-30', 2, 'Top Stories'),
|
||
('2026-09-01', 3, 'Top Stories');",
|
||
)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
// Two finished runs with provider costs, one of them a rerun of 08-30.
|
||
let mut run_ids = Vec::new();
|
||
for (date, started, finished, costs) in [
|
||
(
|
||
"2026-08-30",
|
||
"2026-08-30T09:30:00Z",
|
||
"2026-08-30T09:50:00Z",
|
||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.10},"anthropic":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.60},"voyage":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":0,"cost_usd":0.02}}"#,
|
||
),
|
||
(
|
||
"2026-08-30",
|
||
"2026-08-30T11:00:00Z",
|
||
"2026-08-30T11:10:00Z",
|
||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.04}}"#,
|
||
),
|
||
(
|
||
"2026-09-01",
|
||
"2026-09-01T09:30:00Z",
|
||
"2026-09-01T09:45:00Z",
|
||
r#"{"deepseek":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.14},"gemini":{"input_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"output_tokens":1,"cost_usd":0.07}}"#,
|
||
),
|
||
] {
|
||
let run_id = db
|
||
.start_run(date.parse().unwrap(), started.parse().unwrap())
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"UPDATE runs SET finished_at = ?, status = 'ok', provider_costs_json = ? WHERE id = ?",
|
||
)
|
||
.bind(finished)
|
||
.bind(costs)
|
||
.bind(run_id)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
run_ids.push(run_id);
|
||
}
|
||
// Article 1 was admitted by triage in the first 08-30 run and by knn
|
||
// in the rerun; the latest run wins. Article 2 was an exploration
|
||
// pick admitted by exploration. Article 3 was admitted by blend.
|
||
let exploration = r#"{"v":1,"exploration":true}"#;
|
||
for (run_id, article_id, stage, admitted_by, json) in [
|
||
(run_ids[0], 1, "selected", "[\"triage\"]", "{}"),
|
||
(run_ids[1], 1, "selected", "[\"knn\"]", "{}"),
|
||
(run_ids[1], 2, "selected", "[\"exploration\"]", exploration),
|
||
(run_ids[1], 4, "assessed", "[\"exploration\"]", exploration),
|
||
(run_ids[2], 3, "selected", "[\"blend\"]", "{}"),
|
||
] {
|
||
write(
|
||
&db,
|
||
&CandidateRun {
|
||
run_id,
|
||
article_id,
|
||
stage,
|
||
excluded_reason: None,
|
||
admitted_by: Some(admitted_by),
|
||
signals_json: json,
|
||
utility: Some(50.0),
|
||
rank_utility: None,
|
||
cluster_id: None,
|
||
cluster_rank: None,
|
||
editor_why: None,
|
||
},
|
||
)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
sqlx::query(
|
||
"INSERT INTO rating_events (article_id, issue_date, kind, source, label, value, event_at) VALUES
|
||
(1, '2026-08-30', 'explicit', 'epub', 'good', 0.35, '2026-08-31T08:00:00Z'),
|
||
(1, '2026-08-30', 'explicit', 'epub', 'loved', 1.0, '2026-08-31T09:00:00Z'),
|
||
(2, '2026-08-30', 'explicit', 'epub', 'loved', 1.0, '2026-08-31T09:30:00Z'),
|
||
(3, '2026-09-01', 'explicit', 'epub', 'not_for_me', -1.0, '2026-09-01T12:00:00Z'),
|
||
(4, '2026-08-01', 'explicit', 'epub', 'loved', 1.0, '2026-08-02T12:00:00Z'),
|
||
(3, NULL, 'explicit', 'cli', 'cleared', 0.0, '2026-08-20T12:00:00Z');",
|
||
)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
|
||
let text = stats(&db, 14, now).await.unwrap();
|
||
println!("{text}");
|
||
for line in [
|
||
"stats: last 14 days (2026-08-19 → 2026-09-02)",
|
||
"issues: 2",
|
||
"articles published: 3",
|
||
"mean issue size: 1.5 articles",
|
||
"explicit ratings: 4",
|
||
"explicit ratings (cleared): 1",
|
||
"explicit ratings (good): 1",
|
||
"explicit ratings (loved): 2",
|
||
"explicit ratings (not_for_me): 1",
|
||
"ratings per issue: 2.0",
|
||
"admitted by blend: 1 rated · 0 up · 1 down · 0% up",
|
||
"admitted by exploration: 1 rated · 1 up · 0 down · 100% up",
|
||
"admitted by knn: 1 rated · 1 up · 0 down · 100% up",
|
||
"exploration admitted: 2",
|
||
"exploration selected: 1",
|
||
"exploration rated positively: 1",
|
||
"cost per day (anthropic): $0.043",
|
||
"cost per day (deepseek): $0.020",
|
||
"cost per day (gemini): $0.005",
|
||
"cost per day (voyage): $0.001",
|
||
"cost per day (total): $0.069",
|
||
"mean generation time: 15m00s (3 runs)",
|
||
] {
|
||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||
}
|
||
assert!(
|
||
!text.contains("admitted by triage"),
|
||
"the rerun's row replaces the first run's: {text}"
|
||
);
|
||
assert!(
|
||
text.lines().all(|line| line.chars().count() <= 80),
|
||
"no line wider than 80 columns"
|
||
);
|
||
// Dashboard plan §12: the CLI output is byte-identical before and
|
||
// after the `stats_data` / `render_stats_text` split.
|
||
assert_eq!(text, STATS_TEXT_BEFORE_REFACTOR);
|
||
let data = stats_data(&db, 14, now).await.unwrap();
|
||
assert_eq!(render_stats_text(&data), text);
|
||
|
||
// The figures the stats page adds on top of the text.
|
||
assert_eq!(data.issues, 2);
|
||
assert_eq!(data.total_ratings, 4);
|
||
assert_eq!(
|
||
data.selected_per_issue,
|
||
vec![("2026-08-30".to_string(), 2), ("2026-09-01".to_string(), 1)]
|
||
);
|
||
assert_eq!(
|
||
data.cost_by_day["2026-08-30"]["deepseek"],
|
||
0.10 + 0.04,
|
||
"both 08-30 runs land on the same day"
|
||
);
|
||
assert_eq!(data.cost_by_day["2026-09-01"]["gemini"], 0.07);
|
||
assert_eq!(data.ratings_per_week["2026-08-31"]["loved"], 2);
|
||
assert_eq!(data.ratings_per_week["2026-08-31"]["not_for_me"], 1);
|
||
assert_eq!(data.ratings_per_week["2026-08-17"]["cleared"], 1);
|
||
assert_eq!(data.runs.len(), 3, "three finished non-dry runs");
|
||
assert_eq!(data.runs[0].run_id, run_ids[0], "oldest first");
|
||
assert_eq!(data.runs[0].duration_secs, Some(1200));
|
||
assert_eq!(data.runs[2].date, "2026-09-01");
|
||
assert_eq!(data.per_retriever["knn"].ratio(), "100% up");
|
||
assert_eq!(data.mean_generation_secs(), Some(900));
|
||
|
||
// The newest-N form the overview uses.
|
||
let last_two = run_series(&db, None, Some(2)).await.unwrap();
|
||
assert_eq!(
|
||
last_two.iter().map(|p| p.run_id).collect::<Vec<_>>(),
|
||
vec![run_ids[1], run_ids[2]]
|
||
);
|
||
|
||
// An empty database still prints every heading.
|
||
let (_dir, empty) = db_with_articles(&[]).await;
|
||
let text = stats(&empty, 7, now).await.unwrap();
|
||
for line in [
|
||
"issues: 0",
|
||
"mean issue size: n/a articles",
|
||
"ratings per issue: n/a",
|
||
"rated picks by admitting retriever: none",
|
||
"cost per day (total): $0.000",
|
||
"mean generation time: n/a (0 runs)",
|
||
] {
|
||
assert!(text.contains(line), "missing {line:?} in:\n{text}");
|
||
}
|
||
assert_eq!(text, STATS_TEXT_EMPTY_BEFORE_REFACTOR);
|
||
}
|
||
|
||
/// `stats(db, 14, 2026-09-02T12:00:00Z)` over the seed above, captured
|
||
/// from the pre-refactor implementation.
|
||
const STATS_TEXT_BEFORE_REFACTOR: &str = "\
|
||
stats: last 14 days (2026-08-19 → 2026-09-02)
|
||
issues: 2
|
||
articles published: 3
|
||
mean issue size: 1.5 articles
|
||
explicit ratings: 4
|
||
explicit ratings (cleared): 1
|
||
explicit ratings (good): 1
|
||
explicit ratings (loved): 2
|
||
explicit ratings (not_for_me): 1
|
||
ratings per issue: 2.0
|
||
admitted by blend: 1 rated · 0 up · 1 down · 0% up
|
||
admitted by exploration: 1 rated · 1 up · 0 down · 100% up
|
||
admitted by knn: 1 rated · 1 up · 0 down · 100% up
|
||
exploration admitted: 2
|
||
exploration selected: 1
|
||
exploration rated positively: 1
|
||
cost per day (anthropic): $0.043
|
||
cost per day (deepseek): $0.020
|
||
cost per day (gemini): $0.005
|
||
cost per day (voyage): $0.001
|
||
cost per day (total): $0.069
|
||
mean generation time: 15m00s (3 runs)
|
||
";
|
||
|
||
const STATS_TEXT_EMPTY_BEFORE_REFACTOR: &str = "\
|
||
stats: last 7 days (2026-08-26 → 2026-09-02)
|
||
issues: 0
|
||
articles published: 0
|
||
mean issue size: n/a articles
|
||
explicit ratings: 0
|
||
ratings per issue: n/a
|
||
rated picks by admitting retriever: none
|
||
exploration admitted: 0
|
||
exploration selected: 0
|
||
exploration rated positively: 0
|
||
cost per day (total): $0.000
|
||
mean generation time: n/a (0 runs)
|
||
";
|
||
|
||
#[test]
|
||
fn week_start_is_the_utc_monday() {
|
||
assert_eq!(
|
||
week_start("2026-09-02T12:00:00Z".parse().unwrap()),
|
||
"2026-08-31"
|
||
);
|
||
assert_eq!(
|
||
week_start("2026-08-31T00:00:00Z".parse().unwrap()),
|
||
"2026-08-31"
|
||
);
|
||
assert_eq!(
|
||
week_start("2026-09-06T23:59:59Z".parse().unwrap()),
|
||
"2026-08-31"
|
||
);
|
||
assert_eq!(
|
||
week_start("2026-09-07T00:00:00Z".parse().unwrap()),
|
||
"2026-09-07"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn prune_respects_rated_and_published() {
|
||
let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await;
|
||
let now = Timestamp::now();
|
||
let old = fmt_ts(now - jiff::Span::new().hours(200 * 24));
|
||
sqlx::query("UPDATE articles SET first_seen = ? WHERE id IN (1, 2, 3)")
|
||
.bind(&old)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let blob = encode_blob(&[0.5, 0.5]).unwrap();
|
||
for id in 1..=4 {
|
||
sqlx::query(
|
||
"INSERT INTO article_embeddings
|
||
(article_id, model, dimension, input_hash, embedding, created_at)
|
||
VALUES (?, 'voyage-4-lite', 2, 'h', ?, ?)",
|
||
)
|
||
.bind(id)
|
||
.bind(&blob)
|
||
.bind(&old)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
}
|
||
sqlx::query(
|
||
"INSERT INTO rating_events (article_id, kind, source, label, value, event_at)
|
||
VALUES (1, 'explicit', 'cli', 'loved', 1.0, ?)",
|
||
)
|
||
.bind(&old)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO issues (date, issue_number, generated_at) VALUES ('2026-02-01', 1, ?);
|
||
INSERT INTO issue_articles (issue_date, article_id, section) VALUES ('2026-02-01', 2, 'Top Stories');",
|
||
)
|
||
.bind(&old)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
|
||
let old_run = db
|
||
.start_run("2026-02-01".parse().unwrap(), now)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query("UPDATE runs SET started_at = ? WHERE id = ?")
|
||
.bind(&old)
|
||
.bind(old_run)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
let new_run = db.start_run(date(), now).await.unwrap();
|
||
thin_excluded(&db, old_run, 1, "blocked").await.unwrap();
|
||
thin_excluded(&db, new_run, 1, "blocked").await.unwrap();
|
||
for (id, assessed_at) in [(1, old.clone()), (2, fmt_ts(now))] {
|
||
sqlx::query(
|
||
"INSERT INTO article_assessments
|
||
(article_id, stage, model, prompt_version, score, assessed_at)
|
||
VALUES (?, 'triage', 'deepseek-v4-flash', 1, 7.0, ?)",
|
||
)
|
||
.bind(id)
|
||
.bind(&assessed_at)
|
||
.execute(db.pool())
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
let pruned = prune(&db, 120, 180, now).await.unwrap();
|
||
assert_eq!(
|
||
pruned.embeddings, 1,
|
||
"only the old, unrated, unpublished article 3"
|
||
);
|
||
assert_eq!(pruned.telemetry, 1, "only the old run's rows");
|
||
assert_eq!(pruned.assessments, 1, "only the 200-day-old assessment");
|
||
let remaining: Vec<i64> =
|
||
sqlx::query_scalar("SELECT article_id FROM article_embeddings ORDER BY article_id")
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(remaining, vec![1, 2, 4]);
|
||
let runs: Vec<i64> = sqlx::query_scalar("SELECT run_id FROM candidate_runs")
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(runs, vec![new_run]);
|
||
let assessed: Vec<i64> = sqlx::query_scalar("SELECT article_id FROM article_assessments")
|
||
.fetch_all(db.pool())
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(assessed, vec![2]);
|
||
|
||
// A second pass finds nothing left to remove.
|
||
assert_eq!(prune(&db, 120, 180, now).await.unwrap(), Pruned::default());
|
||
}
|
||
}
|