diff --git a/Cargo.lock b/Cargo.lock index d4e0240..c592979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,6 +831,7 @@ dependencies = [ "hmac", "image", "jiff", + "libc", "rand 0.10.2", "reqwest", "resvg", diff --git a/Cargo.toml b/Cargo.toml index e25ee2b..78fd7bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ hex = "0.4.3" hmac = "0.13.0" image = "0.25.10" jiff = { version = "0.2.35", features = ["serde"] } +libc = "0.2.189" rand = "0.10.2" reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "gzip", "json", "charset", "http2", "query", "system-proxy"] } resvg = "0.48.1" diff --git a/README.md b/README.md index c7fb6ed..dfaafc3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ the summaries and *The Brief*. It assembles two EPUB editions (a standard one and one tuned for the Xteink X4 e-ink reader), converts the X4 edition to XTC, and publishes the lot over its own OPDS catalog — which doubles as a [BookOrbit](https://github.com/thallada/bookorbit) watched folder if you run one. -Each article chapter ends with Loved it / Good / Not for me links that feed back into tomorrow's curation. +Each article chapter ends with Loved it / Good / Not for me links that feed back +into tomorrow's curation, and a short *Behind the paper* chapter before the +colophon says what the run considered, how the deep set was admitted, whether +the learned signals were active, the ten highest-utility near misses, and what +it all cost. Steady-state cost is roughly **$1/day**: $0.05–0.30 in DeepSeek tokens plus ~$0.50–0.80 for the Claude editor and a few cents of Voyage AI embeddings, each @@ -38,6 +42,25 @@ Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enr Every stage writes to SQLite, so a run is idempotent per date: re-running `generate --date 2026-08-15` replaces that issue rather than duplicating it. +Only one writer runs at a time: `generate`, `profile rebuild`, `features +backfill` and `backfill-social` take an advisory `flock` on +`.lock`, and a second invocation exits with `generate is already +running` (naming whichever command holds it). `serve`, `explain`, `stats`, +`ratings`, `features prune` and `db migrate` never wait on it, and the kernel +releases the lock when the holder exits, however it exits. + +Every run ends with a four-line summary in the log and on stdout: + +``` +curation: 412 considered → 398 eligible → 398 triaged → 120 assessed → 60 shortlisted → 17 selected +admission: triage 60 · interest 20 · knn 12 · exploration 5 · blend 23 · auto 0 +preference: 14 rated w/ embeddings → knn 0.35 · feed off · 41 verdicts in prompt +providers: anthropic $0.62 · deepseek $0.11 · voyage $0.02 · total $0.75 · 23m12s +``` + +The full report (per-stage counts and timings — `embed`, `signals`, `triage`, +`admit`, `assess`, `rank`, `editor`, `summaries`, `brief` among them — and +per-provider usage) is stored on the `runs` row and in `issues.report_json`. **Failure policy.** Miniflux ingest, SQLite writes, EPUB assembly and publishing are fatal — without them there is no issue, and the `runs` row records why. @@ -91,6 +114,7 @@ daily-epub ratings set --article 42 --label loved --note "excellent" daily-epub ratings clear --url https://example.com/article daily-epub explain --date YYYY-MM-DD (--article ID | --url URL) [--run-id N] daily-epub explain --date YYYY-MM-DD --near-misses [N] +daily-epub stats [--days 14] # the evaluation framework, one fact per line daily-epub features backfill [--days 30] [--rated-only] [--all] [--yes] daily-epub features prune # stale embeddings + old candidate telemetry daily-epub backfill-social # re-poll social scores for recent articles @@ -104,6 +128,8 @@ does not advance the ingest watermark. It prints the lineup and the cost report. `--skip-embeddings` reads the embedding cache but makes zero Voyage calls. `--rescore` ignores reusable triage/deep assessments for this run. +`--max-articles N` is a ceiling, never a target: the hard maximum becomes +`min(curation.max_article_count, N)` and the soft target is lowered to fit. `explain` answers "why was this (not) in the paper" from the `candidate_runs` row the run persisted for every considered article: the stage it reached and the @@ -116,6 +142,15 @@ database at all is reported as never ingested (a feed problem, not a ranking one). `--near-misses` lists the highest-utility articles that were not selected (by preliminary blend for articles the ranker never reached). +`stats` is the whole evaluation framework, on purpose: for the last `--days` +(14) it prints the issues and articles published, the mean issue size, explicit +ratings by label and per issue, the up/down ratio of rated picks per admitting +retriever (`admitted_by[0]` — triage, interest, knn, exploration, blend, +auto_include), the exploration yield (admitted, selected, rated positively), +cost per day per provider from `runs.provider_costs_json`, and the mean +generation time. Plain text, one fact per line. Tune from it and from reading +the paper; anything more waits for more ratings. + `features backfill` embeds the rated and published articles first (the learned set), then the standing interests, then — only with `--all` — every other article first seen in the window. It prints an estimate and asks before spending @@ -268,6 +303,9 @@ DAILY_EPUB_VOYAGE__API_KEY=… DAILY_EPUB_SERVER__HMAC_SECRET=$(openssl rand -hex 32) EOF sudo chown daily-epub:daily-epub /etc/daily-epub/env && sudo chmod 0600 /etc/daily-epub/env +# `DAILY_EPUB_ANTHROPIC__API_KEY` and `DAILY_EPUB_VOYAGE__API_KEY` are the two +# keys curation v2 added; the units read them from this file unchanged. Either +# may be left unset: the run then falls back to DeepSeek / cached embeddings. # publish dirs must exist and be writable by the service user sudo install -d -o daily-epub -g daily-epub /var/lib/daily-epub/xtc @@ -436,7 +474,7 @@ DAILY_EPUB_OUT_DIR=./out daily-epub generate --dry-run --skip-llm --max-articles ls -la ./out # two .epub files epubcheck "./out/The Daily EPUB - $(date +%F).epub" # expect zero errors # open the standard edition in Calibre / KOReader: cover, The Brief, -# In This Issue, sections, discussions, colophon; TOC depth 2 +# In This Issue, sections, discussions, Behind the paper, colophon; TOC depth 2 # 4. Now with DeepSeek and Claude, still not publishing daily-epub generate --dry-run --out ./out --max-articles 6 @@ -460,12 +498,13 @@ curl -s https://daily.hallada.net/issues.json | jq '.[0]' sqlite3 /var/lib/daily-epub/daily-epub.db 'select * from rating_events order by event_at desc;' # 8. Watch cost and quality for a week -sqlite3 /var/lib/daily-epub/daily-epub.db \ - 'select date, status, entries_fetched, candidates, selected, cost_usd from runs order by id desc limit 7;' +daily-epub stats --days 7 +daily-epub explain --date $(date +%F) --near-misses ``` Tune `curation.ranking.deep_keep`, `target_article_count` and `curation.always_include_feeds` -from what you see in step 8. +from what you see in step 8, and read the paper's *Behind the paper* chapter +each morning: it is the same numbers, on the device. ### Troubleshooting @@ -496,7 +535,10 @@ The crate is a library plus a thin binary, so tests drive the pipeline directly. `tests/e2e_pipeline.rs` is the capstone: synthetic entries → dedupe → offline extraction → signals → triage → admission → selection (both the `--skip-llm` route and a `MockBackend` DeepSeek route) → editorial → both EPUB editions → publish → OPDS -and database rows, with no network access anywhere. +and database rows, with no network access anywhere. `tests/m4_epub.rs` covers +the rendered chapters, including *Behind the paper* in both editions; the +`stats` and lock tests live next to their modules (`curate/telemetry.rs`, +`lock.rs`). ### Layout @@ -516,6 +558,7 @@ images/ article images comments.rs discussion chapters encode re-encode publish.rs BookOrbit + XTC embed into the page server.rs ratings, OPDS html.rs markup helpers db.rs SQLite +lock.rs one writer at a time (flock on .lock) ``` Two modules are worth knowing about before you go looking for their contents. diff --git a/examples/image_audit.rs b/examples/image_audit.rs index 5af74b2..729c2d7 100644 --- a/examples/image_audit.rs +++ b/examples/image_audit.rs @@ -417,6 +417,7 @@ fn write_audit_epub(out_dir: &Path, picks: &[Pick], assets: &[ImageAsset]) { }, world_briefing: None, colophon: Colophon::default(), + behind: Default::default(), }; let cfg = daily_epub::config::Config::default(); match daily_epub::epub::build_edition_with_images( diff --git a/src/curate/editorial.rs b/src/curate/editorial.rs index b9665b9..24cc001 100644 --- a/src/curate/editorial.rs +++ b/src/curate/editorial.rs @@ -318,21 +318,42 @@ pub fn fallback_editorial(lineup: &Lineup) -> Editorial { } } +/// Wall-clock milliseconds of the two editorial calls, for the run report's +/// `summaries` and `brief` stage timings (§15.4). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct EditorialTimings { + pub summaries_ms: i64, + pub brief_ms: i64, +} + pub async fn run( llms: &Llms, lineup: &Lineup, config: &EditorialConfig, temperature: f32, ) -> Editorial { + run_timed(llms, lineup, config, temperature).await.0 +} + +/// [`run`], also reporting how long the summaries and the brief took. +pub async fn run_timed( + llms: &Llms, + lineup: &Lineup, + config: &EditorialConfig, + temperature: f32, +) -> (Editorial, EditorialTimings) { if lineup.picks.is_empty() { - return fallback_editorial(lineup); + return (fallback_editorial(lineup), EditorialTimings::default()); } + let started = std::time::Instant::now(); let mut summaries = summarize_all(llms, lineup, config, temperature).await; for pick in &lineup.picks { summaries .entry(pick.article.id) .or_insert_with(|| excerpt_summary(pick)); } + let summaries_ms = started.elapsed().as_millis() as i64; + let started = std::time::Instant::now(); let front_page_html = match brief(llms, lineup, &summaries, temperature).await { Ok(text) => text_to_paragraphs(&text), Err(error) => { @@ -340,10 +361,17 @@ pub async fn run( fallback_front_page_html(lineup) } }; - Editorial { - front_page_html, - summaries, - } + let brief_ms = started.elapsed().as_millis() as i64; + ( + Editorial { + front_page_html, + summaries, + }, + EditorialTimings { + summaries_ms, + brief_ms, + }, + ) } pub fn summary_to_html(summary: &str) -> String { diff --git a/src/curate/mod.rs b/src/curate/mod.rs index c8fc1ea..992749e 100644 --- a/src/curate/mod.rs +++ b/src/curate/mod.rs @@ -112,17 +112,28 @@ impl Curator { } } - /// Stage C: per-article summaries, section intros and the front page (§3.6). + /// Stage C: per-article summaries and the Brief (§14). /// /// Never fails the run: a budget trip or an API error degrades to excerpts. pub async fn editorial(&self, lineup: &Lineup) -> anyhow::Result { + Ok(self.editorial_timed(lineup).await?.0) + } + + /// [`Self::editorial`] plus the `summaries` / `brief` stage timings (§15.4). + pub async fn editorial_timed( + &self, + lineup: &Lineup, + ) -> anyhow::Result<(Editorial, editorial::EditorialTimings)> { if self.llms.editor_or_bulk().is_none() { tracing::info!("--skip-llm: using feed excerpts as summaries"); - return Ok(editorial::fallback_editorial(lineup)); + return Ok(( + editorial::fallback_editorial(lineup), + editorial::EditorialTimings::default(), + )); } let span = tracing::info_span!("llm_editorial", picks = lineup.picks.len()); let _guard = span.enter(); - Ok(editorial::run( + Ok(editorial::run_timed( &self.llms, lineup, &self.config.editorial, diff --git a/src/curate/profile/mod.rs b/src/curate/profile/mod.rs index 52d1fe6..47f4125 100644 --- a/src/curate/profile/mod.rs +++ b/src/curate/profile/mod.rs @@ -304,6 +304,7 @@ pub async fn load_or_build( ), version, built_at, + verdicts: ratings.len().min(verdict_limit), }; db.kv_set(KV_TASTE_PROFILE, &profile.text).await?; tracing::debug!( @@ -472,6 +473,7 @@ pub async fn rebuild( ), version: next_version, built_at, + verdicts: current.len().min(verdict_limit), }; db.kv_set(KV_TASTE_PROFILE, &profile.text).await?; tracing::info!( diff --git a/src/curate/telemetry.rs b/src/curate/telemetry.rs index 0963c93..da339ee 100644 --- a/src/curate/telemetry.rs +++ b/src/curate/telemetry.rs @@ -15,7 +15,8 @@ use sqlx::Row as _; use crate::curate::signals::{Neighbour, Signals, TopInterest}; use crate::db::{Db, fmt_ts}; -use crate::types::{ArticleId, Candidate}; +use crate::report::RunReport; +use crate::types::{ArticleId, Candidate, NearMiss}; /// The stage vocabulary of §7.4, in pipeline order. pub const STAGES: [&str; 7] = [ @@ -230,6 +231,8 @@ 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, pub admitted_by: Option, @@ -247,6 +250,7 @@ impl ExplainRow { 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"), @@ -305,9 +309,11 @@ pub async fn explain_row( ) -> Result, 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) @@ -326,9 +332,11 @@ pub async fn near_misses( ) -> Result, 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) @@ -579,6 +587,265 @@ pub async fn explain_near_misses( 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, 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::>(json).ok()) + .and_then(|names| names.into_iter().next()) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[derive(Debug, Default, Clone, Copy)] +struct UpDown { + rated: i64, + up: i64, + down: 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 { + 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).date().to_string(); + let mut out = String::new(); + let _ = writeln!(out, "stats: last {days} days ({since_date} → {today})"); + + // --- issues and articles --- + let issues: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE date >= ?") + .bind(&since_date) + .fetch_one(db.pool()) + .await?; + let published: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM issue_articles WHERE issue_date >= ?") + .bind(&since_date) + .fetch_one(db.pool()) + .await?; + let _ = writeln!(out, "issues: {issues}"); + let _ = writeln!(out, "articles published: {published}"); + let per_issue = |n: i64| { + if issues > 0 { + format!("{:.1}", n as f64 / issues as f64) + } else { + "n/a".to_string() + } + }; + let _ = writeln!(out, "mean issue size: {} articles", per_issue(published)); + + // --- 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?; + let mut total_ratings = 0i64; + let mut by_label = Vec::new(); + for row in &labels { + let label = row.get::("label"); + let n = row.get::("n"); + if label != "cleared" { + total_ratings += n; + } + by_label.push((label, n)); + } + let _ = writeln!(out, "explicit ratings: {total_ratings}"); + for (label, n) in &by_label { + let _ = writeln!(out, "explicit ratings ({label}): {n}"); + } + let _ = writeln!(out, "ratings per issue: {}", per_issue(total_ratings)); + + // --- 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 per_retriever: BTreeMap = BTreeMap::new(); + let mut exploration_positive = 0i64; + let mut seen: Option = None; + for row in &rated_picks { + let article_id = row.get::("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::("value"); + let retriever = first_retriever(row.get::, _>("admitted_by").as_deref()); + let entry = 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::(&row.get::("signals_json")) + .map(|signals| signals.exploration) + .unwrap_or(false); + if exploration && value > 0.0 { + exploration_positive += 1; + } + } + if per_retriever.is_empty() { + let _ = writeln!(out, "rated picks by admitting retriever: none"); + } + for (retriever, counts) in &per_retriever { + let ratio = if counts.rated > 0 { + format!("{:.0}% up", 100.0 * counts.up as f64 / counts.rated as f64) + } else { + "n/a".to_string() + }; + let _ = writeln!( + out, + "admitted by {retriever}: {} rated · {} up · {} down · {ratio}", + counts.rated, counts.up, counts.down + ); + } + + // --- 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?; + let mut exploration_admitted = 0i64; + let mut exploration_selected = 0i64; + for row in &exploration_rows { + match row.get::("stage").as_str() { + "selected" => { + exploration_admitted += 1; + exploration_selected += 1; + } + "admitted" | "assessed" | "shortlisted" => exploration_admitted += 1, + _ => {} + } + } + let _ = writeln!(out, "exploration admitted: {exploration_admitted}"); + let _ = writeln!(out, "exploration selected: {exploration_selected}"); + let _ = writeln!(out, "exploration rated positively: {exploration_positive}"); + + // --- cost per day per provider (§7.6) --- + let cost_rows = sqlx::query( + "SELECT provider_costs_json FROM runs + WHERE started_at >= ? AND provider_costs_json IS NOT NULL", + ) + .bind(&since) + .fetch_all(db.pool()) + .await?; + let mut totals: BTreeMap = BTreeMap::new(); + for row in &cost_rows { + let raw = row.get::("provider_costs_json"); + let Ok(providers) = + serde_json::from_str::>(&raw) + else { + continue; + }; + for (provider, usage) in providers { + *totals.entry(provider).or_insert(0.0) += usage.cost_usd; + } + } + let mut grand = 0.0; + for (provider, total) in &totals { + grand += total; + let _ = writeln!( + out, + "cost per day ({provider}): ${:.3}", + total / days as f64 + ); + } + let _ = writeln!(out, "cost per day (total): ${:.3}", grand / days as f64); + + // --- 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?; + let mut durations = Vec::new(); + for row in &run_rows { + let started = row.get::("started_at").parse::(); + let finished = row.get::("finished_at").parse::(); + if let (Ok(started), Ok(finished)) = (started, finished) { + durations.push((finished.as_second() - started.as_second()).max(0)); + } + } + if durations.is_empty() { + let _ = writeln!(out, "mean generation time: n/a (0 runs)"); + } else { + let mean = durations.iter().sum::() / durations.len() as i64; + let _ = writeln!( + out, + "mean generation time: {} ({} runs)", + RunReport::format_duration(mean), + durations.len() + ); + } + Ok(out) +} + // --------------------------------------------------------------------------- // `features prune` (§7.1, §7.4) // --------------------------------------------------------------------------- @@ -972,6 +1239,236 @@ mod tests { ); } + #[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}}"#, + ), + ] { + 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 (voyage): $0.001", + "cost per day (total): $0.064", + "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" + ); + + // 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}"); + } + } + #[tokio::test] async fn prune_respects_rated_and_published() { let (_dir, db) = db_with_articles(&[1, 2, 3, 4]).await; diff --git a/src/epub/build.rs b/src/epub/build.rs index eb8120c..76b3542 100644 --- a/src/epub/build.rs +++ b/src/epub/build.rs @@ -1,7 +1,8 @@ //! Chapter ordering and `epub-builder` assembly (spec §3.10). //! -//! Structure: cover → From the Editor → In This Issue → sections (title page, -//! article chapters, discussion chapters) → World Briefing → colophon. The +//! Structure: cover → The Brief → In This Issue → sections (title page, +//! article chapters, discussion chapters) → World Briefing → Behind the paper +//! → colophon. The //! chapters themselves are rendered by [`super::chapters`] and the cover by //! [`super::cover`]; this module decides the order and zips the result. @@ -14,8 +15,8 @@ use crate::types::{Edition, ImageAsset, Issue}; use super::EpubError; use super::chapters::{ - render_colophon, render_front_page, render_in_this_issue, render_section_page, - render_world_briefing, section_names, + render_behind_the_paper, render_colophon, render_front_page, render_in_this_issue, + render_section_page, render_world_briefing, section_names, }; use super::cover::{CoverAsset, render_cover_page}; use super::x4; @@ -85,6 +86,7 @@ pub fn render_all( if let Some(world) = render_world_briefing(issue)? { chapters.push(world); } + chapters.push(render_behind_the_paper(issue)?); chapters.push(render_colophon(issue)?); if edition == Edition::X4 { @@ -272,11 +274,12 @@ mod tests { "sec-Niche Corner", "art-1002", "world", + "behind", "colophon", ] ); let levels: Vec = chapters.iter().map(|c| c.toc_level).collect(); - assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1]); + assert_eq!(levels, vec![1, 1, 1, 1, 2, 3, 1, 2, 1, 1, 1]); for chapter in &chapters { assert_xml_ok(&chapter.xhtml); } diff --git a/src/epub/chapters.rs b/src/epub/chapters.rs index 06664d0..c41d65c 100644 --- a/src/epub/chapters.rs +++ b/src/epub/chapters.rs @@ -10,7 +10,10 @@ use askama::Template; use crate::comments; use crate::html::{text_escape, to_xhtml}; use crate::images; -use crate::types::{Edition, ImageAsset, Issue, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION}; +use crate::types::{ + BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote, + WORLD_BRIEFING_SECTION, +}; use crate::world; use super::EpubError; @@ -102,6 +105,17 @@ struct WorldBriefingChapter { body_html: String, } +#[derive(Template)] +#[template(path = "behind.xhtml", escape = "html")] +struct BehindChapter { + title: String, + summary_line: String, + admitted_line: String, + learned_line: String, + near_misses: Vec, + models_line: String, +} + #[derive(Template)] #[template(path = "colophon.xhtml", escape = "html")] struct ColophonChapter { @@ -435,6 +449,131 @@ pub fn render_world_briefing(issue: &Issue) -> Result, EpubError })) } +/// "1,465" — thousands separators for the counts in Behind the paper. +fn thousands(n: i64) -> String { + let digits = n.abs().to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + if n < 0 { format!("-{out}") } else { out } +} + +/// `Considered 412 articles from 1,465 feeds · 398 eligible · …` (§15.1). +pub fn behind_summary_line(b: &BehindThePaper) -> String { + format!( + "Considered {} articles from {} feeds \u{00b7} {} eligible \u{00b7} {} triaged \u{00b7} {} read closely \u{00b7} {} shortlisted \u{00b7} {} selected.", + thousands(b.considered), + thousands(b.feeds_seen), + thousands(b.eligible), + thousands(b.triaged), + thousands(b.read_closely), + thousands(b.shortlisted), + thousands(b.selected), + ) +} + +/// `Admitted via: triage 60 · interests 20 · your ratings 12 · exploration 5 · blend 23.` +pub fn behind_admitted_line(b: &BehindThePaper) -> String { + let count = |name: &str| b.admitted_by.get(name).copied().unwrap_or(0); + let mut parts = vec![ + format!("triage {}", count("triage")), + format!("interests {}", count("interest")), + format!("your ratings {}", count("knn")), + format!("exploration {}", count("exploration")), + format!("blend {}", count("blend")), + ]; + if count("auto_include") > 0 { + parts.push(format!("always-include {}", count("auto_include"))); + } + format!("Admitted via: {}.", parts.join(" \u{00b7} ")) +} + +/// `Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off.` +pub fn behind_learned_line(b: &BehindThePaper) -> String { + let percent = |gate: f64| (gate.clamp(0.0, 1.0) * 100.0).round() as i64; + let neighbour = if b.knn_gate > 0.0 { + format!("neighbour signal at {}%", percent(b.knn_gate)) + } else { + "neighbour signal off".to_string() + }; + let feed = if b.feed_gate > 0.0 { + format!("feed affinity at {}%", percent(b.feed_gate)) + } else { + "feed affinity off".to_string() + }; + format!( + "Learned signals: {} rated articles with embeddings ({neighbour}); {feed}.", + thousands(b.rated_with_embeddings) + ) +} + +/// ` — <feed> · quality 8.0 · fit 6.5 · shortlisted, not selected`. +pub fn behind_near_miss_line(miss: &NearMiss) -> String { + let mut parts = vec![format!("{} \u{2014} {}", miss.title, miss.feed_title)]; + if let Some(quality) = miss.quality { + parts.push(format!("quality {quality:.1}")); + } + if let Some(fit) = miss.fit { + parts.push(format!("fit {fit:.1}")); + } + parts.push(match &miss.reason { + Some(reason) => format!("{}, {}", miss.stage, reason.replace('_', " ")), + None => miss.stage.clone(), + }); + parts.join(" \u{00b7} ") +} + +/// `Models: triage and assessment … · editor and summaries … · embeddings …. +/// Cost $0.81. Generation 23 min.` +pub fn behind_models_line(b: &BehindThePaper) -> String { + let editorial = if b.models.summaries == b.models.editor { + format!("editor and summaries {}", b.models.editor) + } else { + format!( + "editor {} \u{00b7} summaries {}", + b.models.editor, b.models.summaries + ) + }; + let generation = if b.generation_secs < 60 { + format!("{} s", b.generation_secs.max(0)) + } else { + format!("{} min", (b.generation_secs as f64 / 60.0).round() as i64) + }; + format!( + "Models: triage and assessment {} \u{00b7} {editorial} \u{00b7} embeddings {}. Cost ${:.2}. Generation {generation}.", + b.models.bulk, b.embedding_model, b.cost_usd + ) +} + +/// "Behind the paper": the run's counts, admission mix, learned-signal state, +/// near misses and models (§15.1). Same text in both editions; no links. +pub fn render_behind_the_paper(issue: &Issue) -> Result<Chapter, EpubError> { + let behind = &issue.behind; + let tpl = BehindChapter { + title: "Behind the paper".into(), + summary_line: behind_summary_line(behind), + admitted_line: behind_admitted_line(behind), + learned_line: behind_learned_line(behind), + near_misses: behind + .near_misses + .iter() + .map(behind_near_miss_line) + .collect(), + models_line: behind_models_line(behind), + }; + Ok(Chapter { + id: "behind".into(), + href: "behind.xhtml".into(), + title: "Behind the paper".into(), + xhtml: tpl.render()?, + toc_level: 1, + }) +} + /// Colophon: generation timestamp, models used, token cost, feed counts (§3.10). pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> { let colophon = &issue.colophon; @@ -676,6 +815,69 @@ mod tests { assert!(render_discussion(&issue.lineup.picks[1]).unwrap().is_none()); } + #[test] + fn behind_the_paper_lines_follow_the_plan_shape() { + let issue = issue(); + let behind = &issue.behind; + assert_eq!( + behind_summary_line(behind), + "Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected." + ); + assert_eq!( + behind_admitted_line(behind), + "Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23." + ); + assert_eq!( + behind_learned_line(behind), + "Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off." + ); + assert_eq!( + behind_near_miss_line(&behind.near_misses[0]), + "The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected" + ); + assert_eq!( + behind_near_miss_line(&behind.near_misses[1]), + "Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted" + ); + assert_eq!( + behind_models_line(behind), + "Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min." + ); + let chapter = render_behind_the_paper(&issue).unwrap(); + assert_eq!(chapter.id, "behind"); + assert_eq!(chapter.href, "behind.xhtml"); + assert!(chapter.xhtml.contains("Behind the paper")); + assert!(chapter.xhtml.contains("1,465 feeds")); + assert!(chapter.xhtml.contains("The One That Got Away")); + assert!(!chapter.xhtml.contains("<a "), "no links in the chapter"); + assert_xml_ok(&chapter.xhtml); + + // Auto-includes are named only when there were any; a short run is in + // seconds; a differing summaries model is listed separately. + let mut short = behind.clone(); + short.admitted_by.insert("auto_include".into(), 2); + short.generation_secs = 48; + short.feed_gate = 0.6; + short.models.summaries = "deepseek-v4-flash".into(); + short.near_misses.clear(); + assert!(behind_admitted_line(&short).ends_with("blend 23 \u{00b7} always-include 2.")); + assert!(behind_learned_line(&short).ends_with("feed affinity at 60%.")); + assert!( + behind_models_line(&short) + .contains("editor claude-opus-5 \u{00b7} summaries deepseek-v4-flash") + ); + assert!(behind_models_line(&short).ends_with("Generation 48 s.")); + let mut issue = issue; + issue.behind = short; + let chapter = render_behind_the_paper(&issue).unwrap(); + assert!(chapter.xhtml.contains("None recorded")); + assert_xml_ok(&chapter.xhtml); + assert_eq!(thousands(0), "0"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1_000), "1,000"); + assert_eq!(thousands(1_234_567), "1,234,567"); + } + #[test] fn world_and_colophon_chapters_render() { let issue = issue(); diff --git a/src/epub/fixtures.rs b/src/epub/fixtures.rs index 1cd8278..f70d780 100644 --- a/src/epub/fixtures.rs +++ b/src/epub/fixtures.rs @@ -155,6 +155,53 @@ pub fn issue() -> Issue { cost_usd: 0.0731, generator_version: "daily-epub 0.1.0".into(), }, + behind: BehindThePaper { + considered: 412, + feeds_seen: 1465, + eligible: 398, + triaged: 398, + read_closely: 120, + shortlisted: 60, + selected: 2, + admitted_by: BTreeMap::from([ + ("triage".to_string(), 60), + ("interest".to_string(), 20), + ("knn".to_string(), 12), + ("exploration".to_string(), 5), + ("blend".to_string(), 23), + ]), + rated_with_embeddings: 14, + knn_gate: 0.35, + feed_gate: 0.0, + near_misses: vec![ + NearMiss { + article_id: 3, + title: "The One That Got Away".into(), + feed_title: "Example Feed".into(), + quality: Some(8.0), + fit: Some(6.5), + stage: "shortlisted".into(), + reason: Some("not_selected".into()), + }, + NearMiss { + article_id: 4, + title: "Never Read Closely".into(), + feed_title: "Other Feed".into(), + quality: None, + fit: None, + stage: "triaged".into(), + reason: Some("not_admitted".into()), + }, + ], + models: Models { + bulk: "deepseek-v4-flash".into(), + editor: "claude-opus-5".into(), + summaries: "claude-opus-5".into(), + }, + embedding_model: "voyage-4-lite".into(), + cost_usd: 0.81, + generation_secs: 23 * 60 + 12, + }, } } diff --git a/src/epub/mod.rs b/src/epub/mod.rs index 82805e8..e13a913 100644 --- a/src/epub/mod.rs +++ b/src/epub/mod.rs @@ -23,6 +23,7 @@ pub const CHAPTER_ORDER: &[&str] = &[ "in-this-issue", "sections", "world-briefing", + "behind-the-paper", "colophon", ]; @@ -192,6 +193,7 @@ mod tests { "OEBPS/disc-1001.xhtml", "OEBPS/art-1002.xhtml", "OEBPS/world.xhtml", + "OEBPS/behind.xhtml", "OEBPS/colophon.xhtml", ] { assert!( diff --git a/src/epub/templates/README.md b/src/epub/templates/README.md index fd806b7..0d02ec6 100644 --- a/src/epub/templates/README.md +++ b/src/epub/templates/README.md @@ -8,12 +8,13 @@ the crate root points askama here (`dirs = ["src/epub/templates"]`). |---|---|---| | `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) | | `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image | -| `front_page.xhtml` | `FrontPage` | "From the Editor" + issue stats line | +| `front_page.xhtml` | `FrontPage` | "The Brief" + issue stats line | | `in_this_issue.xhtml` | `InThisIssue` | Introduction chapter: per-section linked index | -| `section.xhtml` | `SectionPage` | Section title page + LLM intro | +| `section.xhtml` | `SectionPage` | Section title page (name only) | | `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer | | `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` | | `world_briefing.xhtml` | `WorldBriefingChapter` | Wikipedia Current Events (§3.8), body from `world::render_xhtml` | +| `behind.xhtml` | `BehindChapter` | "Behind the paper": run counts, admission mix, near misses, models (§15.1) | | `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts | | `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia | | `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` | diff --git a/src/epub/templates/behind.xhtml b/src/epub/templates/behind.xhtml new file mode 100644 index 0000000..1971d43 --- /dev/null +++ b/src/epub/templates/behind.xhtml @@ -0,0 +1,20 @@ +{% extends "base.xhtml" %} +{% block body_class %}behind{% endblock %} +{% block content %} + <h1>Behind the paper</h1> + <p class="fact-line">{{ summary_line }}</p> + <p class="fact-line">{{ admitted_line }}</p> + <p class="fact-line">{{ learned_line }}</p> + <h2>Near misses</h2> + <p class="fact-line"><em>Highest utility not selected.</em></p> +{% if near_misses.is_empty() %} + <p class="fact-line">None recorded for this run.</p> +{% else %} + <ul class="near-misses"> +{% for line in near_misses %} + <li>{{ line }}</li> +{% endfor %} + </ul> +{% endif %} + <p class="fact-line">{{ models_line }}</p> +{% endblock %} diff --git a/src/lib.rs b/src/lib.rs index 5fd4c4a..4ec7575 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod extract; pub mod html; pub mod http; pub mod images; +pub mod lock; pub mod miniflux; pub mod pipeline; pub mod publish; diff --git a/src/lock.rs b/src/lock.rs new file mode 100644 index 0000000..bc4faa5 --- /dev/null +++ b/src/lock.rs @@ -0,0 +1,168 @@ +//! One writer at a time (plan §5): an advisory `flock(LOCK_EX | LOCK_NB)` on +//! `<database_path>.lock`, taken in `main` for `generate`, `profile rebuild`, +//! `features backfill` and `backfill-social`. No table, no TTL: the kernel +//! releases the lock when the holder exits, however it exits. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write as _}; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; + +/// Held for as long as the value lives; dropping it (or dying) releases it. +#[derive(Debug)] +pub struct RunLock { + _file: File, + pub path: PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockError { + /// Another process holds the lock; `holder` names its command when known. + #[error("{holder} is already running")] + Held { holder: String, path: PathBuf }, + #[error("lock file {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +/// `<database_path>.lock`, next to the database. +pub fn lock_path(database_path: &Path) -> PathBuf { + let mut name = database_path.file_name().unwrap_or_default().to_os_string(); + name.push(".lock"); + database_path.with_file_name(name) +} + +/// Take the lock for `command`, writing the command's name into the file so a +/// second invocation can say who is holding it. +pub fn acquire(database_path: &Path, command: &str) -> Result<RunLock, LockError> { + let path = lock_path(database_path); + let io_err = |source: io::Error| LockError::Io { + path: path.clone(), + source, + }; + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(io_err)?; + } + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(io_err)?; + // SAFETY: `file` owns a valid open descriptor for the duration of the call. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc != 0 { + let source = io::Error::last_os_error(); + if source.kind() == io::ErrorKind::WouldBlock { + let holder = std::fs::read_to_string(&path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "generate".to_string()); + return Err(LockError::Held { holder, path }); + } + return Err(io_err(source)); + } + // Best effort: the name is a courtesy for the error message, never load-bearing. + let _ = file.set_len(0); + let _ = writeln!(file, "{command}"); + Ok(RunLock { _file: file, path }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + const HELPER_ENV: &str = "DAILY_EPUB_LOCK_TEST_HOLDER"; + + #[test] + fn lock_path_sits_next_to_the_database() { + assert_eq!( + lock_path(Path::new("/var/lib/daily-epub/daily-epub.db")), + PathBuf::from("/var/lib/daily-epub/daily-epub.db.lock") + ); + assert_eq!(lock_path(Path::new("x.db")), PathBuf::from("x.db.lock")); + } + + #[test] + fn second_acquire_in_the_same_process_loses_and_names_the_holder() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("nested").join("daily-epub.db"); + let first = acquire(&db, "generate").expect("first lock"); + assert!(first.path.exists(), "the lock file is created on demand"); + match acquire(&db, "profile rebuild") { + Err(LockError::Held { holder, .. }) => { + assert_eq!(holder, "generate"); + assert_eq!( + LockError::Held { + holder, + path: first.path.clone() + } + .to_string(), + "generate is already running" + ); + } + other => panic!("expected the lock to be held, got {other:?}"), + } + drop(first); + let again = acquire(&db, "backfill-social").expect("released on drop"); + assert_eq!( + std::fs::read_to_string(&again.path).unwrap().trim(), + "backfill-social" + ); + } + + /// Not a test of its own: when `HELPER_ENV` names a database path, this + /// body takes the lock and holds it until it is killed. The parent below + /// spawns the test binary with that variable set. + #[test] + fn lock_holder_helper() { + let Ok(db) = std::env::var(HELPER_ENV) else { + return; + }; + let _held = acquire(Path::new(&db), "helper").expect("helper takes the lock"); + std::thread::sleep(Duration::from_secs(30)); + } + + #[test] + fn a_killed_holder_frees_the_lock_for_the_next_process() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("daily-epub.db"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "lock::tests::lock_holder_helper", "--nocapture"]) + .env(HELPER_ENV, &db) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn the holder process"); + + // Wait (well under a second) until the child has written its name. + let path = lock_path(&db); + let deadline = Instant::now() + Duration::from_millis(900); + while std::fs::read_to_string(&path) + .map(|s| s.trim() != "helper") + .unwrap_or(true) + { + assert!(Instant::now() < deadline, "the helper never took the lock"); + std::thread::sleep(Duration::from_millis(10)); + } + match acquire(&db, "generate") { + Err(LockError::Held { holder, .. }) => assert_eq!(holder, "helper"), + other => panic!("another process holds the lock, got {other:?}"), + } + + child.kill().expect("kill the holder"); + child.wait().expect("reap the holder"); + let lock = acquire(&db, "generate").expect("the kernel released the dead holder's lock"); + assert_eq!( + std::fs::read_to_string(&lock.path).unwrap().trim(), + "generate" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 67f7a88..91289f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,9 +15,9 @@ use daily_epub::curate::embedding::{self, BACKFILL_CONFIRM_TOKENS}; use daily_epub::curate::telemetry; use daily_epub::db::Db; use daily_epub::pipeline::{self, GenerateOptions, GenerateOutcome}; -use daily_epub::report::RunReport; +use daily_epub::report::{RunReport, VOYAGE_PROVIDER}; use daily_epub::types::{ArticleId, RatingEvent, Vote}; -use daily_epub::{curate, http, server, social}; +use daily_epub::{curate, http, lock, server, social}; /// A personalized daily newspaper, delivered as an EPUB. #[derive(Debug, Parser)] @@ -45,6 +45,8 @@ enum Command { Ratings(RatingsCommand), /// Why an article was (not) in the paper, from persisted run telemetry. Explain(ExplainArgs), + /// The weekly numbers: issues, ratings, retriever yield, cost, timing. + Stats(StatsArgs), /// Embedding cache and telemetry maintenance. #[command(subcommand)] Features(FeaturesCommand), @@ -199,6 +201,14 @@ struct ExplainArgs { near_misses: Option<usize>, } +/// `stats [--days 14]` (plan §15.3). +#[derive(Debug, clap::Args)] +struct StatsArgs { + /// How many days back to summarize. + #[arg(long, default_value_t = 14)] + days: i64, +} + #[derive(Debug, Subcommand)] enum FeaturesCommand { /// Embed rated and published articles, then interests, into the cache. @@ -243,6 +253,14 @@ async fn main() -> Result<()> { let config = Config::load(cli.config.as_deref()).context("loading configuration")?; tracing::debug!(?config.database_path, "configuration loaded"); + // One writer at a time (§5); read-only commands never wait on it. + let _lock = match lock_holder(&cli.command) { + Some(name) => { + Some(lock::acquire(&config.database_path, name).map_err(|e| anyhow::anyhow!("{e}"))?) + } + None => None, + }; + match cli.command { Command::Generate(args) => { let db = Db::open_and_migrate(&config.database_path).await?; @@ -265,6 +283,11 @@ async fn main() -> Result<()> { let db = Db::open_and_migrate(&config.database_path).await?; cmd_explain(&db, args).await?; } + Command::Stats(args) => { + let db = Db::open_and_migrate(&config.database_path).await?; + let text = telemetry::stats(&db, args.days, jiff::Timestamp::now()).await?; + print!("{text}"); + } Command::Features(command) => { let db = Db::open_and_migrate(&config.database_path).await?; cmd_features(&config, &db, command).await?; @@ -282,6 +305,24 @@ async fn main() -> Result<()> { Ok(()) } +/// The commands that write the database and provider budgets and so hold the +/// run lock (§5): `generate`, `profile rebuild`, `features backfill`, +/// `backfill-social`. Everything else is read-only or its own writer. +fn lock_holder(command: &Command) -> Option<&'static str> { + match command { + Command::Generate(_) => Some("generate"), + Command::Profile(ProfileCommand::Rebuild) => Some("profile rebuild"), + Command::Features(FeaturesCommand::Backfill(_)) => Some("features backfill"), + Command::BackfillSocial(_) => Some("backfill-social"), + Command::Serve + | Command::Ratings(_) + | Command::Explain(_) + | Command::Stats(_) + | Command::Features(FeaturesCommand::Prune) + | Command::Db(_) => None, + } +} + /// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging"). fn init_tracing() { let filter = EnvFilter::try_from_default_env() @@ -343,44 +384,10 @@ fn print_report(report: &RunReport) { report.counts.duplicates_merged, report.counts.entries_dropped, ); - println!( - "curation: {} considered → {} eligible → {} triaged → {} assessed → {} shortlisted → {} selected", - report.counts.articles, - report.counts.eligible, - report.counts.triaged, - report.counts.assessed, - report.counts.shortlisted, - report.counts.selected, - ); - println!( - "admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}", - report - .counts - .admitted_by - .get("triage") - .copied() - .unwrap_or(0), - report - .counts - .admitted_by - .get("interest") - .copied() - .unwrap_or(0), - report.counts.admitted_by.get("knn").copied().unwrap_or(0), - report - .counts - .admitted_by - .get("exploration") - .copied() - .unwrap_or(0), - report.counts.admitted_by.get("blend").copied().unwrap_or(0), - report - .counts - .admitted_by - .get("auto_include") - .copied() - .unwrap_or(0), - ); + // The same four lines the run logged (§15.4). + for line in report.info_block() { + println!("{line}"); + } println!( "tokens: {} input · {} cache read · {} cache write · {} output · {} voyage = ${:.4}", report.usage.input_tokens, @@ -391,6 +398,9 @@ fn print_report(report: &RunReport) { report.cost_usd, ); for (provider, usage) in &report.provider_costs { + if provider == VOYAGE_PROVIDER { + continue; // embedding tokens are printed on their own line below + } println!( " {provider}: {} input · {} cache read · {} cache write · {} output = ${:.4}", usage.usage.input_tokens, @@ -792,6 +802,56 @@ mod tests { assert_eq!(cli.config, Some(PathBuf::from("/tmp/x.toml"))); } + #[test] + fn only_the_writing_commands_take_the_lock() { + let parse = |args: &[&str]| { + Cli::try_parse_from(std::iter::once("daily-epub").chain(args.iter().copied())) + .unwrap() + .command + }; + assert_eq!(lock_holder(&parse(&["generate"])), Some("generate")); + assert_eq!( + lock_holder(&parse(&["profile", "rebuild"])), + Some("profile rebuild") + ); + assert_eq!( + lock_holder(&parse(&["features", "backfill"])), + Some("features backfill") + ); + assert_eq!( + lock_holder(&parse(&["backfill-social"])), + Some("backfill-social") + ); + for args in [ + vec!["serve"], + vec!["explain", "--date", "2026-09-02", "--near-misses"], + vec!["stats"], + vec!["ratings", "list"], + vec!["db", "migrate"], + vec!["features", "prune"], + ] { + assert_eq!(lock_holder(&parse(&args)), None, "{args:?}"); + } + } + + #[test] + fn parses_stats() { + match Cli::try_parse_from(["daily-epub", "stats"]) + .unwrap() + .command + { + Command::Stats(args) => assert_eq!(args.days, 14), + other => panic!("expected stats, got {other:?}"), + } + match Cli::try_parse_from(["daily-epub", "stats", "--days", "7"]) + .unwrap() + .command + { + Command::Stats(args) => assert_eq!(args.days, 7), + other => panic!("expected stats, got {other:?}"), + } + } + #[test] fn parses_explain_and_features() { match Cli::try_parse_from([ diff --git a/src/pipeline.rs b/src/pipeline.rs index e436a39..4a60ccc 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -2,9 +2,10 @@ //! //! ```text //! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment -//! ─▶ signals ─▶ triage ─▶ admission ─▶ LLM scoring ─▶ selection -//! ─▶ comments ─▶ editorial -//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report +//! ─▶ hygiene ─▶ embeddings + signals ─▶ triage ─▶ admission ─▶ deep assessment +//! ─▶ utility + shortlist ─▶ editor ─▶ comments ─▶ summaries + brief +//! ─▶ world briefing ─▶ behind the paper ─▶ EPUB (standard + X4) ─▶ XTC +//! ─▶ publish ─▶ report //! ``` //! //! Failure policy (notes §3): @@ -40,10 +41,10 @@ use crate::db::Db; use crate::extract::Extractor; use crate::miniflux::MinifluxClient; use crate::publish::Published; -use crate::report::{ProviderUsage, RunReport, RunStatus}; +use crate::report::{ProviderUsage, RunReport, RunStatus, VOYAGE_PROVIDER}; use crate::types::{ - Article, ArticleId, Artifact, Candidate, Colophon, Edition, Issue, IssueMeta, Lineup, Models, - reading_minutes, + Article, ArticleId, Artifact, BehindThePaper, Candidate, Colophon, Edition, Issue, IssueMeta, + Lineup, Models, TokenUsage, reading_minutes, }; use crate::{comments, dedupe, epub, http, miniflux, publish, social, world}; @@ -169,6 +170,7 @@ pub fn build_issue( editorial, world_briefing, colophon, + behind: BehindThePaper::default(), } } @@ -238,6 +240,10 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul let stages = match run_stages(&ctx, window_start, window_end, &mut report).await { Ok(stages) => { report.finish(Timestamp::now()); + // The once-per-run info block of §15.4. + for line in report.info_block() { + tracing::info!("{line}"); + } stages } Err(e) => { @@ -483,23 +489,7 @@ async fn run_stages( record_candidates(ctx, &personalized) .await .context("recording admission telemetry")?; - tracing::info!( - "admission: triage {} · interest {} · knn {} · exploration {} · blend {} · auto {}", - admission.admitted_by.get("triage").copied().unwrap_or(0), - admission.admitted_by.get("interest").copied().unwrap_or(0), - admission.admitted_by.get("knn").copied().unwrap_or(0), - admission - .admitted_by - .get("exploration") - .copied() - .unwrap_or(0), - admission.admitted_by.get("blend").copied().unwrap_or(0), - admission - .admitted_by - .get("auto_include") - .copied() - .unwrap_or(0), - ); + tracing::debug!(admitted_by = ?admission.admitted_by, "admission complete"); report.timings.record("admit", elapsed_ms(stage)); // --- Stage 9: deep assessment (§12.1) --- @@ -606,19 +596,23 @@ async fn run_stages( report.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64; report.timings.record("comments", elapsed_ms(stage)); - // --- Stage 9: editorial (§3.6 C) --- - let stage = Timestamp::now(); - let editorial = match curator.editorial(&lineup).await { - Ok(editorial) => editorial, + // --- Stage 9: editorial — summaries and the Brief (§14) --- + let editorial = match curator.editorial_timed(&lineup).await { + Ok((editorial, timings)) => { + report.timings.record("summaries", timings.summaries_ms); + report.timings.record("brief", timings.brief_ms); + editorial + } Err(e) => { report.warn(format!( "editorial generation failed; using excerpts: {e:#}" )); + report.timings.record("summaries", 0); + report.timings.record("brief", 0); editorial::fallback_editorial(&lineup) } }; apply_summaries(&mut lineup, &editorial); - report.timings.record("editorial", elapsed_ms(stage)); // --- Stage 10: completed-day World Briefing (§3.8), best effort --- // Editorial retains budget priority; only the remaining metered budget is @@ -656,6 +650,19 @@ async fn run_stages( cost_usd: editor_meter.cost_usd(), }, ); + // Voyage rides along in `provider_costs_json` (§7.6) so `stats` can price + // it per day; its tokens are embedding input, kept out of the LLM aggregate. + report.provider_costs.insert( + VOYAGE_PROVIDER.into(), + ProviderUsage { + usage: TokenUsage { + input_tokens: report.voyage_tokens, + ..TokenUsage::default() + }, + cost_usd: report.voyage_cost_usd, + }, + ); + let total_cost = bulk_meter.cost_usd() + editor_meter.cost_usd() + report.voyage_cost_usd; let summary_model = match config.editorial.summary_model { crate::config::SummaryModel::Editor if curator.llms.editor.is_some() => { config.anthropic.model.clone() @@ -668,30 +675,32 @@ async fn run_stages( .iter() .map(|(provider, usage)| (provider.clone(), usage.cost_usd)) .collect(); + let models = Models { + bulk: if bulk_available { + config.deepseek.model.clone() + } else { + "none".into() + }, + editor: if curator.llms.editor.is_some() { + config.anthropic.model.clone() + } else if bulk_available { + format!("{} (bulk fallback)", config.deepseek.model) + } else { + "none".into() + }, + summaries: summary_model, + }; let colophon = Colophon { provider_costs, - models: Models { - bulk: if bulk_available { - config.deepseek.model.clone() - } else { - "none".into() - }, - editor: if curator.llms.editor.is_some() { - config.anthropic.model.clone() - } else if bulk_available { - format!("{} (bulk fallback)", config.deepseek.model) - } else { - "none".into() - }, - summaries: summary_model, - }, + models: models.clone(), entries_fetched: report.counts.entries_fetched, feeds_seen: report.counts.feeds_seen, candidates: report.counts.candidates, - cost_usd: bulk_meter.cost_usd() + editor_meter.cost_usd(), + cost_usd: total_cost, generator_version: format!("daily-epub {}", crate::VERSION), }; - let issue = build_issue( + let behind = behind_the_paper(ctx, report, models, total_cost).await; + let mut issue = build_issue( date, issue_number, Timestamp::now(), @@ -700,6 +709,7 @@ async fn run_stages( world_briefing, colophon, ); + issue.behind = behind; // --- Stage 12: build both EPUB editions (§3.10) — fatal on failure --- let stage = Timestamp::now(); @@ -757,6 +767,51 @@ async fn run_stages( }) } +/// Near misses listed in the "Behind the paper" chapter (§15.1). +const NEAR_MISSES_IN_PAPER: usize = 10; + +/// The facts of §15.1, from the report so far and the run's `candidate_runs` +/// rows (selection telemetry must already be written). Never fails: a +/// telemetry read error leaves the near-miss list empty. +async fn behind_the_paper( + ctx: &StageContext<'_>, + report: &RunReport, + models: Models, + cost_usd: f64, +) -> BehindThePaper { + let near_misses = + match telemetry::paper_near_misses(ctx.db, ctx.run_id, NEAR_MISSES_IN_PAPER).await { + Ok(misses) => misses, + Err(error) => { + tracing::warn!(%error, "could not read near misses for the paper"); + Vec::new() + } + }; + let counts = &report.counts; + BehindThePaper { + considered: counts.articles, + feeds_seen: counts.feeds_seen, + eligible: counts.eligible, + triaged: counts.triaged, + read_closely: counts.assessed, + shortlisted: counts.shortlisted, + selected: counts.selected, + admitted_by: counts.admitted_by.clone(), + rated_with_embeddings: counts.rated_with_embeddings, + knn_gate: counts.knn_gate, + feed_gate: counts.feed_gate, + near_misses, + models, + embedding_model: if counts.embedded > 0 { + ctx.config.voyage.model.clone() + } else { + "none".into() + }, + cost_usd, + generation_secs: (Timestamp::now().as_second() - ctx.started_at.as_second()).max(0), + } +} + /// The embedding cache with a Voyage client behind it, or cache-only under /// `--skip-embeddings`, `voyage.enabled = false` or a missing key (§16, §17). fn build_embedding_service( @@ -872,6 +927,8 @@ async fn prepare_features( } }; report.counts.rated_with_embeddings = preference.rated_with_embeddings as i64; + report.counts.knn_gate = preference.knn_gate; + report.counts.feed_gate = preference.feed_gate; for candidate in candidates.iter_mut() { candidate.signals = computed .remove(&candidate.article.id) @@ -1013,6 +1070,7 @@ async fn build_llms( return Llms::default(); } }; + report.counts.verdicts_in_prompt = profile.verdicts as i64; if ctx.skip_llm { tracing::info!("--skip-llm: profile rebuilt; no provider calls will be made"); return Llms::default(); @@ -1047,6 +1105,7 @@ async fn build_llms( version = rebuilt.version, "taste profile rebuilt with editor-or-bulk" ); + report.counts.verdicts_in_prompt = rebuilt.verdicts as i64; llms = make_clients(rebuilt.text); } Ok(None) => {} @@ -1663,6 +1722,66 @@ mod tests { .unwrap(); assert!(misses.contains("not selected, by utility"), "{misses}"); assert!(misses.contains("shortlisted, not_selected"), "{misses}"); + + // The Behind-the-paper facts come from the same rows and the report. + report.counts.articles = 4; + report.counts.feeds_seen = 4; + report.counts.admitted = 2; + report.counts.admitted_by = BTreeMap::from([("interest".to_string(), 2)]); + report.counts.shortlisted = 2; + report.counts.selected = 1; + let behind = behind_the_paper(&ctx, &report, Models::default(), 0.25).await; + assert_eq!(behind.considered, 4); + assert_eq!(behind.feeds_seen, 4); + assert_eq!(behind.eligible, 2); + assert_eq!(behind.shortlisted, 2); + assert_eq!(behind.selected, 1); + assert_eq!(behind.admitted_by.get("interest"), Some(&2)); + assert_eq!(behind.rated_with_embeddings, 0); + assert_eq!(behind.knn_gate, 0.0); + assert_eq!(behind.embedding_model, h.config.voyage.model); + assert_eq!(behind.cost_usd, 0.25); + assert_eq!(behind.near_misses.len(), 1, "one shortlisted, not selected"); + let miss = &behind.near_misses[0]; + assert_eq!(miss.article_id, loser); + assert_eq!( + miss.title, + format!( + "Post {}", + h.articles + .iter() + .find(|a| a.id == loser) + .unwrap() + .best_entry_id + ) + ); + assert_eq!( + miss.feed_title, + h.articles + .iter() + .find(|a| a.id == loser) + .unwrap() + .feed_title + ); + assert_eq!(miss.stage, "shortlisted"); + assert_eq!(miss.reason.as_deref(), Some("not_selected")); + assert!(miss.quality.is_none(), "no deep assessment ran"); + let chapter = crate::epub::chapters::render_behind_the_paper(&Issue { + behind: behind.clone(), + ..crate::epub::fixtures::issue() + }) + .unwrap(); + assert!( + chapter.xhtml.contains("Considered 4 articles from 4 feeds"), + "{}", + chapter.xhtml + ); + assert!(chapter.xhtml.contains(&miss.title), "{}", chapter.xhtml); + assert!( + chapter.xhtml.contains("shortlisted, not selected"), + "{}", + chapter.xhtml + ); } #[tokio::test] diff --git a/src/publish.rs b/src/publish.rs index 0e26d5b..2f96af3 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -752,6 +752,7 @@ mod tests { editorial: Editorial::default(), world_briefing: None, colophon: Colophon::default(), + behind: Default::default(), } } diff --git a/src/report.rs b/src/report.rs index 001e0fb..76aaf00 100644 --- a/src/report.rs +++ b/src/report.rs @@ -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] diff --git a/src/types.rs b/src/types.rs index fdcdd16..fa22e81 100644 --- a/src/types.rs +++ b/src/types.rs @@ -383,6 +383,9 @@ pub struct TasteProfile { pub text: String, pub version: i64, pub built_at: Timestamp, + /// Explicit verdicts rendered into the prompt's "Recent verdicts" block (§8.4). + #[serde(default)] + pub verdicts: usize, } // --------------------------------------------------------------------------- @@ -562,6 +565,9 @@ pub struct Issue { pub world_briefing: Option<WorldBriefing>, /// Colophon facts: models used, token cost, feed counts (§3.10). pub colophon: Colophon, + /// The "Behind the paper" chapter's facts (§15.1); filled by the pipeline. + #[serde(default)] + pub behind: BehindThePaper, } /// Resolved model names printed in the colophon (§15.1). @@ -584,6 +590,49 @@ pub struct Colophon { pub generator_version: String, } +/// One of the highest-utility articles that did not make the paper (§15.1). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct NearMiss { + pub article_id: ArticleId, + pub title: String, + pub feed_title: String, + pub quality: Option<f64>, + pub fit: Option<f64>, + /// `candidate_runs.stage` reached. + pub stage: String, + /// `candidate_runs.excluded_reason`, when the row carries one. + pub reason: Option<String>, +} + +/// Facts for the "Behind the paper" chapter (§15.1), derived from the run's +/// report and its `candidate_runs` rows. Templates render it verbatim. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct BehindThePaper { + /// Deduplicated articles the run looked at. + pub considered: i64, + pub feeds_seen: i64, + pub eligible: i64, + pub triaged: i64, + /// Deep-assessed ("read closely"). + pub read_closely: i64, + pub shortlisted: i64, + pub selected: i64, + /// First admitting retriever → count (§11). + pub admitted_by: BTreeMap<String, i64>, + pub rated_with_embeddings: i64, + /// The neighbour signal's gate ramp, 0–1 (§9.2). + pub knn_gate: f64, + /// The feed-affinity gate ramp, 0–1 (§9.3). + pub feed_gate: f64, + pub near_misses: Vec<NearMiss>, + pub models: Models, + pub embedding_model: String, + /// Every provider, Voyage included. + pub cost_usd: f64, + /// Wall clock from the run's start to issue assembly. + pub generation_secs: i64, +} + /// A downloaded, re-encoded image embedded in an edition (§3.10 images). #[derive(Debug, Clone, PartialEq)] pub struct ImageAsset { diff --git a/tests/m4_epub.rs b/tests/m4_epub.rs index d517e93..ab90737 100644 --- a/tests/m4_epub.rs +++ b/tests/m4_epub.rs @@ -89,6 +89,7 @@ fn standard_edition_is_a_well_formed_epub3_archive() { "OEBPS/sec-niche-corner.xhtml", "OEBPS/art-1002.xhtml", "OEBPS/world.xhtml", + "OEBPS/behind.xhtml", "OEBPS/colophon.xhtml", ] { assert!(contains_entry(&zip, entry), "missing {entry}"); @@ -186,11 +187,77 @@ fn chapter_ids_hrefs_and_toc_levels_are_stable() { ), ("art-1002".into(), "art-1002.xhtml".into(), 2), ("world".into(), "world.xhtml".into(), 1), + ("behind".into(), "behind.xhtml".into(), 1), ("colophon".into(), "colophon.xhtml".into(), 1), ] ); } +/// "Behind the paper" sits between the World Briefing and the colophon in both +/// editions, carries the counts, the admission mix, the learned-signal state, +/// the near misses and the models, and has no links (§15.1). +#[test] +fn behind_the_paper_renders_counts_and_near_misses_in_both_editions() { + let issue = fixtures::issue(); + for edition in [Edition::Standard, Edition::X4] { + let (_dir, _, zip) = build_edition_to_bytes(&issue, edition); + let behind = read_entry(&zip, "OEBPS/behind.xhtml"); + assert!(behind.contains("<h1>Behind the paper</h1>"), "{behind}"); + assert!( + behind.contains( + "Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected." + ), + "{behind}" + ); + assert!( + behind.contains( + "Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23." + ), + "{behind}" + ); + assert!( + behind.contains( + "Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off." + ), + "{behind}" + ); + assert!(behind.contains("<h2>Near misses</h2>"), "{behind}"); + assert!( + behind.contains( + "<li>The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected</li>" + ), + "{behind}" + ); + assert!( + behind.contains( + "<li>Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted</li>" + ), + "{behind}" + ); + assert!( + behind.contains( + "Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min." + ), + "{behind}" + ); + assert!(!behind.contains("<a "), "no links in {edition:?}: {behind}"); + assert!(!behind.contains(" ")); + for tag in ["html", "head", "body", "div", "p", "ul", "li", "h1", "h2"] { + // `<li` would also match `<link`; count real opening tags only. + let opens = behind.matches(&format!("<{tag}>")).count() + + behind.matches(&format!("<{tag} ")).count(); + let closes = behind.matches(&format!("</{tag}>")).count(); + assert_eq!(opens, closes, "unbalanced <{tag}> in {edition:?}"); + } + + // Ordering: world → behind → colophon in the spine. + let opf = read_entry(&zip, "OEBPS/content.opf"); + let position = |href: &str| opf.find(&format!("href=\"{href}\"")).expect(href); + assert!(position("world.xhtml") < position("behind.xhtml")); + assert!(position("behind.xhtml") < position("colophon.xhtml")); + } +} + #[test] fn every_chapter_is_parseable_xhtml() { let issue = fixtures::issue();