Files
the-daily-epub/src/curate/embedding.rs
T
thalladaandClaude Fable 5.1 f71a4c2799 Keep the publication an article's page declares
Readability already reads og:site_name and JSON-LD publisher.name; carry
that through Page and Extracted and store it in articles.publication, the
same way the page byline is kept. `publication_label` is what the readers
will see after the feed name: the site name, else the domain, and nothing
when it would only repeat the feed's own title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
2026-09-09 05:48:46 +00:00

1508 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Voyage embeddings, the f32 BLOB codec, and the SQLite cache (plan §4.3,
//! §7.1–7.2, §16 `features backfill`).
//!
//! The network is reached through an [`EmbeddingBackend`] so tests can inject
//! canned vectors ([`MockBackend`]) without touching the wire, mirroring
//! `ChatBackend` in `llm.rs`. Nothing here is fatal to a run: a failed batch
//! leaves its articles without embeddings and the caller carries on (§17).
//! Raw vectors never reach logs or reports.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use futures::{StreamExt as _, stream};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use sqlx::Row as _;
use crate::config::{Config, VoyageConfig};
use crate::curate::{approx_tokens, profile, prompt_text};
use crate::db::{Db, fmt_ts};
use crate::http::RetryPolicy;
use crate::types::{Article, ArticleId};
/// The only place the Voyage key comes from (§4.3).
pub const VOYAGE_API_KEY_ENV: &str = "DAILY_EPUB_VOYAGE__API_KEY";
/// USD per million tokens, `voyage-4-lite` (§4.3, verified 2026-08-17).
pub const VOYAGE_PRICE_PER_MTOK: f64 = 0.02;
/// `features backfill` asks before spending more than this without `--yes` (§16).
pub const BACKFILL_CONFIRM_TOKENS: i64 = 5_000_000;
const EMBEDDING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
#[error("voyage api key is not configured (set DAILY_EPUB_VOYAGE__API_KEY)")]
MissingApiKey,
#[error("voyage request failed: {0}")]
Api(String),
/// A 5xx/429/network failure: worth retrying.
#[error("voyage request failed (transient): {0}")]
Transient(String),
#[error("voyage response index {index} is invalid for {len} inputs")]
InvalidIndex { index: usize, len: usize },
#[error("voyage response contained duplicate index {0}")]
DuplicateIndex(usize),
#[error("voyage response returned {actual} vectors for {expected} inputs")]
ResponseLength { expected: usize, actual: usize },
#[error("embedding dimension mismatch: expected {expected}, got {actual}")]
Dimension { expected: usize, actual: usize },
#[error("embedding contains a non-finite value")]
NonFinite,
#[error("embedding blob length {actual} does not match dimension {dimension}")]
BlobLength { dimension: usize, actual: usize },
#[error("voyage daily cost ceiling of ${limit:.2} reached")]
BudgetExceeded { limit: f64 },
#[error(transparent)]
Db(#[from] crate::db::DbError),
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
}
impl EmbeddingError {
fn is_transient(&self) -> bool {
matches!(self, Self::Transient(_))
}
}
/// Voyage's `input_type`: documents for articles, queries for interests (§7.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InputType {
Document,
Query,
}
#[derive(Debug, Clone)]
pub struct EmbeddingRequest {
pub input: Vec<String>,
pub model: String,
pub input_type: InputType,
pub output_dimension: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IndexedEmbedding {
pub index: usize,
pub embedding: Vec<f32>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EmbeddingCompletion {
/// Ordered by `index`, one per input.
pub data: Vec<IndexedEmbedding>,
pub total_tokens: i64,
}
/// Network seam matching `ChatBackend`; tests inject canned completions.
pub trait EmbeddingBackend: std::fmt::Debug + Send + Sync {
fn embed<'a>(
&'a self,
request: EmbeddingRequest,
) -> BoxFuture<'a, Result<EmbeddingCompletion, EmbeddingError>>;
}
/// `POST {base_url}/embeddings` with the bearer key from
/// `DAILY_EPUB_VOYAGE__API_KEY` (§4.3). The key is never logged.
#[derive(Debug, Clone)]
pub struct VoyageBackend {
http: reqwest::Client,
endpoint: String,
api_key: String,
}
impl VoyageBackend {
pub fn new(config: &VoyageConfig) -> Result<Self, EmbeddingError> {
// The config field is how figment carries the env var; the direct
// read covers callers that built the config by hand.
let api_key = config
.api_key
.clone()
.or_else(|| std::env::var(VOYAGE_API_KEY_ENV).ok())
.filter(|value| !value.trim().is_empty())
.ok_or(EmbeddingError::MissingApiKey)?;
let http = crate::http::build_client(EMBEDDING_TIMEOUT)
.map_err(|error| EmbeddingError::Api(format!("building HTTP client: {error}")))?;
Ok(Self {
http,
endpoint: format!("{}/embeddings", config.base_url.trim_end_matches('/')),
api_key,
})
}
}
#[derive(Debug, Serialize)]
struct ApiRequest<'a> {
input: &'a [String],
model: &'a str,
input_type: InputType,
truncation: bool,
output_dimension: usize,
output_dtype: &'static str,
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
#[serde(default)]
data: Vec<ApiEmbedding>,
#[serde(default)]
usage: ApiUsage,
}
#[derive(Debug, Deserialize)]
struct ApiEmbedding {
index: usize,
embedding: Vec<f32>,
}
#[derive(Debug, Default, Deserialize)]
struct ApiUsage {
#[serde(default)]
total_tokens: i64,
}
impl EmbeddingBackend for VoyageBackend {
fn embed<'a>(
&'a self,
request: EmbeddingRequest,
) -> BoxFuture<'a, Result<EmbeddingCompletion, EmbeddingError>> {
Box::pin(async move {
let response = self
.http
.post(&self.endpoint)
.bearer_auth(&self.api_key)
.json(&ApiRequest {
input: &request.input,
model: &request.model,
input_type: request.input_type,
truncation: true,
output_dimension: request.output_dimension,
output_dtype: "float",
})
.send()
.await
.map_err(|error| {
if crate::http::is_retryable(&error) {
EmbeddingError::Transient(error.to_string())
} else {
EmbeddingError::Api(error.to_string())
}
})?;
let status = response.status();
if !status.is_success() {
let detail = response.text().await.unwrap_or_default();
let message = format!("{status}: {}", detail.chars().take(500).collect::<String>());
return Err(if status.is_server_error() || status.as_u16() == 429 {
EmbeddingError::Transient(message)
} else {
EmbeddingError::Api(message)
});
}
let parsed: ApiResponse = response
.json()
.await
.map_err(|error| EmbeddingError::Api(format!("decoding response: {error}")))?;
map_response(parsed, request.input.len(), request.output_dimension)
})
}
}
/// Order the response by `index` and reject short, long, duplicate or
/// malformed vectors (§4.3).
fn map_response(
response: ApiResponse,
expected: usize,
dimension: usize,
) -> Result<EmbeddingCompletion, EmbeddingError> {
let actual = response.data.len();
if actual != expected {
return Err(EmbeddingError::ResponseLength { expected, actual });
}
let mut ordered: Vec<Option<IndexedEmbedding>> = vec![None; expected];
for item in response.data {
if item.index >= expected {
return Err(EmbeddingError::InvalidIndex {
index: item.index,
len: expected,
});
}
validate_vector(&item.embedding, dimension)?;
let index = item.index;
if ordered[index]
.replace(IndexedEmbedding {
index,
embedding: item.embedding,
})
.is_some()
{
return Err(EmbeddingError::DuplicateIndex(index));
}
}
Ok(EmbeddingCompletion {
data: ordered.into_iter().flatten().collect(),
total_tokens: response.usage.total_tokens.max(0),
})
}
/// Voyage token meter with the `max_daily_usd` runaway guard (§5).
#[derive(Debug, Clone)]
pub struct UsageMeter {
tokens: Arc<Mutex<i64>>,
max_daily_usd: f64,
}
impl UsageMeter {
pub fn new(max_daily_usd: f64) -> Self {
Self {
tokens: Arc::new(Mutex::new(0)),
max_daily_usd,
}
}
pub fn total_tokens(&self) -> i64 {
match self.tokens.lock() {
Ok(tokens) => *tokens,
Err(poisoned) => *poisoned.into_inner(),
}
}
pub fn cost_usd(&self) -> f64 {
cost_for_tokens(self.total_tokens())
}
fn check(&self) -> Result<(), EmbeddingError> {
if self.max_daily_usd > 0.0 && self.cost_usd() >= self.max_daily_usd {
Err(EmbeddingError::BudgetExceeded {
limit: self.max_daily_usd,
})
} else {
Ok(())
}
}
fn record(&self, tokens: i64) {
match self.tokens.lock() {
Ok(mut total) => *total += tokens.max(0),
Err(poisoned) => *poisoned.into_inner() += tokens.max(0),
}
}
}
pub fn cost_for_tokens(tokens: i64) -> f64 {
tokens as f64 * VOYAGE_PRICE_PER_MTOK / 1_000_000.0
}
/// Batching, bounded concurrency, retries and metering over a backend (§4.3).
#[derive(Debug, Clone)]
pub struct EmbeddingClient {
config: VoyageConfig,
backend: Arc<dyn EmbeddingBackend>,
retry: RetryPolicy,
pub meter: UsageMeter,
}
impl EmbeddingClient {
pub fn new(config: &VoyageConfig) -> Result<Self, EmbeddingError> {
Ok(Self::with_backend(
config.clone(),
Arc::new(VoyageBackend::new(config)?),
))
}
pub fn with_backend(config: VoyageConfig, backend: Arc<dyn EmbeddingBackend>) -> Self {
Self {
meter: UsageMeter::new(config.max_daily_usd),
config,
backend,
retry: RetryPolicy::default(),
}
}
/// Embed every text in `batch_size` chunks, at most `max_concurrent_requests`
/// in flight. A failed batch yields `None` for its texts and is logged.
pub async fn embed_many(
&self,
texts: &[String],
input_type: InputType,
) -> Vec<Option<Vec<f32>>> {
if texts.is_empty() {
return Vec::new();
}
let batch_size = self.config.batch_size.max(1);
let batches = texts
.chunks(batch_size)
.enumerate()
.map(|(batch_index, chunk)| (batch_index * batch_size, chunk.to_vec()));
let client = self.clone();
let mut completed = stream::iter(batches.map(move |(offset, input)| {
let client = client.clone();
async move {
let result = client.embed_batch(input, input_type).await;
(offset, result)
}
}))
.buffer_unordered(self.config.max_concurrent_requests.max(1));
let mut output = vec![None; texts.len()];
while let Some((offset, result)) = completed.next().await {
match result {
Ok(vectors) => {
for (index, vector) in vectors.into_iter().enumerate() {
if let Some(slot) = output.get_mut(offset + index) {
*slot = Some(vector);
}
}
}
Err(error) => {
tracing::warn!(%error, offset, "voyage batch failed; leaving its embeddings absent")
}
}
}
output
}
async fn embed_batch(
&self,
input: Vec<String>,
input_type: InputType,
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
self.meter.check()?;
let request = EmbeddingRequest {
input,
model: self.config.model.clone(),
input_type,
output_dimension: self.config.output_dimension,
};
let completion = self
.retry
.run("voyage embeddings", EmbeddingError::is_transient, || {
self.backend.embed(request.clone())
})
.await?;
self.meter.record(completion.total_tokens);
Ok(completion
.data
.into_iter()
.map(|item| item.embedding)
.collect())
}
}
// ---------------------------------------------------------------------------
// Codec and arithmetic (§7.1)
// ---------------------------------------------------------------------------
/// f32 little-endian, `dimension * 4` bytes; non-finite values are rejected.
pub fn encode_blob(vector: &[f32]) -> Result<Vec<u8>, EmbeddingError> {
if vector.iter().any(|value| !value.is_finite()) {
return Err(EmbeddingError::NonFinite);
}
let mut bytes = Vec::with_capacity(vector.len() * 4);
for value in vector {
bytes.extend_from_slice(&value.to_le_bytes());
}
Ok(bytes)
}
pub fn decode_blob(bytes: &[u8], dimension: usize) -> Result<Vec<f32>, EmbeddingError> {
if bytes.len() != dimension.saturating_mul(4) {
return Err(EmbeddingError::BlobLength {
dimension,
actual: bytes.len(),
});
}
let mut vector = Vec::with_capacity(dimension);
for chunk in bytes.as_chunks::<4>().0 {
let value = f32::from_le_bytes(*chunk);
if !value.is_finite() {
return Err(EmbeddingError::NonFinite);
}
vector.push(value);
}
Ok(vector)
}
/// Dot product (= cosine, Voyage vectors are unit-normalized) with a dimension check.
pub fn dot(left: &[f32], right: &[f32]) -> Result<f64, EmbeddingError> {
if left.len() != right.len() {
return Err(EmbeddingError::Dimension {
expected: left.len(),
actual: right.len(),
});
}
Ok(left
.iter()
.zip(right)
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum())
}
/// The `limit` closest compatible cached article vectors, highest cosine first.
///
/// A brute-force scan: the cache holds at most a few thousand rows after
/// pruning, so one statement plus a sort is cheap. The caller supplies the
/// target row's model, dimension and decoded vector so pages that already
/// load that row do not query it a second time. Malformed candidate blobs are
/// ignored like malformed entries in the normal cache loader.
pub async fn nearest_articles(
db: &Db,
article_id: ArticleId,
model: &str,
dimension: usize,
target: &[f32],
limit: usize,
) -> Result<Vec<(ArticleId, f64)>, EmbeddingError> {
if target.len() != dimension {
return Err(EmbeddingError::Dimension {
expected: dimension,
actual: target.len(),
});
}
if limit == 0 {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT article_id, embedding FROM article_embeddings
WHERE model = ? AND dimension = ? AND article_id != ?",
)
.bind(model)
.bind(dimension as i64)
.bind(article_id)
.fetch_all(db.pool())
.await?;
let mut scored = Vec::with_capacity(rows.len());
for row in rows {
let candidate_id: ArticleId = row.get("article_id");
let candidate = match decode_blob(&row.get::<Vec<u8>, _>("embedding"), dimension) {
Ok(candidate) => candidate,
Err(error) => {
tracing::warn!(article_id = candidate_id, %error, "ignoring a malformed embedding");
continue;
}
};
let cosine = dot(target, &candidate)?;
if cosine.is_finite() {
scored.push((candidate_id, cosine));
}
}
// Highest cosine first; ties by id so the order is stable.
scored.sort_by(|left, right| {
right
.1
.total_cmp(&left.1)
.then_with(|| left.0.cmp(&right.0))
});
scored.truncate(limit);
Ok(scored)
}
fn validate_vector(vector: &[f32], dimension: usize) -> Result<(), EmbeddingError> {
if vector.len() != dimension {
return Err(EmbeddingError::Dimension {
expected: dimension,
actual: vector.len(),
});
}
encode_blob(vector).map(|_| ())
}
// ---------------------------------------------------------------------------
// Embedded text (§7.1)
// ---------------------------------------------------------------------------
/// `"Title: {title}\n\n{plain body}"`, whitespace collapsed, cut at
/// `max_chars` on a char boundary. Deliberately no feed name, author or scores.
pub fn article_input(article: &Article, max_chars: usize) -> String {
let body = prompt_text(&article.content_html);
let title = article
.title
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
truncate_chars(&format!("Title: {title}\n\n{body}"), max_chars)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
match text.char_indices().nth(max_chars) {
Some((byte, _)) => text[..byte].to_string(),
None => text.to_string(),
}
}
/// `sha256` of the embedded text, the cache key alongside model and dimension.
pub fn input_hash(text: &str) -> String {
hex::encode(Sha256::digest(text.as_bytes()))
}
// ---------------------------------------------------------------------------
// Cache orchestration (§7.1, §7.2)
// ---------------------------------------------------------------------------
/// A cache miss waiting for the network.
#[derive(Debug, Clone)]
struct ArticleMiss {
article_id: ArticleId,
text: String,
hash: String,
}
/// The `article_embeddings` / `interest_embeddings` cache in front of a client.
///
/// Without a client (`--skip-embeddings`, Voyage disabled, no key) it answers
/// from the cache only and never touches the network.
#[derive(Debug, Clone)]
pub struct EmbeddingService {
db: Db,
config: VoyageConfig,
client: Option<EmbeddingClient>,
}
impl EmbeddingService {
pub fn cached_only(db: Db, config: VoyageConfig) -> Self {
Self {
db,
config,
client: None,
}
}
pub fn real(db: Db, config: VoyageConfig) -> Result<Self, EmbeddingError> {
let client = EmbeddingClient::new(&config)?;
Ok(Self::with_client(db, config, client))
}
pub fn with_client(db: Db, config: VoyageConfig, client: EmbeddingClient) -> Self {
Self {
db,
config,
client: Some(client),
}
}
pub fn config(&self) -> &VoyageConfig {
&self.config
}
/// `None` when the service is cache-only.
pub fn meter(&self) -> Option<&UsageMeter> {
self.client.as_ref().map(|client| &client.meter)
}
pub fn has_client(&self) -> bool {
self.client.is_some()
}
/// Split the articles into cached vectors and misses (no network).
async fn lookup_articles(
&self,
articles: &[Article],
) -> Result<(HashMap<ArticleId, Vec<f32>>, Vec<ArticleMiss>), EmbeddingError> {
let mut found = HashMap::new();
let mut misses = Vec::new();
for article in articles {
let text = article_input(article, self.config.max_input_chars);
let hash = input_hash(&text);
let row = sqlx::query(
"SELECT embedding FROM article_embeddings
WHERE article_id = ? AND model = ? AND dimension = ? AND input_hash = ?",
)
.bind(article.id)
.bind(&self.config.model)
.bind(self.config.output_dimension as i64)
.bind(&hash)
.fetch_optional(self.db.pool())
.await?;
let cached = row.and_then(|row| {
decode_blob(
&row.get::<Vec<u8>, _>("embedding"),
self.config.output_dimension,
)
.map_err(|error| {
tracing::warn!(article_id = article.id, %error, "ignoring a malformed cached embedding")
})
.ok()
});
match cached {
Some(vector) => {
found.insert(article.id, vector);
}
None => misses.push(ArticleMiss {
article_id: article.id,
text,
hash,
}),
}
}
Ok((found, misses))
}
/// Articles with no usable cached vector, with their estimated token cost.
pub async fn uncached_articles(
&self,
articles: &[Article],
) -> Result<Vec<(ArticleId, i64)>, EmbeddingError> {
let (_, misses) = self.lookup_articles(articles).await?;
Ok(misses
.into_iter()
.map(|miss| (miss.article_id, approx_tokens(&miss.text) as i64))
.collect())
}
/// Cached-or-fetched vectors for every article that has one (§7.1).
pub async fn articles(
&self,
articles: &[Article],
) -> Result<HashMap<ArticleId, Vec<f32>>, EmbeddingError> {
let (mut found, misses) = self.lookup_articles(articles).await?;
let Some(client) = self.client.as_ref() else {
return Ok(found);
};
if misses.is_empty() {
return Ok(found);
}
let texts = misses
.iter()
.map(|miss| miss.text.clone())
.collect::<Vec<_>>();
let vectors = client.embed_many(&texts, InputType::Document).await;
let now = fmt_ts(Timestamp::now());
for (miss, vector) in misses.into_iter().zip(vectors) {
let Some(vector) = vector else { continue };
let blob = encode_blob(&vector)?;
sqlx::query(
"INSERT INTO article_embeddings
(article_id, model, dimension, input_hash, embedding, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(article_id) DO UPDATE SET
model = excluded.model, dimension = excluded.dimension,
input_hash = excluded.input_hash, embedding = excluded.embedding,
created_at = excluded.created_at",
)
.bind(miss.article_id)
.bind(&self.config.model)
.bind(self.config.output_dimension as i64)
.bind(&miss.hash)
.bind(blob)
.bind(&now)
.execute(self.db.pool())
.await?;
found.insert(miss.article_id, vector);
}
Ok(found)
}
async fn lookup_interests(
&self,
interests: &[String],
) -> Result<(HashMap<String, Vec<f32>>, Vec<String>), EmbeddingError> {
let mut found = HashMap::new();
let mut misses = Vec::new();
for interest in interests {
let row = sqlx::query(
"SELECT embedding FROM interest_embeddings
WHERE interest = ? AND model = ? AND dimension = ?",
)
.bind(interest)
.bind(&self.config.model)
.bind(self.config.output_dimension as i64)
.fetch_optional(self.db.pool())
.await?;
let cached = row.and_then(|row| {
decode_blob(
&row.get::<Vec<u8>, _>("embedding"),
self.config.output_dimension,
)
.map_err(|error| {
tracing::warn!(interest, %error, "ignoring a malformed cached interest embedding")
})
.ok()
});
match cached {
Some(vector) => {
found.insert(interest.clone(), vector);
}
None => misses.push(interest.clone()),
}
}
Ok((found, misses))
}
pub async fn uncached_interests(
&self,
interests: &[String],
) -> Result<Vec<String>, EmbeddingError> {
Ok(self.lookup_interests(interests).await?.1)
}
/// Cached-or-fetched query vectors for the bare interest strings (§7.2).
pub async fn interests(
&self,
interests: &[String],
) -> Result<HashMap<String, Vec<f32>>, EmbeddingError> {
let (mut found, misses) = self.lookup_interests(interests).await?;
let Some(client) = self.client.as_ref() else {
return Ok(found);
};
if misses.is_empty() {
return Ok(found);
}
let vectors = client.embed_many(&misses, InputType::Query).await;
let now = fmt_ts(Timestamp::now());
for (interest, vector) in misses.into_iter().zip(vectors) {
let Some(vector) = vector else { continue };
let blob = encode_blob(&vector)?;
sqlx::query(
"INSERT INTO interest_embeddings
(interest, model, dimension, embedding, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(interest) DO UPDATE SET
model = excluded.model, dimension = excluded.dimension,
embedding = excluded.embedding, created_at = excluded.created_at",
)
.bind(&interest)
.bind(&self.config.model)
.bind(self.config.output_dimension as i64)
.bind(blob)
.bind(&now)
.execute(self.db.pool())
.await?;
found.insert(interest, vector);
}
Ok(found)
}
}
/// Cached vectors for the given ids under the configured model and dimension,
/// whatever text they were computed from (the rated set, §9.2).
pub async fn load_article_embeddings(
db: &Db,
config: &VoyageConfig,
article_ids: &[ArticleId],
) -> Result<HashMap<ArticleId, Vec<f32>>, EmbeddingError> {
let mut output = HashMap::new();
for article_id in article_ids {
let row = sqlx::query(
"SELECT embedding FROM article_embeddings
WHERE article_id = ? AND model = ? AND dimension = ?",
)
.bind(article_id)
.bind(&config.model)
.bind(config.output_dimension as i64)
.fetch_optional(db.pool())
.await?;
if let Some(row) = row {
match decode_blob(&row.get::<Vec<u8>, _>("embedding"), config.output_dimension) {
Ok(vector) => {
output.insert(*article_id, vector);
}
Err(error) => tracing::warn!(article_id, %error, "ignoring a malformed embedding"),
}
}
}
Ok(output)
}
// ---------------------------------------------------------------------------
// `features backfill` (§16)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default)]
pub struct BackfillOptions {
/// Window for published and (under `--all`) other articles, in days.
pub days: i64,
/// Only the rated set.
pub rated_only: bool,
/// Also every other article first seen inside the window.
pub all: bool,
}
/// What a backfill would embed: cache misses only, in priority order.
#[derive(Debug, Default)]
pub struct BackfillPlan {
/// Rated and published articles (the learned set), rated first.
pub learned: Vec<Article>,
pub interests: Vec<String>,
/// Other recent articles; only under `--all`.
pub others: Vec<Article>,
pub estimated_tokens: i64,
/// Articles and interests that were already cached and will be skipped.
pub cached: usize,
}
impl BackfillPlan {
pub fn is_empty(&self) -> bool {
self.learned.is_empty() && self.interests.is_empty() && self.others.is_empty()
}
pub fn article_count(&self) -> usize {
self.learned.len() + self.others.len()
}
pub fn estimated_cost_usd(&self) -> f64 {
cost_for_tokens(self.estimated_tokens)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BackfillOutcome {
pub articles_embedded: usize,
pub interests_embedded: usize,
pub tokens: i64,
pub cost_usd: f64,
}
/// Decide what `features backfill` would embed without calling Voyage.
pub async fn plan_backfill(
db: &Db,
config: &Config,
service: &EmbeddingService,
opts: &BackfillOptions,
) -> anyhow::Result<BackfillPlan> {
let since = Timestamp::now()
.checked_sub(jiff::Span::new().hours(opts.days.max(0).saturating_mul(24)))
.unwrap_or(Timestamp::UNIX_EPOCH);
let mut ids = Vec::new();
for rating in db
.current_ratings(config.curation.ranking.rating_lookback_days)
.await?
{
ids.push(rating.article_id);
}
if !opts.rated_only {
ids.extend(db.published_article_ids_since(since).await?);
}
let mut seen = std::collections::HashSet::new();
let mut learned = Vec::new();
for id in ids {
if seen.insert(id)
&& let Some(article) = db.get_article(id).await?
{
learned.push(article);
}
}
let mut others = Vec::new();
if opts.all && !opts.rated_only {
for id in db.article_ids_since(since).await? {
if seen.insert(id)
&& let Some(article) = db.get_article(id).await?
{
others.push(article);
}
}
}
let interests =
match profile::load_standing_interests(&config.interests_opml, &config.profile_path) {
Ok(interests) => interests,
Err(error) => {
tracing::warn!(%error, "could not load standing interests; skipping them");
Vec::new()
}
};
let mut plan = BackfillPlan::default();
let mut keep = |articles: Vec<Article>, misses: Vec<(ArticleId, i64)>| -> Vec<Article> {
let wanted: HashMap<ArticleId, i64> = misses.into_iter().collect();
plan.cached += articles.len() - wanted.len();
plan.estimated_tokens += wanted.values().sum::<i64>();
articles
.into_iter()
.filter(|article| wanted.contains_key(&article.id))
.collect()
};
let learned_misses = service.uncached_articles(&learned).await?;
plan.learned = keep(learned, learned_misses);
let other_misses = service.uncached_articles(&others).await?;
plan.others = keep(others, other_misses);
let interest_misses = service.uncached_interests(&interests).await?;
plan.cached += interests.len() - interest_misses.len();
plan.estimated_tokens += interest_misses
.iter()
.map(|interest| approx_tokens(interest) as i64)
.sum::<i64>();
plan.interests = interest_misses;
Ok(plan)
}
/// Embed the plan in priority order: learned set, interests, then the rest.
pub async fn run_backfill(
service: &EmbeddingService,
plan: &BackfillPlan,
) -> anyhow::Result<BackfillOutcome> {
let mut outcome = BackfillOutcome::default();
outcome.articles_embedded += service.articles(&plan.learned).await?.len();
outcome.interests_embedded += service.interests(&plan.interests).await?.len();
outcome.articles_embedded += service.articles(&plan.others).await?.len();
if let Some(meter) = service.meter() {
outcome.tokens = meter.total_tokens();
outcome.cost_usd = meter.cost_usd();
}
Ok(outcome)
}
// ---------------------------------------------------------------------------
// Test backend
// ---------------------------------------------------------------------------
/// Canned-vector backend for tests: pops scripted replies in order.
#[cfg(test)]
#[derive(Debug, Default)]
pub struct MockBackend {
scripted: Mutex<std::collections::VecDeque<Result<EmbeddingCompletion, String>>>,
/// Every request the code under test sent, in order.
pub seen: Mutex<Vec<EmbeddingRequest>>,
/// When set, every request is answered with this many-dimensional unit
/// vectors derived from the input text (deterministic, no scripting).
auto_dimension: Mutex<Option<usize>>,
}
#[cfg(test)]
impl MockBackend {
pub fn new() -> Self {
Self::default()
}
/// Answer every request with deterministic vectors of this dimension.
pub fn auto(dimension: usize) -> Self {
Self {
auto_dimension: Mutex::new(Some(dimension)),
..Self::default()
}
}
pub fn push(&self, completion: EmbeddingCompletion) {
self.scripted
.lock()
.expect("mock mutex")
.push_back(Ok(completion));
}
pub fn push_error(&self, error: &str) {
self.scripted
.lock()
.expect("mock mutex")
.push_back(Err(error.to_string()));
}
pub fn calls(&self) -> usize {
self.seen.lock().expect("mock mutex").len()
}
pub fn requests(&self) -> Vec<EmbeddingRequest> {
self.seen.lock().expect("mock mutex").clone()
}
/// A unit vector that depends only on the text, for cache tests.
pub fn vector_for(text: &str, dimension: usize) -> Vec<f32> {
let digest = Sha256::digest(text.as_bytes());
let mut vector = (0..dimension)
.map(|i| f32::from(digest[i % digest.len()]) / 255.0 - 0.5)
.collect::<Vec<_>>();
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-6);
vector.iter_mut().for_each(|v| *v /= norm);
vector
}
}
#[cfg(test)]
impl EmbeddingBackend for MockBackend {
fn embed<'a>(
&'a self,
request: EmbeddingRequest,
) -> BoxFuture<'a, Result<EmbeddingCompletion, EmbeddingError>> {
Box::pin(async move {
let auto = *self.auto_dimension.lock().expect("mock mutex");
self.seen.lock().expect("mock mutex").push(request.clone());
if let Some(dimension) = auto {
return Ok(EmbeddingCompletion {
data: request
.input
.iter()
.enumerate()
.map(|(index, text)| IndexedEmbedding {
index,
embedding: Self::vector_for(text, dimension),
})
.collect(),
total_tokens: request
.input
.iter()
.map(|text| approx_tokens(text) as i64)
.sum(),
});
}
match self.scripted.lock().expect("mock mutex").pop_front() {
Some(Ok(completion)) => Ok(completion),
Some(Err(error)) => Err(EmbeddingError::Api(error)),
None => Err(EmbeddingError::Api("mock exhausted".into())),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExtractMethod, SourceKind, SourceRef};
fn vector(index: usize, values: &[f32]) -> IndexedEmbedding {
IndexedEmbedding {
index,
embedding: values.to_vec(),
}
}
fn small_config() -> VoyageConfig {
VoyageConfig {
output_dimension: 4,
batch_size: 2,
max_concurrent_requests: 2,
..VoyageConfig::default()
}
}
fn article(id: ArticleId, title: &str, body: &str) -> Article {
Article {
id,
canonical_url: format!("https://example.com/{id}"),
title: title.into(),
best_entry_id: id,
content_html: body.into(),
word_count: 2,
excerpt_only: false,
image_count: 0,
sources: vec![SourceRef {
entry_id: id,
feed_id: 9,
feed_title: "Secret Feed".into(),
category: None,
kind: SourceKind::Feed,
}],
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
url: format!("https://example.com/{id}"),
author: Some("Secret Author".into()),
publication: None,
feed_id: 9,
feed_title: "Secret Feed".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
}
}
async fn db_with_articles(ids: &[ArticleId]) -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("embed.db"))
.await
.unwrap();
for id in ids {
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, first_seen)
VALUES (?, ?, 'Article', '2026-08-15T00:00:00Z')",
)
.bind(id)
.bind(format!("https://example.com/{id}"))
.execute(db.pool())
.await
.unwrap();
}
(dir, db)
}
fn service(db: Db, config: VoyageConfig, backend: Arc<MockBackend>) -> EmbeddingService {
let client = EmbeddingClient::with_backend(config.clone(), backend);
EmbeddingService::with_client(db, config, client)
}
#[test]
fn blob_round_trip_and_validation() {
let values = vec![0.25, -1.5, 3.0];
assert_eq!(
decode_blob(&encode_blob(&values).unwrap(), 3).unwrap(),
values
);
assert!(matches!(
decode_blob(&[0; 4], 2),
Err(EmbeddingError::BlobLength { .. })
));
assert!(matches!(
encode_blob(&[f32::NAN]),
Err(EmbeddingError::NonFinite)
));
let mut bytes = encode_blob(&[1.0, 2.0]).unwrap();
bytes[4..].copy_from_slice(&f32::INFINITY.to_le_bytes());
assert!(matches!(
decode_blob(&bytes, 2),
Err(EmbeddingError::NonFinite)
));
}
#[test]
fn dot_checks_dimensions() {
assert!((dot(&[1.0, 0.0], &[1.0, 0.0]).unwrap() - 1.0).abs() < 1e-9);
assert!(matches!(
dot(&[1.0], &[1.0, 0.0]),
Err(EmbeddingError::Dimension { .. })
));
}
#[test]
fn response_is_mapped_by_index_and_checked() {
let response = ApiResponse {
data: vec![
ApiEmbedding {
index: 1,
embedding: vec![0.0, 1.0],
},
ApiEmbedding {
index: 0,
embedding: vec![1.0, 0.0],
},
],
usage: ApiUsage { total_tokens: 12 },
};
let mapped = map_response(response, 2, 2).unwrap();
assert_eq!(mapped.data[0].embedding, [1.0, 0.0]);
assert_eq!(mapped.data[1].embedding, [0.0, 1.0]);
assert_eq!(mapped.total_tokens, 12);
let short = ApiResponse {
data: vec![ApiEmbedding {
index: 0,
embedding: vec![1.0],
}],
usage: ApiUsage::default(),
};
assert!(matches!(
map_response(short, 2, 1),
Err(EmbeddingError::ResponseLength { .. })
));
let wrong_dimension = ApiResponse {
data: vec![ApiEmbedding {
index: 0,
embedding: vec![1.0, 2.0, 3.0],
}],
usage: ApiUsage::default(),
};
assert!(matches!(
map_response(wrong_dimension, 1, 2),
Err(EmbeddingError::Dimension { .. })
));
let duplicate = ApiResponse {
data: vec![
ApiEmbedding {
index: 0,
embedding: vec![1.0],
},
ApiEmbedding {
index: 0,
embedding: vec![1.0],
},
],
usage: ApiUsage::default(),
};
assert!(matches!(
map_response(duplicate, 2, 1),
Err(EmbeddingError::DuplicateIndex(0))
));
}
#[tokio::test]
async fn one_failed_batch_does_not_abort_another() {
let config = VoyageConfig {
batch_size: 1,
max_concurrent_requests: 1,
output_dimension: 2,
..VoyageConfig::default()
};
let backend = Arc::new(MockBackend::new());
backend.push_error("failed");
backend.push(EmbeddingCompletion {
data: vec![vector(0, &[1.0, 0.0])],
total_tokens: 3,
});
let client = EmbeddingClient::with_backend(config, backend.clone());
let result = client
.embed_many(&["a".into(), "b".into()], InputType::Document)
.await;
assert!(result[0].is_none());
assert_eq!(result[1].as_deref(), Some([1.0, 0.0].as_slice()));
assert_eq!(backend.calls(), 2);
assert_eq!(client.meter.total_tokens(), 3);
}
#[tokio::test]
async fn the_budget_guard_stops_further_batches() {
let config = VoyageConfig {
batch_size: 1,
max_concurrent_requests: 1,
output_dimension: 1,
max_daily_usd: 0.000_000_02, // one token
..VoyageConfig::default()
};
let backend = Arc::new(MockBackend::new());
backend.push(EmbeddingCompletion {
data: vec![vector(0, &[1.0])],
total_tokens: 1,
});
backend.push(EmbeddingCompletion {
data: vec![vector(0, &[1.0])],
total_tokens: 1,
});
let client = EmbeddingClient::with_backend(config, backend.clone());
let result = client
.embed_many(&["a".into(), "b".into()], InputType::Document)
.await;
assert!(result[0].is_some());
assert!(result[1].is_none());
assert_eq!(backend.calls(), 1);
}
#[test]
fn article_text_excludes_feed_author_and_collapses_markup() {
let text = article_input(&article(1, "A title", "<p>Hello world</p>"), 60_000);
assert_eq!(text, "Title: A title\n\nHello world");
assert!(!text.contains("Secret Feed") && !text.contains("Secret Author"));
// Cut on a char boundary.
let cut = article_input(&article(1, "T", "héllo wörld"), 12);
assert_eq!(cut.chars().count(), 12);
assert!(cut.starts_with("Title: T\n\nh"));
}
#[tokio::test]
async fn cache_hits_on_same_hash_and_misses_on_changed_text_model_or_dimension() {
let (_dir, db) = db_with_articles(&[1]).await;
let backend = Arc::new(MockBackend::auto(4));
let config = small_config();
let svc = service(db.clone(), config.clone(), backend.clone());
let a = article(1, "Title", "<p>body</p>");
let first = svc.articles(std::slice::from_ref(&a)).await.unwrap();
assert_eq!(backend.calls(), 1);
assert_eq!(first[&1].len(), 4);
let request = &backend.requests()[0];
assert_eq!(request.input_type, InputType::Document);
assert_eq!(request.input[0], "Title: Title\n\nbody");
assert_eq!(request.output_dimension, 4);
// Same text → cache hit, no call.
let again = svc.articles(std::slice::from_ref(&a)).await.unwrap();
assert_eq!(backend.calls(), 1);
assert_eq!(again[&1], first[&1]);
assert!(
svc.uncached_articles(std::slice::from_ref(&a))
.await
.unwrap()
.is_empty()
);
// Changed text → new hash → miss, row overwritten.
let edited = article(1, "Title", "<p>new body</p>");
let after_edit = svc.articles(std::slice::from_ref(&edited)).await.unwrap();
assert_eq!(backend.calls(), 2);
assert_ne!(after_edit[&1], first[&1]);
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_embeddings")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(rows, 1, "one row per article, overwritten");
// Changed model → miss.
let other_model = EmbeddingService::with_client(
db.clone(),
VoyageConfig {
model: "voyage-other".into(),
..config.clone()
},
EmbeddingClient::with_backend(
VoyageConfig {
model: "voyage-other".into(),
..config.clone()
},
backend.clone(),
),
);
other_model
.articles(std::slice::from_ref(&edited))
.await
.unwrap();
assert_eq!(backend.calls(), 3);
// Changed dimension → miss (the mock answers in the requested dimension).
let backend8 = Arc::new(MockBackend::auto(8));
let dim8 = VoyageConfig {
output_dimension: 8,
..config.clone()
};
let other_dimension = service(db.clone(), dim8, backend8.clone());
let vectors = other_dimension
.articles(std::slice::from_ref(&edited))
.await
.unwrap();
assert_eq!(backend8.calls(), 1);
assert_eq!(vectors[&1].len(), 8);
// Cache-only: no client, so a miss stays a miss and nothing is called.
let cache_only = EmbeddingService::cached_only(db.clone(), small_config());
let fresh = article(1, "Title", "<p>yet another body</p>");
assert!(
cache_only
.articles(std::slice::from_ref(&fresh))
.await
.unwrap()
.is_empty()
);
assert!(cache_only.meter().is_none());
}
#[tokio::test]
async fn interests_are_embedded_as_bare_queries_and_cached() {
let (_dir, db) = db_with_articles(&[]).await;
let backend = Arc::new(MockBackend::auto(4));
let svc = service(db.clone(), small_config(), backend.clone());
let interests = vec!["Writerdeck".to_string(), "Gaussian Splatting".to_string()];
let first = svc.interests(&interests).await.unwrap();
assert_eq!(first.len(), 2);
assert_eq!(backend.calls(), 1);
let request = &backend.requests()[0];
assert_eq!(request.input_type, InputType::Query);
assert_eq!(request.input, interests);
svc.interests(&interests).await.unwrap();
assert_eq!(backend.calls(), 1, "warm cache makes no call");
assert_eq!(
load_article_embeddings(&db, &small_config(), &[1])
.await
.unwrap()
.len(),
0
);
}
#[tokio::test]
async fn backfill_prioritizes_the_learned_set_and_is_idempotent() {
let (dir, db) = db_with_articles(&[1, 2, 3]).await;
// Article 1 is rated, article 2 is published, article 3 is neither.
sqlx::query(
"INSERT INTO rating_events (article_id, kind, source, label, value, event_at)
VALUES (1, 'explicit', 'cli', 'loved', 1.0, ?)",
)
.bind(fmt_ts(Timestamp::now()))
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO issues (date, issue_number, generated_at) VALUES ('2026-08-15', 1, '2026-08-15T12:00:00Z');
INSERT INTO issue_articles (issue_date, article_id, section) VALUES ('2026-08-15', 2, 'Top Stories');",
)
.execute(db.pool())
.await
.unwrap();
// Article rows carry no body in this fixture; refresh them with one so
// that `get_article` yields embeddable text.
sqlx::query("UPDATE articles SET content_html = '<p>some body text</p>', first_seen = ?")
.bind(fmt_ts(Timestamp::now()))
.execute(db.pool())
.await
.unwrap();
let config = Config {
voyage: small_config(),
interests_opml: dir.path().join("interests.opml"),
profile_path: dir.path().join("profile.md"),
..Config::default()
};
std::fs::write(
&config.interests_opml,
"<opml><body><outline text=\"Writerdeck\"/></body></opml>",
)
.unwrap();
std::fs::write(&config.profile_path, "# Reader profile\n").unwrap();
let backend = Arc::new(MockBackend::auto(4));
let svc = service(db.clone(), config.voyage.clone(), backend.clone());
let opts = BackfillOptions {
days: 30,
rated_only: false,
all: false,
};
let plan = plan_backfill(&db, &config, &svc, &opts).await.unwrap();
assert_eq!(
plan.learned.iter().map(|a| a.id).collect::<Vec<_>>(),
vec![1, 2],
"rated first, then published; article 3 needs --all"
);
assert_eq!(plan.interests, vec!["Writerdeck".to_string()]);
assert!(plan.others.is_empty());
assert!(plan.estimated_tokens > 0);
let outcome = run_backfill(&svc, &plan).await.unwrap();
assert_eq!(outcome.articles_embedded, 2);
assert_eq!(outcome.interests_embedded, 1);
let calls = backend.calls();
assert!(calls >= 2);
// Warm cache ⇒ empty plan and zero calls.
let plan = plan_backfill(&db, &config, &svc, &opts).await.unwrap();
assert!(plan.is_empty());
assert_eq!(plan.cached, 3);
run_backfill(&svc, &plan).await.unwrap();
assert_eq!(backend.calls(), calls);
// --all picks up the third article; --rated-only limits to the rated set.
let all = plan_backfill(
&db,
&config,
&svc,
&BackfillOptions {
all: true,
..opts.clone()
},
)
.await
.unwrap();
assert_eq!(all.others.iter().map(|a| a.id).collect::<Vec<_>>(), vec![3]);
let (_dir2, fresh_db) = db_with_articles(&[1, 2]).await;
sqlx::query(
"INSERT INTO rating_events (article_id, kind, source, label, value, event_at)
VALUES (1, 'explicit', 'cli', 'loved', 1.0, ?);",
)
.bind(fmt_ts(Timestamp::now()))
.execute(fresh_db.pool())
.await
.unwrap();
let fresh_svc = service(
fresh_db.clone(),
config.voyage.clone(),
Arc::new(MockBackend::auto(4)),
);
let rated_only = plan_backfill(
&fresh_db,
&config,
&fresh_svc,
&BackfillOptions {
rated_only: true,
all: true,
days: 30,
},
)
.await
.unwrap();
assert_eq!(
rated_only.learned.iter().map(|a| a.id).collect::<Vec<_>>(),
vec![1]
);
assert!(rated_only.others.is_empty());
}
}