Compare commits
6 commits
2a41544546
...
6fc33f4854
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc33f4854 | ||
|
|
279125abb5 | ||
| 4f70bfc960 | |||
|
|
3cf7b3790d | ||
|
|
341ff9517a | ||
|
|
dcbba2cd2a |
12 changed files with 225 additions and 94 deletions
|
|
@ -7,30 +7,80 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: docker
|
runs-on: rust-ci
|
||||||
container: docker.io/library/rust:1-bookworm
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache cargo registry and target dir
|
- name: Cache cargo registry and build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
target
|
target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache sccache compilation objects
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /root/.cache/sccache
|
||||||
|
# Not keyed to Cargo.lock: sccache caches individual compiler
|
||||||
|
# invocations by content hash, so it should accumulate across
|
||||||
|
# dependency bumps rather than reset like the target/ cache above.
|
||||||
|
key: sccache-${{ runner.os }}-${{ github.run_id }}
|
||||||
|
restore-keys: |
|
||||||
|
sccache-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Add wasm target
|
- name: Add wasm target
|
||||||
run: rustup target add wasm32-unknown-unknown
|
run: rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Check native crates
|
- name: Format check
|
||||||
run: cargo check --workspace --exclude feedsignal-web
|
run: cargo fmt --check
|
||||||
|
|
||||||
- name: Check web crate (server)
|
- name: Clippy (native crates)
|
||||||
run: cargo check -p feedsignal-web --no-default-features --features server
|
run: cargo clippy --workspace --exclude feedsignal-web --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Check web crate (wasm client)
|
- name: Clippy (web crate, server)
|
||||||
run: cargo check -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown
|
run: cargo clippy -p feedsignal-web --no-default-features --features server --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Clippy (web crate, wasm client)
|
||||||
|
run: cargo clippy -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown -- -D warnings
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: check
|
||||||
|
runs-on: rust-ci
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache cargo registry and build artifacts
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache sccache compilation objects
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /root/.cache/sccache
|
||||||
|
key: sccache-${{ runner.os }}-${{ github.run_id }}
|
||||||
|
restore-keys: |
|
||||||
|
sccache-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace --exclude feedsignal-web
|
run: cargo test --workspace --exclude feedsignal-web
|
||||||
|
|
||||||
|
audit:
|
||||||
|
needs: test
|
||||||
|
runs-on: rust-ci
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: cargo audit
|
||||||
|
run: cargo audit
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.idea/**
|
||||||
|
.claude/worktrees/**
|
||||||
|
|
@ -11,7 +11,7 @@ members = [
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
license = "MIT"
|
license = "AGPL-3"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,10 @@ mod tests {
|
||||||
|
|
||||||
// --- apply_feedback: new = clamp(current + LEARNING_RATE(0.15) * surprise, -1, 1) ---
|
// --- apply_feedback: new = clamp(current + LEARNING_RATE(0.15) * surprise, -1, 1) ---
|
||||||
|
|
||||||
|
/// Positive surprise (engaged more than predicted) should move the
|
||||||
|
/// score up, never down or unchanged.
|
||||||
#[test]
|
#[test]
|
||||||
fn under_predicted_relevance_boosts_topic() {
|
fn under_predicted_relevance_boosts_topic() {
|
||||||
// Positive surprise (engaged more than predicted) should move the
|
|
||||||
// score up, never down or unchanged.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["rust".to_string()];
|
let topics = vec!["rust".to_string()];
|
||||||
// Model predicted 0.2 relevance, user fully read it: surprise = 0.8.
|
// Model predicted 0.2 relevance, user fully read it: surprise = 0.8.
|
||||||
|
|
@ -128,10 +128,10 @@ mod tests {
|
||||||
assert!(aff.score("rust") > 0.0);
|
assert!(aff.score("rust") > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negative surprise (engaged less than predicted) should move the
|
||||||
|
/// score down, the mirror image of the boost case above.
|
||||||
#[test]
|
#[test]
|
||||||
fn over_predicted_relevance_lowers_topic() {
|
fn over_predicted_relevance_lowers_topic() {
|
||||||
// Negative surprise (engaged less than predicted) should move the
|
|
||||||
// score down, the mirror image of the boost case above.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["crypto".to_string()];
|
let topics = vec!["crypto".to_string()];
|
||||||
// Model predicted 0.9, user dismissed unread: engagement 0, surprise = -0.9.
|
// Model predicted 0.9, user dismissed unread: engagement 0, surprise = -0.9.
|
||||||
|
|
@ -139,11 +139,11 @@ mod tests {
|
||||||
assert!(aff.score("crypto") < 0.0);
|
assert!(aff.score("crypto") < 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scores are documented to live in [-1.0, 1.0]. Repeated max-surprise
|
||||||
|
/// feedback would overshoot 1.0 without the clamp, so this guards the
|
||||||
|
/// invariant directly rather than trusting a single update.
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_feedback_clamps_at_positive_one() {
|
fn apply_feedback_clamps_at_positive_one() {
|
||||||
// Scores are documented to live in [-1.0, 1.0]. Repeated max-surprise
|
|
||||||
// feedback would overshoot 1.0 without the clamp, so this guards the
|
|
||||||
// invariant directly rather than trusting a single update.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["rust".to_string()];
|
let topics = vec!["rust".to_string()];
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
|
|
@ -152,9 +152,9 @@ mod tests {
|
||||||
assert_eq!(aff.score("rust"), 1.0);
|
assert_eq!(aff.score("rust"), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies a decay from a large negative value doesn't overshoot and go beyond -1.0
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_feedback_clamps_at_negative_one() {
|
fn apply_feedback_clamps_at_negative_one() {
|
||||||
// Same invariant as above, checked on the negative side.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["crypto".to_string()];
|
let topics = vec!["crypto".to_string()];
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
|
|
@ -163,11 +163,11 @@ mod tests {
|
||||||
assert_eq!(aff.score("crypto"), -1.0);
|
assert_eq!(aff.score("crypto"), -1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// apply_feedback loops over every topic on the article and applies
|
||||||
|
/// the same surprise to each independently; it must not skip topics
|
||||||
|
/// or bleed the update into topics the article wasn't tagged with.
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_feedback_updates_every_topic_on_the_article() {
|
fn apply_feedback_updates_every_topic_on_the_article() {
|
||||||
// apply_feedback loops over every topic on the article and applies
|
|
||||||
// the same surprise to each independently; it must not skip topics
|
|
||||||
// or bleed the update into topics the article wasn't tagged with.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["rust".to_string(), "async".to_string()];
|
let topics = vec!["rust".to_string(), "async".to_string()];
|
||||||
aff.apply_feedback(&topics, 0.4);
|
aff.apply_feedback(&topics, 0.4);
|
||||||
|
|
@ -177,10 +177,10 @@ mod tests {
|
||||||
assert_eq!(aff.score("crypto"), 0.0);
|
assert_eq!(aff.score("crypto"), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// surprise = 0.0 means engagement exactly matched the prediction, so
|
||||||
|
/// the score shouldn't move at all (current + 0.15 * 0.0 == current).
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_feedback_zero_surprise_is_a_noop() {
|
fn apply_feedback_zero_surprise_is_a_noop() {
|
||||||
// surprise = 0.0 means engagement exactly matched the prediction, so
|
|
||||||
// the score shouldn't move at all (current + 0.15 * 0.0 == current).
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
let topics = vec!["rust".to_string()];
|
let topics = vec!["rust".to_string()];
|
||||||
aff.apply_feedback(&topics, 0.5);
|
aff.apply_feedback(&topics, 0.5);
|
||||||
|
|
@ -191,10 +191,10 @@ mod tests {
|
||||||
|
|
||||||
// --- decay: v -= sign(v) * DAILY_DECAY(0.02), settling at 0 instead of overshooting ---
|
// --- decay: v -= sign(v) * DAILY_DECAY(0.02), settling at 0 instead of overshooting ---
|
||||||
|
|
||||||
|
/// Core decay behavior: a positive score should shrink toward zero
|
||||||
|
/// after one nightly pass, without crossing it.
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_pulls_toward_zero() {
|
fn decay_pulls_toward_zero() {
|
||||||
// Core decay behavior: a positive score should shrink toward zero
|
|
||||||
// after one nightly pass, without crossing it.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["rust".to_string()], 1.0);
|
aff.apply_feedback(&["rust".to_string()], 1.0);
|
||||||
let before = aff.score("rust");
|
let before = aff.score("rust");
|
||||||
|
|
@ -203,11 +203,11 @@ mod tests {
|
||||||
assert!(aff.score("rust") > 0.0);
|
assert!(aff.score("rust") > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the exact arithmetic (not just the direction) so a future
|
||||||
|
/// change to the decay formula is caught immediately.
|
||||||
|
/// surprise 1.0 -> 0.15, then one decay pass subtracts DAILY_DECAY (0.02).
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_gives_expected_value() {
|
fn decay_gives_expected_value() {
|
||||||
// Pins the exact arithmetic (not just the direction) so a future
|
|
||||||
// change to the decay formula is caught immediately.
|
|
||||||
// surprise 1.0 -> 0.15, then one decay pass subtracts DAILY_DECAY (0.02).
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["java".to_string()], 1.0);
|
aff.apply_feedback(&["java".to_string()], 1.0);
|
||||||
aff.decay();
|
aff.decay();
|
||||||
|
|
@ -215,33 +215,33 @@ mod tests {
|
||||||
assert_eq!(aff.score("java"), 0.13);
|
assert_eq!(aff.score("java"), 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for a real bug: subtracting a fixed 0.02 from a
|
||||||
|
/// smaller score (e.g. 0.015) used to flip its sign to -0.005 instead
|
||||||
|
/// of landing on 0.0, which would make the score oscillate around
|
||||||
|
/// zero on every subsequent decay pass rather than settling.
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_settles_at_zero_instead_of_overshooting() {
|
fn decay_settles_at_zero_instead_of_overshooting() {
|
||||||
// Regression test for a real bug: subtracting a fixed 0.02 from a
|
|
||||||
// smaller score (e.g. 0.015) used to flip its sign to -0.005 instead
|
|
||||||
// of landing on 0.0, which would make the score oscillate around
|
|
||||||
// zero on every subsequent decay pass rather than settling.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["rust".to_string()], 0.1); // score = 0.015
|
aff.apply_feedback(&["rust".to_string()], 0.1); // score = 0.015
|
||||||
aff.decay();
|
aff.decay();
|
||||||
assert_eq!(aff.score("rust"), 0.0);
|
assert_eq!(aff.score("rust"), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same fix as above, verified on the negative side, and also checks
|
||||||
|
/// that the post-decay prune (dropping |v| <= 1e-4) actually removes
|
||||||
|
/// the entry rather than leaving a stray 0.0 in the map.
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_prunes_negative_scores_that_settle_at_zero() {
|
fn decay_prunes_negative_scores_that_settle_at_zero() {
|
||||||
// Same fix as above, verified on the negative side, and also checks
|
|
||||||
// that the post-decay prune (dropping |v| <= 1e-4) actually removes
|
|
||||||
// the entry rather than leaving a stray 0.0 in the map.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["crypto".to_string()], -0.1); // score = -0.015
|
aff.apply_feedback(&["crypto".to_string()], -0.1); // score = -0.015
|
||||||
aff.decay();
|
aff.decay();
|
||||||
assert_eq!(aff.score("crypto"), 0.0);
|
assert_eq!(aff.score("crypto"), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// decay_pulls_toward_zero's mirror image: negative scores should
|
||||||
|
/// shrink in magnitude too, not just positive ones.
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_is_symmetric_for_negative_scores() {
|
fn decay_is_symmetric_for_negative_scores() {
|
||||||
// decay_pulls_toward_zero's mirror image: negative scores should
|
|
||||||
// shrink in magnitude too, not just positive ones.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["crypto".to_string()], -1.0);
|
aff.apply_feedback(&["crypto".to_string()], -1.0);
|
||||||
let before = aff.score("crypto");
|
let before = aff.score("crypto");
|
||||||
|
|
@ -252,27 +252,27 @@ mod tests {
|
||||||
|
|
||||||
// --- score / get_mean_affinity ---
|
// --- score / get_mean_affinity ---
|
||||||
|
|
||||||
|
/// A topic with no feedback yet must read as neutral (0.0), not
|
||||||
|
/// panic or return some other sentinel.
|
||||||
#[test]
|
#[test]
|
||||||
fn score_defaults_to_zero_for_unknown_topic() {
|
fn score_defaults_to_zero_for_unknown_topic() {
|
||||||
// A topic with no feedback yet must read as neutral (0.0), not
|
|
||||||
// panic or return some other sentinel.
|
|
||||||
let aff = TopicAffinities::default();
|
let aff = TopicAffinities::default();
|
||||||
assert_eq!(aff.score("never-seen"), 0.0);
|
assert_eq!(aff.score("never-seen"), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Documented behavior for untagged articles: defer entirely to the
|
||||||
|
/// embedding/LLM stages by returning a neutral 0.0 rather than
|
||||||
|
/// dividing by zero.
|
||||||
#[test]
|
#[test]
|
||||||
fn get_mean_affinity_given_empty_topics_returns_zero() {
|
fn get_mean_affinity_given_empty_topics_returns_zero() {
|
||||||
// Documented behavior for untagged articles: defer entirely to the
|
|
||||||
// embedding/LLM stages by returning a neutral 0.0 rather than
|
|
||||||
// dividing by zero.
|
|
||||||
let aff = TopicAffinities::default();
|
let aff = TopicAffinities::default();
|
||||||
assert_eq!(aff.get_mean_affinity(&[]), 0.0);
|
assert_eq!(aff.get_mean_affinity(&[]), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirms it's a plain arithmetic mean: an equally strong positive
|
||||||
|
/// and negative topic on the same article should cancel out to 0.0.
|
||||||
#[test]
|
#[test]
|
||||||
fn get_mean_affinity_averages_across_topics() {
|
fn get_mean_affinity_averages_across_topics() {
|
||||||
// Confirms it's a plain arithmetic mean: an equally strong positive
|
|
||||||
// and negative topic on the same article should cancel out to 0.0.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
||||||
aff.apply_feedback(&["crypto".to_string()], -1.0); // -0.15
|
aff.apply_feedback(&["crypto".to_string()], -1.0); // -0.15
|
||||||
|
|
@ -280,11 +280,11 @@ mod tests {
|
||||||
assert_eq!(aff.get_mean_affinity(&topics), 0.0);
|
assert_eq!(aff.get_mean_affinity(&topics), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A topic mix of "known" and "never seen" shouldn't shrink the
|
||||||
|
/// denominator or get skipped — the unscored topic counts as 0.0 in
|
||||||
|
/// the average, per score()'s default.
|
||||||
#[test]
|
#[test]
|
||||||
fn get_mean_affinity_treats_unscored_topics_as_zero() {
|
fn get_mean_affinity_treats_unscored_topics_as_zero() {
|
||||||
// A topic mix of "known" and "never seen" shouldn't shrink the
|
|
||||||
// denominator or get skipped — the unscored topic counts as 0.0 in
|
|
||||||
// the average, per score()'s default.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
||||||
let topics = vec!["rust".to_string(), "never-seen".to_string()];
|
let topics = vec!["rust".to_string(), "never-seen".to_string()];
|
||||||
|
|
@ -293,10 +293,10 @@ mod tests {
|
||||||
|
|
||||||
// --- top_n ---
|
// --- top_n ---
|
||||||
|
|
||||||
|
/// top_n is used to surface a user's strongest interests, so it must
|
||||||
|
/// sort highest-first (not insertion order) and respect the limit.
|
||||||
#[test]
|
#[test]
|
||||||
fn top_n_sorts_descending_and_truncates() {
|
fn top_n_sorts_descending_and_truncates() {
|
||||||
// top_n is used to surface a user's strongest interests, so it must
|
|
||||||
// sort highest-first (not insertion order) and respect the limit.
|
|
||||||
let mut aff = TopicAffinities::default();
|
let mut aff = TopicAffinities::default();
|
||||||
aff.apply_feedback(&["low".to_string()], 0.2);
|
aff.apply_feedback(&["low".to_string()], 0.2);
|
||||||
aff.apply_feedback(&["high".to_string()], 1.0);
|
aff.apply_feedback(&["high".to_string()], 1.0);
|
||||||
|
|
@ -310,72 +310,72 @@ mod tests {
|
||||||
|
|
||||||
// --- engagement_score ---
|
// --- engagement_score ---
|
||||||
|
|
||||||
|
/// No signal at all (not opened, not dismissed) is neutral, not
|
||||||
|
/// penalized.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_never_opened_is_zero() {
|
fn engagement_score_never_opened_is_zero() {
|
||||||
// No signal at all (not opened, not dismissed) is neutral, not
|
|
||||||
// penalized.
|
|
||||||
assert_eq!(engagement_score(false, None, None, false, false), 0.0);
|
assert_eq!(engagement_score(false, None, None, false, false), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dismissing without opening is an explicit negative signal, but
|
||||||
|
/// engagement_score itself is floored at 0.0 (the doc comment notes
|
||||||
|
/// the negative direction is expressed later via `surprise`, not
|
||||||
|
/// here) — this pins that the dismissed+!opened branch returns 0.0,
|
||||||
|
/// not a negative number.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_dismissed_without_opening_is_zero() {
|
fn engagement_score_dismissed_without_opening_is_zero() {
|
||||||
// Dismissing without opening is an explicit negative signal, but
|
|
||||||
// engagement_score itself is floored at 0.0 (the doc comment notes
|
|
||||||
// the negative direction is expressed later via `surprise`, not
|
|
||||||
// here) — this pins that the dismissed+!opened branch returns 0.0,
|
|
||||||
// not a negative number.
|
|
||||||
assert_eq!(engagement_score(false, None, None, false, true), 0.0);
|
assert_eq!(engagement_score(false, None, None, false, true), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reading half the estimated time should score as half-engaged.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_opened_uses_dwell_over_estimate_ratio() {
|
fn engagement_score_opened_uses_dwell_over_estimate_ratio() {
|
||||||
// Reading half the estimated time should score as half-engaged.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engagement_score(true, Some(30), Some(60), false, false),
|
engagement_score(true, Some(30), Some(60), false, false),
|
||||||
0.5
|
0.5
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dwelling far longer than the estimate (e.g. left the tab open)
|
||||||
|
/// must not push the score above the documented [0.0, 1.0] range.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_opened_caps_ratio_at_one() {
|
fn engagement_score_opened_caps_ratio_at_one() {
|
||||||
// Dwelling far longer than the estimate (e.g. left the tab open)
|
|
||||||
// must not push the score above the documented [0.0, 1.0] range.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engagement_score(true, Some(600), Some(60), false, false),
|
engagement_score(true, Some(600), Some(60), false, false),
|
||||||
1.0
|
1.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When we simply don't have dwell/estimate data yet, the code
|
||||||
|
/// credits partial engagement (0.5) rather than assuming 0 (unfairly
|
||||||
|
/// penalizing) or 1 (unfairly rewarding).
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_opened_without_dwell_or_estimate_defaults_to_half() {
|
fn engagement_score_opened_without_dwell_or_estimate_defaults_to_half() {
|
||||||
// When we simply don't have dwell/estimate data yet, the code
|
|
||||||
// credits partial engagement (0.5) rather than assuming 0 (unfairly
|
|
||||||
// penalizing) or 1 (unfairly rewarding).
|
|
||||||
assert_eq!(engagement_score(true, None, None, false, false), 0.5);
|
assert_eq!(engagement_score(true, None, None, false, false), 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// est == 0 would divide by zero, so the `est > 0` guard routes this
|
||||||
|
/// case to the same "unknown read time" default (0.5) instead of
|
||||||
|
/// panicking or producing NaN/infinity.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_opened_with_zero_estimate_defaults_to_half() {
|
fn engagement_score_opened_with_zero_estimate_defaults_to_half() {
|
||||||
// est == 0 would divide by zero, so the `est > 0` guard routes this
|
|
||||||
// case to the same "unknown read time" default (0.5) instead of
|
|
||||||
// panicking or producing NaN/infinity.
|
|
||||||
assert_eq!(engagement_score(true, Some(10), Some(0), false, false), 0.5);
|
assert_eq!(engagement_score(true, Some(10), Some(0), false, false), 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starring is an explicit "yes" beyond dwell time: it should add
|
||||||
|
/// 0.3 on top of the dwell-ratio score.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_starred_adds_bonus() {
|
fn engagement_score_starred_adds_bonus() {
|
||||||
// Starring is an explicit "yes" beyond dwell time: it should add
|
|
||||||
// 0.3 on top of the dwell-ratio score.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engagement_score(true, Some(30), Some(60), true, false),
|
engagement_score(true, Some(30), Some(60), true, false),
|
||||||
0.8
|
0.8
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The +0.3 star bonus must also respect the 1.0 ceiling, even when
|
||||||
|
/// the dwell ratio alone is already at the max.
|
||||||
#[test]
|
#[test]
|
||||||
fn engagement_score_starred_bonus_caps_at_one() {
|
fn engagement_score_starred_bonus_caps_at_one() {
|
||||||
// The +0.3 star bonus must also respect the 1.0 ceiling, even when
|
|
||||||
// the dwell ratio alone is already at the max.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engagement_score(true, Some(60), Some(60), true, false),
|
engagement_score(true, Some(60), Some(60), true, false),
|
||||||
1.0
|
1.0
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub mod models;
|
|
||||||
pub mod affinity;
|
pub mod affinity;
|
||||||
|
pub mod models;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
|
||||||
pub use affinity::TopicAffinities;
|
pub use affinity::TopicAffinities;
|
||||||
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
|
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
|
||||||
pub use scoring::{RelevanceInputs, score_article};
|
pub use scoring::{score_article, RelevanceInputs};
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ pub enum ReadingOutcome {
|
||||||
Impression,
|
Impression,
|
||||||
/// User opened the article. `dwell_seconds` is filled in later via an
|
/// User opened the article. `dwell_seconds` is filled in later via an
|
||||||
/// update event (e.g. on tab close / navigation away) once known.
|
/// update event (e.g. on tab close / navigation away) once known.
|
||||||
Opened { dwell_seconds: Option<u32> },
|
Opened {
|
||||||
|
dwell_seconds: Option<u32>,
|
||||||
|
},
|
||||||
Starred,
|
Starred,
|
||||||
/// Explicitly marked not relevant, independent of whether it was opened.
|
/// Explicitly marked not relevant, independent of whether it was opened.
|
||||||
Dismissed,
|
Dismissed,
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@ const W_EMBEDDING: f32 = 0.25;
|
||||||
const W_AFFINITY: f32 = 0.15;
|
const W_AFFINITY: f32 = 0.15;
|
||||||
|
|
||||||
pub fn score_article(inputs: RelevanceInputs) -> f32 {
|
pub fn score_article(inputs: RelevanceInputs) -> f32 {
|
||||||
let affinity = inputs.affinities.score_topics(inputs.topics) as f32; // [-1, 1]
|
let affinity = inputs.affinities.get_mean_affinity(inputs.topics) as f32; // [-1, 1]
|
||||||
let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1]
|
let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1]
|
||||||
|
|
||||||
match inputs.llm_score {
|
match inputs.llm_score {
|
||||||
Some(llm) => W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component,
|
Some(llm) => {
|
||||||
|
W_LLM * llm + W_EMBEDDING * inputs.embedding_score + W_AFFINITY * affinity_component
|
||||||
|
}
|
||||||
// No LLM score yet: redistribute its weight onto the embedding score.
|
// No LLM score yet: redistribute its weight onto the embedding score.
|
||||||
None => (W_LLM + W_EMBEDDING) * inputs.embedding_score + W_AFFINITY * affinity_component,
|
None => (W_LLM + W_EMBEDDING) * inputs.embedding_score + W_AFFINITY * affinity_component,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ impl Db {
|
||||||
let url = database_url.to_string();
|
let url = database_url.to_string();
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let mut conn = SqliteConnection::establish(&url)?;
|
let mut conn = SqliteConnection::establish(&url)?;
|
||||||
conn.run_pending_migrations(MIGRATIONS).map_err(|e| anyhow::anyhow!(e))?;
|
conn.run_pending_migrations(MIGRATIONS)
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
|
@ -58,7 +59,11 @@ impl Db {
|
||||||
.set(dsl::title.eq(title))
|
.set(dsl::title.eq(title))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
let id: String = dsl::feeds.filter(dsl::url.eq(url)).select(dsl::id).first(&mut conn).await?;
|
let id: String = dsl::feeds
|
||||||
|
.filter(dsl::url.eq(url))
|
||||||
|
.select(dsl::id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.await?;
|
||||||
Ok(Uuid::parse_str(&id)?)
|
Ok(Uuid::parse_str(&id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +109,10 @@ impl Db {
|
||||||
|
|
||||||
/// Title/summary/topics/embedding_score for one article, used to build
|
/// Title/summary/topics/embedding_score for one article, used to build
|
||||||
/// the LLM-judging prompt for it.
|
/// the LLM-judging prompt for it.
|
||||||
pub async fn article_scoring_fields(&self, article_id: Uuid) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
pub async fn article_scoring_fields(
|
||||||
|
&self,
|
||||||
|
article_id: Uuid,
|
||||||
|
) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
||||||
use schema::articles::dsl;
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
||||||
|
|
@ -122,18 +130,31 @@ impl Db {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn store_llm_result(&self, article_id: Uuid, llm_score: f32, rationale: &str, final_score: f32) -> Result<()> {
|
pub async fn store_llm_result(
|
||||||
|
&self,
|
||||||
|
article_id: Uuid,
|
||||||
|
llm_score: f32,
|
||||||
|
rationale: &str,
|
||||||
|
final_score: f32,
|
||||||
|
) -> Result<()> {
|
||||||
use schema::articles::dsl;
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string())))
|
diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string())))
|
||||||
.set((dsl::llm_score.eq(llm_score), dsl::llm_rationale.eq(rationale), dsl::final_score.eq(final_score)))
|
.set((
|
||||||
|
dsl::llm_score.eq(llm_score),
|
||||||
|
dsl::llm_rationale.eq(rationale),
|
||||||
|
dsl::final_score.eq(final_score),
|
||||||
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highest-ranked articles for display, most relevant first.
|
/// Highest-ranked articles for display, most relevant first.
|
||||||
pub async fn list_ranked_articles(&self, limit: i64) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
|
pub async fn list_ranked_articles(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
|
||||||
use schema::articles::dsl;
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
||||||
|
|
@ -141,13 +162,27 @@ impl Db {
|
||||||
let rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles
|
let rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles
|
||||||
.order(dsl::final_score.desc())
|
.order(dsl::final_score.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.select((dsl::id, dsl::title, dsl::url, dsl::summary, dsl::topics, dsl::final_score))
|
.select((
|
||||||
|
dsl::id,
|
||||||
|
dsl::title,
|
||||||
|
dsl::url,
|
||||||
|
dsl::summary,
|
||||||
|
dsl::topics,
|
||||||
|
dsl::final_score,
|
||||||
|
))
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, title, url, summary, topics_json, final_score)| {
|
.map(|(id, title, url, summary, topics_json, final_score)| {
|
||||||
(id, title, url, summary, serde_json::from_str(&topics_json).unwrap_or_default(), final_score)
|
(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
summary,
|
||||||
|
serde_json::from_str(&topics_json).unwrap_or_default(),
|
||||||
|
final_score,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +230,11 @@ impl Db {
|
||||||
let json = serde_json::to_string(affinities)?;
|
let json = serde_json::to_string(affinities)?;
|
||||||
let updated_at = Utc::now().to_rfc3339();
|
let updated_at = Utc::now().to_rfc3339();
|
||||||
diesel::insert_into(dsl::topic_affinities)
|
diesel::insert_into(dsl::topic_affinities)
|
||||||
.values((dsl::user_id.eq("default"), dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at)))
|
.values((
|
||||||
|
dsl::user_id.eq("default"),
|
||||||
|
dsl::affinities.eq(&json),
|
||||||
|
dsl::updated_at.eq(&updated_at),
|
||||||
|
))
|
||||||
.on_conflict(dsl::user_id)
|
.on_conflict(dsl::user_id)
|
||||||
.do_update()
|
.do_update()
|
||||||
.set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at)))
|
.set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at)))
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,12 @@ impl Llm {
|
||||||
/// be pulled locally, e.g. `ollama pull nomic-embed-text` and
|
/// be pulled locally, e.g. `ollama pull nomic-embed-text` and
|
||||||
/// `ollama pull llama3.1`. `embedding_dims` must match the pulled
|
/// `ollama pull llama3.1`. `embedding_dims` must match the pulled
|
||||||
/// embedding model (768 for nomic-embed-text, 384 for all-minilm).
|
/// embedding model (768 for nomic-embed-text, 384 for all-minilm).
|
||||||
pub fn new(base_url: &str, embedding_model: impl Into<String>, embedding_dims: usize, chat_model: impl Into<String>) -> Result<Self> {
|
pub fn new(
|
||||||
|
base_url: &str,
|
||||||
|
embedding_model: impl Into<String>,
|
||||||
|
embedding_dims: usize,
|
||||||
|
chat_model: impl Into<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
let client = ollama::Client::builder()
|
let client = ollama::Client::builder()
|
||||||
.api_key(Nothing)
|
.api_key(Nothing)
|
||||||
.base_url(base_url)
|
.base_url(base_url)
|
||||||
|
|
@ -42,8 +47,13 @@ impl Llm {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn embed(&self, text: &str) -> Result<Vec<f32>> {
|
pub async fn embed(&self, text: &str) -> Result<Vec<f32>> {
|
||||||
let model = self.client.embedding_model_with_ndims(&self.embedding_model, self.embedding_dims);
|
let model = self
|
||||||
let embedding = model.embed_text(text).await.context("ollama embedding request failed")?;
|
.client
|
||||||
|
.embedding_model_with_ndims(&self.embedding_model, self.embedding_dims);
|
||||||
|
let embedding = model
|
||||||
|
.embed_text(text)
|
||||||
|
.await
|
||||||
|
.context("ollama embedding request failed")?;
|
||||||
Ok(embedding.vec.into_iter().map(|v| v as f32).collect())
|
Ok(embedding.vec.into_iter().map(|v| v as f32).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +89,10 @@ impl Llm {
|
||||||
)
|
)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let response = model.completion(request).await.context("ollama chat request failed")?;
|
let response = model
|
||||||
|
.completion(request)
|
||||||
|
.await
|
||||||
|
.context("ollama chat request failed")?;
|
||||||
let text: String = response
|
let text: String = response
|
||||||
.choice
|
.choice
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ pub fn App() -> Element {
|
||||||
let articles = use_server_future(list_ranked_articles)?;
|
let articles = use_server_future(list_ranked_articles)?;
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
style { {include_str!("../assets/app.css")} }
|
Stylesheet { href: asset!("/assets/app.css") }
|
||||||
main {
|
main {
|
||||||
h1 { "feedsignal" }
|
h1 { "feedsignal" }
|
||||||
match articles.read().as_ref() {
|
match articles.read().as_ref() {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,15 @@ async fn ensure_background_jobs_started(db: Arc<Db>) {
|
||||||
.get_or_init(|| async {
|
.get_or_init(|| async {
|
||||||
// TODO: move base_url/model names to config/env once there's a
|
// TODO: move base_url/model names to config/env once there's a
|
||||||
// settings story; hardcoded to models already pulled locally.
|
// settings story; hardcoded to models already pulled locally.
|
||||||
let llm = Arc::new(Llm::new("http://localhost:11434", "nomic-embed-text", 768, "gemma4-e4b").expect("failed to construct ollama client"));
|
let llm = Arc::new(
|
||||||
|
Llm::new(
|
||||||
|
"http://localhost:11434",
|
||||||
|
"nomic-embed-text",
|
||||||
|
768,
|
||||||
|
"gemma4-e4b",
|
||||||
|
)
|
||||||
|
.expect("failed to construct ollama client"),
|
||||||
|
);
|
||||||
if let Err(err) = pipeline::start_scheduler(db, llm).await {
|
if let Err(err) = pipeline::start_scheduler(db, llm).await {
|
||||||
tracing::error!(?err, "failed to start background job scheduler");
|
tracing::error!(?err, "failed to start background job scheduler");
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +55,16 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
|
||||||
.list_ranked_articles(100)
|
.list_ranked_articles(100)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, title, url, summary, topics, final_score)| ArticleView { id, title, url, summary, topics, final_score })
|
.map(
|
||||||
|
|(id, title, url, summary, topics, final_score)| ArticleView {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
summary,
|
||||||
|
topics,
|
||||||
|
final_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,9 @@ pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler>
|
||||||
/// a TODO here — wire it in once feed subscription management exists.
|
/// a TODO here — wire it in once feed subscription management exists.
|
||||||
async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> {
|
async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> {
|
||||||
let affinities = db.load_affinities().await?;
|
let affinities = db.load_affinities().await?;
|
||||||
let shortlist = db.shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE).await?;
|
let shortlist = db
|
||||||
|
.shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tracing::info!(count = shortlist.len(), "scoring shortlist with LLM");
|
tracing::info!(count = shortlist.len(), "scoring shortlist with LLM");
|
||||||
|
|
||||||
|
|
@ -72,7 +74,9 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> {
|
||||||
for article_id in shortlist {
|
for article_id in shortlist {
|
||||||
// Fetch just the fields needed for the prompt; a real implementation
|
// Fetch just the fields needed for the prompt; a real implementation
|
||||||
// would batch this rather than one query per article.
|
// would batch this rather than one query per article.
|
||||||
let Some((title, summary, topics, embedding_score)) = db.article_scoring_fields(article_id).await? else {
|
let Some((title, summary, topics, embedding_score)) =
|
||||||
|
db.article_scoring_fields(article_id).await?
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,7 +96,8 @@ async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> {
|
||||||
affinities: &affinities,
|
affinities: &affinities,
|
||||||
});
|
});
|
||||||
|
|
||||||
db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score).await?;
|
db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue