Curation v2 step 6: Behind the paper, stats, run report block, lock

Behind-the-paper chapter (behind.xhtml, both editions) built from the run's
StageCounts and candidate_runs near misses; daily-epub stats [--days N];
StageCounts gains knn/feed gates and verdicts_in_prompt, timings split
into summaries + brief, the four-line §15.4 info block logged once per
run and printed by print_report; src/lock.rs flock guard on
<database_path>.lock for generate, profile rebuild, features backfill and
backfill-social; README updated for the new CLI, env vars and costs.

Implemented by a Claude agent from docs/plans/curation-v2-briefs/step6.md;
reviewed against plan §5, §15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
This commit is contained in:
2026-09-02 16:21:06 +00:00
co-authored by Claude Fable 5.1
parent 05a74a0dcf
commit d261cd485d
22 changed files with 1583 additions and 113 deletions
Generated
+1
View File
@@ -831,6 +831,7 @@ dependencies = [
"hmac",
"image",
"jiff",
"libc",
"rand 0.10.2",
"reqwest",
"resvg",
+1
View File
@@ -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"
+49 -6
View File
@@ -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.050.30 in DeepSeek tokens plus
~$0.500.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
`<database_path>.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 <database_path>.lock)
```
Two modules are worth knowing about before you go looking for their contents.
+1
View File
@@ -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(
+31 -3
View File
@@ -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 {
if lineup.picks.is_empty() {
return fallback_editorial(lineup);
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), 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)
}
};
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 {
+14 -3
View File
@@ -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<Editorial> {
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,
+2
View File
@@ -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!(
+498 -1
View File
@@ -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<String>,
pub admitted_by: Option<String>,
@@ -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<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)
@@ -326,9 +332,11 @@ pub async fn near_misses(
) -> 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)
@@ -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<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())
}
#[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<String> {
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::<String, _>("label");
let n = row.get::<i64, _>("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<String, UpDown> = BTreeMap::new();
let mut exploration_positive = 0i64;
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 = 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 {
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::<String, _>("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<String, f64> = BTreeMap::new();
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;
};
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::<String, _>("started_at").parse::<Timestamp>();
let finished = row.get::<String, _>("finished_at").parse::<Timestamp>();
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::<i64>() / 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;
+8 -5
View File
@@ -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<u8> = 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);
}
+203 -1
View File
@@ -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<String>,
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<Option<Chapter>, 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)
)
}
/// `<title> — <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();
+47
View File
@@ -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,
},
}
}
+2
View File
@@ -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!(
+3 -2
View File
@@ -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` |
+20
View File
@@ -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 %}
+1
View File
@@ -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;
+168
View File
@@ -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"
);
}
}
+100 -40
View File
@@ -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([
+153 -34
View File
@@ -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,9 +675,7 @@ async fn run_stages(
.iter()
.map(|(provider, usage)| (provider.clone(), usage.cost_usd))
.collect();
let colophon = Colophon {
provider_costs,
models: Models {
let models = Models {
bulk: if bulk_available {
config.deepseek.model.clone()
} else {
@@ -684,14 +689,18 @@ async fn run_stages(
"none".into()
},
summaries: summary_model,
},
};
let colophon = Colophon {
provider_costs,
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]
+1
View File
@@ -752,6 +752,7 @@ mod tests {
editorial: Editorial::default(),
world_briefing: None,
colophon: Colophon::default(),
behind: Default::default(),
}
}
+151 -5
View File
@@ -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, 01 (§9.2); 0 means the signal is absent.
#[serde(default)]
pub knn_gate: f64,
/// The feed-affinity gate ramp, 01 (§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]
+49
View File
@@ -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, 01 (§9.2).
pub knn_gate: f64,
/// The feed-affinity gate ramp, 01 (§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 {
+67
View File
@@ -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("&nbsp;"));
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();