Add the interests table and module (first-class interests, step 1)

Standing interests get a table of their own plus article_interests, the
per-run top-3 matches, and a pure rates() that derives each interest's
Beta-smoothed weight from current ratings the way feed affinity does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
This commit is contained in:
2026-09-13 05:00:56 +00:00
co-authored by Claude Fable 5.1
parent e82f7b0d13
commit f0c0927ab8
4 changed files with 559 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE interests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
category TEXT, -- NULL until categorized
created_at TEXT NOT NULL,
categorized_at TEXT
);
CREATE INDEX idx_interests_category ON interests(category);
CREATE TABLE article_interests (
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
interest_id INTEGER NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
cos REAL NOT NULL,
z REAL NOT NULL,
run_id INTEGER, -- NULL for backfilled rows
PRIMARY KEY (article_id, interest_id)
);
CREATE INDEX idx_article_interests_interest ON article_interests(interest_id, cos DESC);
+6
View File
@@ -1689,6 +1689,10 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::raw_sql(include_str!("../migrations/0013_interests.sql"))
.execute(&pool)
.await
.unwrap();
let rows = sqlx::query( let rows = sqlx::query(
"SELECT article_id, issue_date, kind, source, label, value, event_at, user_id "SELECT article_id, issue_date, kind, source, label, value, event_at, user_id
@@ -1724,6 +1728,8 @@ mod tests {
"interest_embeddings", "interest_embeddings",
"article_assessments", "article_assessments",
"candidate_runs", "candidate_runs",
"interests",
"article_interests",
"users", "users",
"sessions", "sessions",
"config_changes", "config_changes",
+534
View File
@@ -0,0 +1,534 @@
//! Standing-interest storage and rating-derived weights.
//!
//! Interest queries stay here so the central database layer remains focused on
//! the pipeline's shared records.
use std::collections::HashMap;
use anyhow::{Result, bail};
use jiff::Timestamp;
use sqlx::Row as _;
use crate::curate::signals::TopInterest;
use crate::db::{Db, fmt_ts};
use crate::types::ArticleId;
/// One standing interest and its optional prompt category.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Interest {
pub id: i64,
pub name: String,
pub category: Option<String>,
pub created_at: String,
pub categorized_at: Option<String>,
}
/// Result of adding a name whose uniqueness is case-insensitive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddOutcome {
Added(i64),
Duplicate,
}
/// One stored article-to-interest match.
#[derive(Debug, Clone, PartialEq)]
pub struct MatchRow {
pub article_id: ArticleId,
pub interest_id: i64,
pub name: String,
pub cos: f64,
pub z: f64,
}
/// Rating credit accumulated for one interest.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Rate {
pub up: f64,
pub down: f64,
pub n: usize,
}
impl Rate {
/// Beta smoothing keeps an unrated interest neutral.
pub fn weight(&self) -> f64 {
(self.up + 1.0) / (self.up + self.down + 2.0)
}
}
/// Interest rates plus the number of ratings that could affect them.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rates {
pub by_interest: HashMap<i64, Rate>,
pub attributable: usize,
}
const INTEREST_COLUMNS: &str = "id, name, category, created_at, categorized_at";
fn interest_from(row: &sqlx::sqlite::SqliteRow) -> Interest {
Interest {
id: row.get("id"),
name: row.get("name"),
category: row.get("category"),
created_at: row.get("created_at"),
categorized_at: row.get("categorized_at"),
}
}
/// All interests, ordered case-insensitively by name.
pub async fn list(db: &Db) -> Result<Vec<Interest>> {
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {INTEREST_COLUMNS} FROM interests ORDER BY name COLLATE NOCASE, name"
)))
.fetch_all(db.pool())
.await?;
Ok(rows.iter().map(interest_from).collect())
}
/// Add one trimmed, non-empty name of at most 80 characters.
pub async fn add(
db: &Db,
name: &str,
category: Option<&str>,
now: Timestamp,
) -> Result<AddOutcome> {
let name = name.trim();
let len = name.chars().count();
if !(1..=80).contains(&len) {
bail!("interest name must be 1–80 characters");
}
let result = sqlx::query(
"INSERT OR IGNORE INTO interests (name, category, created_at, categorized_at)
VALUES (?, ?, ?, ?)",
)
.bind(name)
.bind(category)
.bind(fmt_ts(now))
.bind(category.map(|_| fmt_ts(now)))
.execute(db.pool())
.await?;
if result.rows_affected() == 0 {
Ok(AddOutcome::Duplicate)
} else {
Ok(AddOutcome::Added(result.last_insert_rowid()))
}
}
/// Set or clear an interest category and its categorization timestamp together.
pub async fn set_category(db: &Db, id: i64, category: Option<&str>, now: Timestamp) -> Result<()> {
sqlx::query("UPDATE interests SET category = ?, categorized_at = ? WHERE id = ?")
.bind(category)
.bind(category.map(|_| fmt_ts(now)))
.bind(id)
.execute(db.pool())
.await?;
Ok(())
}
/// Delete an interest, its match rows, and its name-keyed cached embedding.
pub async fn delete(db: &Db, id: i64) -> Result<()> {
let mut tx = db.pool().begin().await?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM interests WHERE id = ?")
.bind(id)
.fetch_optional(&mut *tx)
.await?;
sqlx::query("DELETE FROM interests WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
if let Some(name) = name {
sqlx::query("DELETE FROM interest_embeddings WHERE interest = ?")
.bind(name)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// All names in the stable order used for embedding requests.
pub async fn names(db: &Db) -> Result<Vec<String>> {
Ok(
sqlx::query_scalar("SELECT name FROM interests ORDER BY name COLLATE NOCASE, name")
.fetch_all(db.pool())
.await?,
)
}
/// Names grouped for the prompt, with uncategorized interests last.
pub async fn grouped(db: &Db) -> Result<Vec<(String, Vec<String>)>> {
let mut by_category: HashMap<String, Vec<String>> = HashMap::new();
let mut other = Vec::new();
for interest in list(db).await? {
if let Some(category) = interest.category {
by_category.entry(category).or_default().push(interest.name);
} else {
other.push(interest.name);
}
}
let mut groups: Vec<_> = by_category.into_iter().collect();
groups.sort_by(|left, right| {
left.0
.to_lowercase()
.cmp(&right.0.to_lowercase())
.then_with(|| left.0.cmp(&right.0))
});
for (_, members) in &mut groups {
members.sort_by(|left, right| {
left.to_lowercase()
.cmp(&right.to_lowercase())
.then_with(|| left.cmp(right))
});
}
if !other.is_empty() {
groups.push(("Other standing interests".to_string(), other));
}
Ok(groups)
}
/// Interests awaiting the categorizer, ordered case-insensitively by name.
pub async fn uncategorized(db: &Db) -> Result<Vec<Interest>> {
let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT {INTEREST_COLUMNS} FROM interests WHERE category IS NULL
ORDER BY name COLLATE NOCASE, name"
)))
.fetch_all(db.pool())
.await?;
Ok(rows.iter().map(interest_from).collect())
}
/// Upsert the current run's recorded top-interest matches in one transaction.
pub async fn replace_matches(
db: &Db,
run_id: Option<i64>,
matches: &[(ArticleId, Vec<TopInterest>)],
ids: &HashMap<String, i64>,
) -> Result<()> {
let mut tx = db.pool().begin().await?;
for (article_id, top_interests) in matches {
for top in top_interests {
let Some(interest_id) = ids.get(&top.name) else {
continue;
};
sqlx::query(
"INSERT INTO article_interests (article_id, interest_id, cos, z, run_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(article_id, interest_id) DO UPDATE SET
cos = excluded.cos, z = excluded.z, run_id = excluded.run_id",
)
.bind(article_id)
.bind(interest_id)
.bind(top.cos)
.bind(top.z)
.bind(run_id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
/// Stored matches for the requested articles.
pub async fn matches_for_articles(db: &Db, article_ids: &[ArticleId]) -> Result<Vec<MatchRow>> {
let mut matches = Vec::new();
for chunk in article_ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(", ");
let mut query = sqlx::query(sqlx::AssertSqlSafe(format!(
"SELECT ai.article_id, ai.interest_id, i.name, ai.cos, ai.z
FROM article_interests ai
JOIN interests i ON i.id = ai.interest_id
WHERE ai.article_id IN ({placeholders})"
)));
for article_id in chunk {
query = query.bind(article_id);
}
for row in query.fetch_all(db.pool()).await? {
matches.push(MatchRow {
article_id: row.get("article_id"),
interest_id: row.get("interest_id"),
name: row.get("name"),
cos: row.get("cos"),
z: row.get("z"),
});
}
}
matches.sort_by(|left, right| {
left.article_id
.cmp(&right.article_id)
.then_with(|| right.z.total_cmp(&left.z))
.then_with(|| left.interest_id.cmp(&right.interest_id))
});
Ok(matches)
}
/// Number of stored article matches for each interest.
pub async fn match_counts(db: &Db) -> Result<HashMap<i64, i64>> {
let rows = sqlx::query(
"SELECT interest_id, COUNT(*) AS matches FROM article_interests GROUP BY interest_id",
)
.fetch_all(db.pool())
.await?;
Ok(rows
.iter()
.map(|row| (row.get("interest_id"), row.get("matches")))
.collect())
}
/// Derive smoothed interest rates from current ratings and their match rows.
pub fn rates(ratings: &[(ArticleId, f64, f64)], rows: &[(ArticleId, i64, f64)]) -> Rates {
let mut rows_by_article: HashMap<ArticleId, Vec<(i64, f64)>> = HashMap::new();
for &(article_id, interest_id, z) in rows {
rows_by_article
.entry(article_id)
.or_default()
.push((interest_id, z));
}
let mut result = Rates::default();
for &(article_id, value, decay) in ratings {
let mut attributed = false;
if let Some(matches) = rows_by_article.get(&article_id) {
for &(interest_id, z) in matches {
let strength = (z / 3.0).clamp(0.0, 1.0);
if strength <= 0.0 {
continue;
}
attributed = true;
let credit = value * decay * strength;
let rate = result.by_interest.entry(interest_id).or_default();
rate.up += credit.max(0.0);
rate.down += (-credit).max(0.0);
rate.n += 1;
}
}
if attributed {
result.attributable += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(value: &str) -> Timestamp {
value.parse().unwrap()
}
async fn test_db() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
.await
.unwrap();
(dir, db)
}
async fn seed_article(db: &Db, id: ArticleId) {
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, first_seen) VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(format!("https://example.com/{id}"))
.bind(format!("Article {id}"))
.bind("2026-09-12T00:00:00Z")
.execute(db.pool())
.await
.unwrap();
}
#[tokio::test]
async fn add_trims_names_and_uniqueness_is_case_insensitive() {
let (_dir, db) = test_db().await;
let now = ts("2026-09-12T12:00:00Z");
let AddOutcome::Added(id) = add(&db, " Rust ", Some("Software"), now).await.unwrap()
else {
panic!("first insert should succeed");
};
assert_eq!(
add(&db, "rust", None, now).await.unwrap(),
AddOutcome::Duplicate
);
assert!(add(&db, " ", None, now).await.is_err());
assert!(add(&db, &"x".repeat(81), None, now).await.is_err());
let interests = list(&db).await.unwrap();
assert_eq!(interests.len(), 1);
assert_eq!(interests[0].id, id);
assert_eq!(interests[0].name, "Rust");
assert_eq!(interests[0].category.as_deref(), Some("Software"));
assert_eq!(
interests[0].categorized_at.as_deref(),
Some("2026-09-12T12:00:00Z")
);
}
#[test]
fn rates_apply_value_decay_strength_and_negative_credit() {
let ratings = [
(1, 1.0, 1.0),
(2, 0.35, 1.0),
(3, 1.0, 0.5),
(4, -1.0, 0.5),
(5, -1.0, 1.0),
];
let rows = [
(1, 10, 3.0),
(2, 10, 1.5),
(3, 11, 3.0),
(4, 10, 0.9),
(5, 12, 0.0),
];
let rates = rates(&ratings, &rows);
assert_eq!(rates.attributable, 4);
let ten = rates.by_interest[&10];
assert!((ten.up - 1.175).abs() < 1e-12);
assert!((ten.down - 0.15).abs() < 1e-12);
assert_eq!(ten.n, 3);
assert!((ten.weight() - 2.175 / 3.325).abs() < 1e-12);
assert_eq!(
rates.by_interest[&11],
Rate {
up: 0.5,
down: 0.0,
n: 1
}
);
assert!(!rates.by_interest.contains_key(&12));
assert_eq!(Rate::default().weight(), 0.5);
}
#[tokio::test]
async fn delete_cascades_matches_and_removes_the_embedding() {
let (_dir, db) = test_db().await;
seed_article(&db, 1).await;
let now = ts("2026-09-12T12:00:00Z");
let AddOutcome::Added(id) = add(&db, "Rust", None, now).await.unwrap() else {
unreachable!();
};
sqlx::query(
"INSERT INTO interest_embeddings
(interest, model, dimension, embedding, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind("Rust")
.bind("test")
.bind(1_i64)
.bind(vec![0_u8; 4])
.bind("2026-09-12T12:00:00Z")
.execute(db.pool())
.await
.unwrap();
let ids = HashMap::from([("Rust".to_string(), id)]);
replace_matches(
&db,
Some(7),
&[(
1,
vec![
TopInterest {
name: "Rust".into(),
cos: 0.7,
z: 1.2,
},
TopInterest {
name: "Unknown".into(),
cos: 0.9,
z: 2.0,
},
],
)],
&ids,
)
.await
.unwrap();
replace_matches(
&db,
None,
&[(
1,
vec![TopInterest {
name: "Rust".into(),
cos: 0.8,
z: 1.5,
}],
)],
&ids,
)
.await
.unwrap();
assert_eq!(match_counts(&db).await.unwrap(), HashMap::from([(id, 1)]));
assert_eq!(
matches_for_articles(&db, &[1]).await.unwrap(),
[MatchRow {
article_id: 1,
interest_id: id,
name: "Rust".into(),
cos: 0.8,
z: 1.5
}]
);
let run_id: Option<i64> = sqlx::query_scalar(
"SELECT run_id FROM article_interests WHERE article_id = 1 AND interest_id = ?",
)
.bind(id)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(run_id, None);
delete(&db, id).await.unwrap();
let match_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_interests")
.fetch_one(db.pool())
.await
.unwrap();
let embedding_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM interest_embeddings")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(match_rows, 0);
assert_eq!(embedding_rows, 0);
}
#[tokio::test]
async fn grouped_sorts_categories_and_puts_uncategorized_last() {
let (_dir, db) = test_db().await;
let now = ts("2026-09-12T12:00:00Z");
add(&db, "zebra", Some("Animals"), now).await.unwrap();
add(&db, "Alpaca", Some("Animals"), now).await.unwrap();
let AddOutcome::Added(id) = add(&db, "rust", None, now).await.unwrap() else {
unreachable!();
};
add(&db, "Baking", Some("cooking"), now).await.unwrap();
assert_eq!(
grouped(&db).await.unwrap(),
[
("Animals".into(), vec!["Alpaca".into(), "zebra".into()]),
("cooking".into(), vec!["Baking".into()]),
("Other standing interests".into(), vec!["rust".into()]),
]
);
assert_eq!(uncategorized(&db).await.unwrap()[0].id, id);
set_category(&db, id, Some("Software"), now).await.unwrap();
assert!(uncategorized(&db).await.unwrap().is_empty());
set_category(&db, id, None, now).await.unwrap();
let rust = uncategorized(&db).await.unwrap().pop().unwrap();
assert_eq!(rust.categorized_at, None);
assert_eq!(
names(&db).await.unwrap(),
["Alpaca", "Baking", "rust", "zebra"]
);
}
#[tokio::test]
async fn empty_article_lookup_is_a_no_op() {
let (_dir, db) = test_db().await;
assert!(matches_for_articles(&db, &[]).await.unwrap().is_empty());
}
}
+1
View File
@@ -26,6 +26,7 @@ pub mod html;
pub mod http; pub mod http;
pub mod images; pub mod images;
pub mod imports; pub mod imports;
pub mod interests;
pub mod jobs; pub mod jobs;
pub mod lock; pub mod lock;
pub mod mail; pub mod mail;