feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s
Some checks failed
CI / Check / Test (push) Failing after 9m16s
Conservative, live insights (ADR-0010) derived from each user's diary: rating lift by genre (first-time watches only), genre seasonality vs a uniform 1/12 month baseline, medium weekend-lift, and watch-time rhythm. - domain: WatchRow model, InsightsQuery port, pure insights_analyzer with all honesty thresholds (sample minimums, strength cutoffs, suppressions for import noise), unit-tested - adapters: thin Sqlite/Postgres fetch twins, reusing WrapUp's genre fetch - application: get_insights use case, InsightsGroup in Deps - composition/server: wiring through Repositories/DatabaseOutput - presentation: GET /api/v1/insights handler, typed serde-tagged DTOs, openapi doc - spa: /insights route (shadcn cards), insights feature hook, profile entry link, en locale strings - server test: end-to-end seed sqlite → HTTP → rating lift + rhythm Also records ADR-0010, defines "Insight" in the glossary, and includes pre-existing worktree comment-stripping cleanups.
This commit is contained in:
@@ -39,10 +39,6 @@ pub struct FederatedProfile {
|
||||
pub banner_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A domain-blocklist entry as presented to admin surfaces. Mirrors what the
|
||||
/// federation adapter can supply; `blocked_at` is a pre-formatted string
|
||||
/// because that is what the underlying store returns and every consumer
|
||||
/// renders it verbatim.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlockedDomainInfo {
|
||||
pub domain: String,
|
||||
@@ -50,9 +46,6 @@ pub struct BlockedDomainInfo {
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
/// A remote actor the local instance follows, reduced to the two fields any
|
||||
/// consumer actually reads. Deliberately narrower than the federation
|
||||
/// library's actor type — widening it is a decision, not an oversight.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FollowedActorInfo {
|
||||
pub url: String,
|
||||
|
||||
66
crates/domain/src/models/insights.rs
Normal file
66
crates/domain/src/models/insights.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One denormalized row per Review — everything the insight analyzer needs,
|
||||
/// nothing it doesn't (ADR-0010). Lighter than `WrapUpMovieRow` on purpose:
|
||||
/// WrapUp's row is a report-era artifact with poster paths, budgets and cast
|
||||
/// that Insights must not inherit.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WatchRow {
|
||||
pub movie_id: Uuid,
|
||||
pub rating: u8,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub watch_medium: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single conservative, baseline-relative statement about a user's viewing
|
||||
/// behavior (see CONTEXT.md "Insight"). Every variant carries the numbers the
|
||||
/// SPA needs to render the claim plus the `sample_count` behind it, so the UI
|
||||
/// can show why a category is empty without another round trip.
|
||||
///
|
||||
/// Ordering contract: `build_insights` returns insights grouped by category in
|
||||
/// a fixed order (rating lift, seasonality, medium timing, rhythm), strongest
|
||||
/// first within each category, at most `MAX_PER_CATEGORY` per category.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Insight {
|
||||
/// The genre's average rating sits `delta` stars away from the user's own
|
||||
/// average. First-time watches only — a rewatch is chosen because it is
|
||||
/// already liked, so counting it would conflate choice with genre.
|
||||
RatingLift {
|
||||
genre: String,
|
||||
genre_avg: f64,
|
||||
user_avg: f64,
|
||||
delta: f64,
|
||||
sample_count: u32,
|
||||
},
|
||||
/// The genre's share of watches in one month of the year is `multiplier`
|
||||
/// times the uniform 1/12 baseline.
|
||||
Seasonality {
|
||||
genre: String,
|
||||
/// 1–12
|
||||
month: u32,
|
||||
multiplier: f64,
|
||||
sample_count: u32,
|
||||
},
|
||||
/// The medium's weekend share of watches is `multiplier` times the user's
|
||||
/// overall weekend share.
|
||||
MediumTiming {
|
||||
medium: String,
|
||||
multiplier: f64,
|
||||
weekend_share: f64,
|
||||
baseline_share: f64,
|
||||
sample_count: u32,
|
||||
},
|
||||
/// When watching actually happens: the 3-hour window and the weekdays that
|
||||
/// carry the most real-timestamped watches.
|
||||
Rhythm {
|
||||
/// 0–23, start of the peak 3-hour window.
|
||||
peak_hour_start: u32,
|
||||
/// 0–23, exclusive end of the peak window (`start + 3`, mod 24).
|
||||
peak_hour_end: u32,
|
||||
/// Chrono weekday numbers, strongest first: Mon=1 … Sun=7.
|
||||
top_days: Vec<u32>,
|
||||
sample_count: u32,
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod goal;
|
||||
pub mod import;
|
||||
pub mod import_profile;
|
||||
pub mod import_session;
|
||||
pub mod insights;
|
||||
pub mod person;
|
||||
pub mod remote_goal;
|
||||
pub mod remote_watchlist;
|
||||
@@ -24,6 +25,7 @@ pub mod wrapup;
|
||||
pub use enrichment::*;
|
||||
pub use federation::*;
|
||||
pub use feed::*;
|
||||
pub use insights::*;
|
||||
pub use movie::*;
|
||||
pub use review::*;
|
||||
pub use stats::{
|
||||
|
||||
@@ -12,7 +12,6 @@ impl ExternalPersonId {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Parse the TMDb numeric ID from "tmdb:12345". Returns None for other formats.
|
||||
pub fn tmdb_id(&self) -> Option<i64> {
|
||||
self.0.strip_prefix("tmdb:").and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ pub struct PersistedReview {
|
||||
pub watch_medium: Option<WatchMedium>,
|
||||
}
|
||||
|
||||
/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReviewEdit {
|
||||
pub rating: Option<Rating>,
|
||||
@@ -133,7 +132,6 @@ impl Review {
|
||||
pub fn watch_medium(&self) -> Option<&WatchMedium> {
|
||||
self.watch_medium.as_ref()
|
||||
}
|
||||
/// Returns [star1_filled, star2_filled, ..., star5_filled]
|
||||
pub fn stars(&self) -> [bool; 5] {
|
||||
let r = self.rating.value();
|
||||
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]
|
||||
|
||||
@@ -42,13 +42,9 @@ pub struct PersonSearchHit {
|
||||
pub name: String,
|
||||
pub known_for_department: Option<String>,
|
||||
pub profile_path: Option<String>,
|
||||
/// Top movie titles this person is known for — populated at query time
|
||||
/// by joining relational tables, never from the index.
|
||||
pub known_for_titles: Vec<String>,
|
||||
}
|
||||
|
||||
/// Document submitted to the search index.
|
||||
/// Add a new variant here to make a new entity type searchable — the port never changes.
|
||||
pub enum IndexableDocument {
|
||||
Movie {
|
||||
id: MovieId,
|
||||
@@ -58,7 +54,6 @@ pub enum IndexableDocument {
|
||||
Person {
|
||||
id: PersonId,
|
||||
person: Box<Person>,
|
||||
// known_for_titles intentionally absent — no reads inside a command flow
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ pub trait EventPublisher: Send + Sync {
|
||||
}
|
||||
|
||||
pub trait EventConsumer: Send + Sync {
|
||||
/// Returns a stream of event envelopes. Each envelope carries a domain event
|
||||
/// and an ack handle — callers ack after successful dispatch, nack on failure.
|
||||
/// Implementations decide transport (NATS, DB queue, in-memory channel).
|
||||
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, DomainError>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ use crate::{
|
||||
models::{BlockedDomainInfo, FollowedActorInfo},
|
||||
};
|
||||
|
||||
/// Serves ActivityPub documents for content negotiation. Presentation calls
|
||||
/// this when a peer sends `Accept: application/activity+json`.
|
||||
#[async_trait]
|
||||
pub trait ApDocumentPort: Send + Sync {
|
||||
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError>;
|
||||
@@ -23,8 +21,6 @@ pub trait ApDocumentPort: Send + Sync {
|
||||
) -> Result<String, DomainError>;
|
||||
}
|
||||
|
||||
/// Instance-wide domain blocklist administration. Presentation calls this from
|
||||
/// the admin API and the admin HTML pages; nothing outside presentation does.
|
||||
#[async_trait]
|
||||
pub trait InstanceBlocklistPort: Send + Sync {
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError>;
|
||||
@@ -36,8 +32,6 @@ pub trait InstanceBlocklistPort: Send + Sync {
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Pulling remote content in, and pushing local content out, after a follow
|
||||
/// is established. Worker-side only — no HTTP handler calls this.
|
||||
#[async_trait]
|
||||
pub trait ApBackfillPort: Send + Sync {
|
||||
async fn get_following(
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::{errors::DomainError, value_objects::PosterUrl};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ObjectStorage: Send + Sync {
|
||||
/// Stores `image_bytes` at `key` and returns the stored key.
|
||||
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
|
||||
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
|
||||
async fn get_stream(
|
||||
|
||||
12
crates/domain/src/ports/insights.rs
Normal file
12
crates/domain/src/ports/insights.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{errors::DomainError, models::insights::WatchRow, value_objects::UserId};
|
||||
|
||||
/// Read side of Insights (ADR-0010): fetches one denormalized `WatchRow` per
|
||||
/// review across the user's whole diary. All thresholds and baseline
|
||||
/// comparisons live in the pure `insights_analyzer`, never in adapters —
|
||||
/// SQL's job here is "rows in, rows out" and nothing else.
|
||||
#[async_trait]
|
||||
pub trait InsightsQuery: Send + Sync {
|
||||
async fn get_watch_rows(&self, user_id: &UserId) -> Result<Vec<WatchRow>, DomainError>;
|
||||
}
|
||||
@@ -12,7 +12,6 @@ pub trait MediaServerParser: Send + Sync {
|
||||
-> Result<Option<ParsedPlaybackEvent>, DomainError>;
|
||||
}
|
||||
|
||||
/// Write port — mutates watch events.
|
||||
#[async_trait]
|
||||
pub trait WatchEventCommand: Send + Sync {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
|
||||
@@ -32,7 +31,6 @@ pub trait WatchEventCommand: Send + Sync {
|
||||
) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
/// Read port — queries watch events. No mutations.
|
||||
#[async_trait]
|
||||
pub trait WatchEventQuery: Send + Sync {
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError>;
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod goals;
|
||||
pub mod image_fetcher;
|
||||
pub mod images;
|
||||
pub mod import;
|
||||
pub mod insights;
|
||||
pub mod jobs;
|
||||
pub mod media_server;
|
||||
pub mod movie;
|
||||
@@ -29,6 +30,7 @@ pub use goals::*;
|
||||
pub use image_fetcher::*;
|
||||
pub use images::*;
|
||||
pub use import::*;
|
||||
pub use insights::*;
|
||||
pub use jobs::*;
|
||||
pub use media_server::*;
|
||||
pub use movie::*;
|
||||
|
||||
@@ -9,14 +9,12 @@ use crate::{
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
|
||||
/// Write port — mutates the movies table.
|
||||
#[async_trait]
|
||||
pub trait MovieCommand: Send + Sync {
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Read port — queries movies. No mutations.
|
||||
#[async_trait]
|
||||
pub trait MovieQuery: Send + Sync {
|
||||
async fn get_movie_by_external_id(
|
||||
@@ -42,7 +40,6 @@ pub trait MovieQuery: Send + Sync {
|
||||
page: &PageParams,
|
||||
filter: &MovieFilter,
|
||||
) -> Result<Paginated<MovieSummary>, DomainError>;
|
||||
/// Returns all movies that have an external_metadata_id set. Used for deduplication.
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -62,14 +59,11 @@ pub trait MetadataClient: Send + Sync {
|
||||
pub trait MovieProfileRepository: Send + Sync {
|
||||
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
|
||||
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
|
||||
/// (enriched_at older than 30 days).
|
||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieEnrichmentClient: Send + Sync {
|
||||
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
@@ -79,9 +73,6 @@ pub trait MovieEnrichmentClient: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieDeduplicator: Send + Sync {
|
||||
/// Atomically re-points all foreign keys (reviews, watchlist entries, movie profiles)
|
||||
/// from `old_id` to `canonical_id`, upserts the canonical movie record, then deletes
|
||||
/// the old duplicate. Returns the number of rows re-pointed across all tables.
|
||||
async fn merge_into_canonical(
|
||||
&self,
|
||||
old_id: &MovieId,
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::{
|
||||
|
||||
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopRemoteWatchlistRepository;
|
||||
|
||||
#[async_trait]
|
||||
@@ -113,7 +112,6 @@ impl super::BlockQuery for NoopSocialQuery {
|
||||
|
||||
// ── NoopFederationAdminQuery ─────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — returns empty results.
|
||||
pub struct NoopFederationAdminQuery;
|
||||
|
||||
#[async_trait]
|
||||
@@ -127,7 +125,6 @@ impl super::FederationAdminQuery for NoopFederationAdminQuery {
|
||||
|
||||
// ── NoopApDocument ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopApDocument;
|
||||
|
||||
#[async_trait]
|
||||
@@ -153,7 +150,6 @@ impl super::ApDocumentPort for NoopApDocument {
|
||||
|
||||
// ── NoopInstanceBlocklist ────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopInstanceBlocklist;
|
||||
|
||||
#[async_trait]
|
||||
@@ -173,7 +169,6 @@ impl super::InstanceBlocklistPort for NoopInstanceBlocklist {
|
||||
|
||||
// ── NoopApBackfill ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Stub used when federation is disabled — every operation is a no-op.
|
||||
pub struct NoopApBackfill;
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -10,13 +10,9 @@ pub trait PersonEnrichmentClient: Send + Sync {
|
||||
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
|
||||
}
|
||||
|
||||
/// Write port — mutates the persons table. No reads.
|
||||
#[async_trait]
|
||||
pub trait PersonCommand: Send + Sync {
|
||||
/// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres).
|
||||
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>;
|
||||
/// Insert a batch of missing persons from movie_cast/movie_crew into the persons table.
|
||||
/// Returns (inserted_count, has_more).
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
batch_size: u32,
|
||||
@@ -28,7 +24,6 @@ pub trait PersonCommand: Send + Sync {
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Read port — queries persons and credits. No mutations.
|
||||
#[async_trait]
|
||||
pub trait PersonQuery: Send + Sync {
|
||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError>;
|
||||
@@ -36,10 +31,7 @@ pub trait PersonQuery: Send + Sync {
|
||||
&self,
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError>;
|
||||
/// Returns the person's full cast and crew credit history across all indexed movies.
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError>;
|
||||
/// Returns persons who have no remaining entries in movie_cast or movie_crew.
|
||||
/// Called after movie deletion to find index entries that can be pruned.
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError>;
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -5,17 +5,13 @@ use crate::{
|
||||
models::{EntityType, IndexableDocument, SearchQuery, SearchResults},
|
||||
};
|
||||
|
||||
/// Read port — executes search queries. No mutations.
|
||||
#[async_trait]
|
||||
pub trait SearchPort: Send + Sync {
|
||||
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError>;
|
||||
}
|
||||
|
||||
/// Write port — manages the search index. No reads.
|
||||
#[async_trait]
|
||||
pub trait SearchCommand: Send + Sync {
|
||||
/// Add or replace a document in the search index.
|
||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>;
|
||||
/// Remove a document from the search index by entity type and internal ID string.
|
||||
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -127,35 +127,11 @@ pub trait LocalApContentQuery: Send + Sync {
|
||||
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||
}
|
||||
|
||||
/// Resolves a `FollowTarget` (a handle or an already-known identity) to the
|
||||
/// `SocialIdentity` that should be dispatched on — local vs. remote.
|
||||
///
|
||||
/// Split out from `SocialCommand::follow` because the decision must happen
|
||||
/// once, before dispatch, and both `LocalSocialService` and
|
||||
/// `CompositeSocialAdapter` need to observe its result rather than each
|
||||
/// re-deriving it (which would need `UserRepository` on the composite and
|
||||
/// duplicate the local/remote fallthrough logic).
|
||||
#[async_trait]
|
||||
pub trait FollowTargetResolver: Send + Sync {
|
||||
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError>;
|
||||
}
|
||||
|
||||
/// Performs the local follow write for a target whose `SocialIdentity` a caller
|
||||
/// has *already* resolved via `FollowTargetResolver::resolve_target`.
|
||||
///
|
||||
/// Exists so `CompositeSocialAdapter` — which must call `resolve_target` first
|
||||
/// to decide local vs. remote dispatch — doesn't then hand the raw
|
||||
/// `FollowTarget` to `SocialCommand::follow` and pay for a second resolution of
|
||||
/// the same handle. That second resolution wasn't just wasteful: on a
|
||||
/// federation-ON deployment it could also change the answer (the target user
|
||||
/// deleted between the two calls, e.g.), turning a local follow into a bogus
|
||||
/// "federation is not enabled" error.
|
||||
///
|
||||
/// `LocalSocialService::follow` is `resolve_target_identity` then
|
||||
/// `follow_resolved` — the self-follow guard and the two-sided write live only
|
||||
/// in the latter, so a caller that already has the identity (the composite)
|
||||
/// and one that doesn't (federation-off `SocialCommand::follow`, wired
|
||||
/// directly) both end up running the exact same write path.
|
||||
#[async_trait]
|
||||
pub trait ResolvedFollow: Send + Sync {
|
||||
async fn follow_resolved(
|
||||
@@ -165,15 +141,6 @@ pub trait ResolvedFollow: Send + Sync {
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// The subset of social behavior that needs no ActivityPub — everything a
|
||||
/// single-instance deployment can do with only its own database.
|
||||
///
|
||||
/// A marker supertrait with a blanket impl, so any type implementing all five
|
||||
/// parts is usable as `Arc<dyn LocalSocial>` without a separate registration.
|
||||
/// Same shape as `k_ap::FollowRepository` over its five follow traits.
|
||||
///
|
||||
/// `CompositeSocialAdapter` takes one of these for its local branches, which is
|
||||
/// what lets the `activitypub` crate stay unaware of `application`.
|
||||
pub trait LocalSocial:
|
||||
SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow
|
||||
{
|
||||
@@ -333,8 +300,6 @@ mod local_social_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The blanket impl must make any type implementing all five usable as
|
||||
/// `Arc<dyn LocalSocial>`, and `LocalSocial` must stay object-safe.
|
||||
#[test]
|
||||
fn blanket_impl_yields_a_trait_object() {
|
||||
let local: Arc<dyn LocalSocial> = Arc::new(Stub);
|
||||
|
||||
@@ -11,13 +11,10 @@ use crate::{
|
||||
|
||||
#[async_trait]
|
||||
pub trait WatchlistRepository: Send + Sync {
|
||||
/// Add a new entry. Silently succeeds if the entry already exists.
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove an entry. Returns NotFound if the entry does not exist.
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove an entry if it exists. Never returns NotFound.
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
|
||||
343
crates/domain/src/services/insights_analyzer.rs
Normal file
343
crates/domain/src/services/insights_analyzer.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
//! Conservative insight extraction over a user's whole diary (ADR-0010).
|
||||
//!
|
||||
//! Every threshold lives here, not in adapters, so the honesty rules are
|
||||
//! unit-testable without a database. An insight that cannot clear its
|
||||
//! thresholds is silently absent — an empty page is the correct rendering of
|
||||
//! a small or noisy diary, not a bug to fix by lowering thresholds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, Timelike, Weekday};
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
|
||||
// ── Honesty thresholds ───────────────────────────────────────────────────────
|
||||
|
||||
/// First-time ratings a genre needs before its average means anything.
|
||||
const MIN_GENRE_RATINGS: usize = 5;
|
||||
/// First-time ratings overall before the user's own average is a stable baseline.
|
||||
const MIN_BASELINE_RATINGS: usize = 10;
|
||||
/// Minimum distance from the user's average before a rating lift is worth saying.
|
||||
const MIN_RATING_DELTA: f64 = 0.3;
|
||||
/// Rows a genre needs before month-of-year distribution is more than noise.
|
||||
const MIN_GENRE_SEASON_ROWS: usize = 15;
|
||||
/// Minimum lift over the uniform 1/12 month baseline.
|
||||
const MIN_SEASON_MULTIPLIER: f64 = 1.5;
|
||||
/// Rows a medium needs before its weekend share means anything.
|
||||
const MIN_MEDIUM_ROWS: usize = 5;
|
||||
/// Minimum lift over the user's own weekend share.
|
||||
const MIN_MEDIUM_MULTIPLIER: f64 = 1.5;
|
||||
/// More than this share of rows without a medium → the medium-timing category
|
||||
/// would describe half a diary; skip it entirely.
|
||||
const MAX_MISSING_MEDIUM_SHARE: f64 = 0.3;
|
||||
/// Real-timestamped rows needed to say anything about rhythm.
|
||||
const MIN_RHYTHM_ROWS: usize = 10;
|
||||
/// More than this share of rows at exactly 00:00 → timestamps are import noise
|
||||
/// (CSV imports carry no time); skip rhythm entirely.
|
||||
const MAX_MIDNIGHT_SHARE: f64 = 0.5;
|
||||
/// Insights shown per category — more than this and the page is a wall.
|
||||
const MAX_PER_CATEGORY: usize = 3;
|
||||
/// Width of the peak-hour window in the rhythm insight.
|
||||
const RHYTHM_WINDOW_HOURS: u32 = 3;
|
||||
|
||||
pub fn build_insights(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut insights = Vec::new();
|
||||
insights.extend(rating_lifts(rows));
|
||||
insights.extend(seasonalities(rows));
|
||||
insights.extend(medium_timings(rows));
|
||||
insights.extend(rhythm(rows));
|
||||
insights
|
||||
}
|
||||
|
||||
// ── Rating lift ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let first_time = first_time_rows(rows);
|
||||
if first_time.len() < MIN_BASELINE_RATINGS {
|
||||
return vec![];
|
||||
}
|
||||
let user_avg = avg_rating(&first_time);
|
||||
|
||||
let mut by_genre: HashMap<&str, Vec<u8>> = HashMap::new();
|
||||
for row in first_time {
|
||||
for genre in &row.genres {
|
||||
by_genre.entry(genre).or_default().push(row.rating);
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<Insight> = by_genre
|
||||
.into_iter()
|
||||
.filter(|(_, ratings)| ratings.len() >= MIN_GENRE_RATINGS)
|
||||
.filter_map(|(genre, ratings)| {
|
||||
let genre_avg =
|
||||
ratings.iter().map(|&r| f64::from(r)).sum::<f64>() / ratings.len() as f64;
|
||||
let delta = round2(genre_avg - user_avg);
|
||||
if delta.abs() < MIN_RATING_DELTA {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(delta.abs(), ratings.len()),
|
||||
genre,
|
||||
genre_avg,
|
||||
delta,
|
||||
ratings.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, genre_avg, delta, n)| Insight::RatingLift {
|
||||
genre: genre.to_string(),
|
||||
genre_avg: round2(genre_avg),
|
||||
user_avg: round2(user_avg),
|
||||
delta,
|
||||
sample_count: n as u32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(strongest_first);
|
||||
candidates.truncate(MAX_PER_CATEGORY);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Strongest first; ties broken by label ascending so output is deterministic.
|
||||
fn strongest_first(a: &Insight, b: &Insight) -> std::cmp::Ordering {
|
||||
strength_of(b)
|
||||
.partial_cmp(&strength_of(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| label_of(a).cmp(label_of(b)))
|
||||
}
|
||||
|
||||
/// The earliest review of each movie; later reviews of the same movie are
|
||||
/// rewatches (the same definition `ReviewHistory` uses).
|
||||
fn first_time_rows(rows: &[WatchRow]) -> Vec<&WatchRow> {
|
||||
let mut seen: HashMap<uuid::Uuid, &WatchRow> = HashMap::new();
|
||||
for row in rows {
|
||||
match seen.get(&row.movie_id) {
|
||||
Some(existing) if existing.watched_at <= row.watched_at => {}
|
||||
_ => {
|
||||
seen.insert(row.movie_id, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut first: Vec<&WatchRow> = seen.into_values().collect();
|
||||
first.sort_by_key(|r| r.watched_at);
|
||||
first
|
||||
}
|
||||
|
||||
fn avg_rating(rows: &[&WatchRow]) -> f64 {
|
||||
rows.iter().map(|r| f64::from(r.rating)).sum::<f64>() / rows.len() as f64
|
||||
}
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let mut by_genre: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
for row in rows {
|
||||
for genre in &row.genres {
|
||||
by_genre
|
||||
.entry(genre)
|
||||
.or_default()
|
||||
.push(row.watched_at.month());
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<Insight> = by_genre
|
||||
.into_iter()
|
||||
.filter(|(_, months)| months.len() >= MIN_GENRE_SEASON_ROWS)
|
||||
.filter_map(|(genre, months)| {
|
||||
let total = months.len() as f64;
|
||||
let mut histogram = [0u32; 12];
|
||||
for &m in &months {
|
||||
histogram[(m - 1) as usize] += 1;
|
||||
}
|
||||
// Smallest month number wins ties — deterministic output.
|
||||
let (peak_month, peak_count) = histogram
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(i, c)| (*c, std::cmp::Reverse(*i)))
|
||||
.map(|(i, c)| (i as u32 + 1, c))?;
|
||||
// Baseline is a uniform year: each month expects total/12 watches.
|
||||
let multiplier = round2(f64::from(peak_count) * 12.0 / total);
|
||||
if multiplier < MIN_SEASON_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, months.len()),
|
||||
genre,
|
||||
peak_month,
|
||||
multiplier,
|
||||
months.len(),
|
||||
))
|
||||
})
|
||||
.map(|(_, genre, month, multiplier, n)| Insight::Seasonality {
|
||||
genre: genre.to_string(),
|
||||
month,
|
||||
multiplier,
|
||||
sample_count: n as u32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(strongest_first);
|
||||
candidates.truncate(MAX_PER_CATEGORY);
|
||||
candidates
|
||||
}
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
if rows.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let with_medium_count = rows.iter().filter(|r| r.watch_medium.is_some()).count();
|
||||
let missing_share = 1.0 - with_medium_count as f64 / rows.len() as f64;
|
||||
if missing_share > MAX_MISSING_MEDIUM_SHARE {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// (total watches, weekend watches) per medium.
|
||||
let mut by_medium: HashMap<&str, (usize, usize)> = HashMap::new();
|
||||
let mut weekend_total = 0usize;
|
||||
for row in rows.iter().filter(|r| r.watch_medium.is_some()) {
|
||||
let weekend = matches!(row.watched_at.weekday(), Weekday::Sat | Weekday::Sun);
|
||||
weekend_total += usize::from(weekend);
|
||||
let entry = by_medium
|
||||
.entry(row.watch_medium.as_deref().expect("filtered above"))
|
||||
.or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
entry.1 += usize::from(weekend);
|
||||
}
|
||||
|
||||
let baseline_share = weekend_total as f64 / with_medium_count as f64;
|
||||
// No weekend watches at all → no baseline to lift against.
|
||||
if baseline_share == 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut candidates: Vec<Insight> = by_medium
|
||||
.into_iter()
|
||||
.filter(|(_, (count, _))| *count >= MIN_MEDIUM_ROWS)
|
||||
.filter_map(|(medium, (count, weekend))| {
|
||||
let weekend_share = weekend as f64 / count as f64;
|
||||
let multiplier = round2(weekend_share / baseline_share);
|
||||
if multiplier < MIN_MEDIUM_MULTIPLIER {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
strength(multiplier - 1.0, count),
|
||||
medium,
|
||||
weekend_share,
|
||||
multiplier,
|
||||
count,
|
||||
))
|
||||
})
|
||||
.map(
|
||||
|(_, medium, weekend_share, multiplier, n)| Insight::MediumTiming {
|
||||
medium: medium.to_string(),
|
||||
multiplier,
|
||||
weekend_share: round2(weekend_share),
|
||||
baseline_share: round2(baseline_share),
|
||||
sample_count: n as u32,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(strongest_first);
|
||||
candidates.truncate(MAX_PER_CATEGORY);
|
||||
candidates
|
||||
}
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
|
||||
let real: Vec<&WatchRow> = rows
|
||||
.iter()
|
||||
.filter(|r| !(r.watched_at.hour() == 0 && r.watched_at.minute() == 0))
|
||||
.collect();
|
||||
let midnight_share = 1.0 - real.len() as f64 / rows.len().max(1) as f64;
|
||||
if rows.is_empty() || real.len() < MIN_RHYTHM_ROWS || midnight_share > MAX_MIDNIGHT_SHARE {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut hours = [0u32; 24];
|
||||
let mut days = [0u32; 7]; // index 0 = Monday (chrono weekday number − 1)
|
||||
for row in &real {
|
||||
hours[row.watched_at.hour() as usize] += 1;
|
||||
days[row.watched_at.weekday().number_from_monday() as usize - 1] += 1;
|
||||
}
|
||||
|
||||
// Circular 3-hour window with the most watches; smallest start hour wins ties.
|
||||
let mut best_start = 0usize;
|
||||
let mut best_count = 0u32;
|
||||
for start in 0..24 {
|
||||
let count: u32 = (0..RHYTHM_WINDOW_HOURS)
|
||||
.map(|o| hours[(start + o as usize) % 24])
|
||||
.sum();
|
||||
if count > best_count {
|
||||
best_count = count;
|
||||
best_start = start;
|
||||
}
|
||||
}
|
||||
|
||||
// Days carrying at least 60% of the busiest day's load, strongest first,
|
||||
// capped at three — "mostly Tue–Thu" without the tail of noise.
|
||||
let max_day = days.iter().copied().max().unwrap_or(0);
|
||||
let mut top_days: Vec<(u32, u32)> = days
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| *c > 0 && f64::from(*c) >= f64::from(max_day) * 0.6)
|
||||
.map(|(i, c)| (i as u32 + 1, c))
|
||||
.collect();
|
||||
// Count descending, then day number ascending — deterministic.
|
||||
top_days.sort_unstable_by_key(|&(day, count)| (std::cmp::Reverse(count), day));
|
||||
let top_days: Vec<u32> = top_days.into_iter().map(|(day, _)| day).take(3).collect();
|
||||
|
||||
vec![Insight::Rhythm {
|
||||
peak_hour_start: best_start as u32,
|
||||
peak_hour_end: (best_start as u32 + RHYTHM_WINDOW_HOURS) % 24,
|
||||
top_days,
|
||||
sample_count: real.len() as u32,
|
||||
}]
|
||||
}
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Strength = effect size scaled by evidence volume. A 1.6× lift on 60 rows
|
||||
/// outranks 1.7× on 3; a raw lift would not.
|
||||
fn strength(effect: f64, sample: usize) -> f64 {
|
||||
effect * (1.0 + (sample as f64).ln())
|
||||
}
|
||||
|
||||
fn strength_of(insight: &Insight) -> f64 {
|
||||
match insight {
|
||||
Insight::RatingLift {
|
||||
delta,
|
||||
sample_count,
|
||||
..
|
||||
} => strength(delta.abs(), *sample_count as usize),
|
||||
Insight::Seasonality {
|
||||
multiplier,
|
||||
sample_count,
|
||||
..
|
||||
}
|
||||
| Insight::MediumTiming {
|
||||
multiplier,
|
||||
sample_count,
|
||||
..
|
||||
} => strength(multiplier - 1.0, *sample_count as usize),
|
||||
Insight::Rhythm { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn label_of(insight: &Insight) -> &str {
|
||||
match insight {
|
||||
Insight::RatingLift { genre, .. } | Insight::Seasonality { genre, .. } => genre,
|
||||
Insight::MediumTiming { medium, .. } => medium,
|
||||
Insight::Rhythm { .. } => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn round2(v: f64) -> f64 {
|
||||
(v * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/insights_analyzer.rs"]
|
||||
mod tests;
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod insights_analyzer;
|
||||
pub mod review_history;
|
||||
pub mod wrapup_analyzer;
|
||||
|
||||
365
crates/domain/src/services/tests/insights_analyzer.rs
Normal file
365
crates/domain/src/services/tests/insights_analyzer.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Insight, WatchRow};
|
||||
|
||||
use super::super::insights_analyzer::build_insights;
|
||||
|
||||
fn watch(genres: &[&str], rating: u8, date: NaiveDate, hour: u32) -> WatchRow {
|
||||
WatchRow {
|
||||
movie_id: Uuid::new_v4(),
|
||||
rating,
|
||||
watched_at: date.and_hms_opt(hour, 30, 0).unwrap(),
|
||||
watch_medium: Some("streaming".to_string()),
|
||||
genres: genres.iter().map(|g| g.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
||||
}
|
||||
|
||||
fn kinds(insights: &[Insight]) -> Vec<&str> {
|
||||
insights
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
Insight::RatingLift { .. } => "rating_lift",
|
||||
Insight::Seasonality { .. } => "seasonality",
|
||||
Insight::MediumTiming { .. } => "medium_timing",
|
||||
Insight::Rhythm { .. } => "rhythm",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Rating lift ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rating_lift_compares_genre_to_personal_average() {
|
||||
// 12 thrillers rated 5, 12 comedies rated 2 — user average 3.5.
|
||||
let mut rows: Vec<WatchRow> = (0..12)
|
||||
.map(|i| watch(&["Thriller"], 5, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
delta,
|
||||
sample_count,
|
||||
..
|
||||
} if genre == "Thriller" => Some((*delta, *sample_count)),
|
||||
_ => None,
|
||||
});
|
||||
let (delta, count) = thrill.expect("Thriller lift expected");
|
||||
assert!((delta - 1.5).abs() < 1e-9, "delta was {delta}");
|
||||
assert_eq!(count, 12);
|
||||
|
||||
let comedy = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift { genre, delta, .. } if genre == "Comedy" => Some(*delta),
|
||||
_ => None,
|
||||
});
|
||||
assert!((comedy.expect("Comedy lift expected") + 1.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_lift_ignores_rewatches() {
|
||||
// 10 first-time dramas rated 3 (baseline), 5 thrillers rated 5…
|
||||
let mut rows: Vec<WatchRow> = (0..10)
|
||||
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
let mut thriller_ids: Vec<uuid::Uuid> = Vec::new();
|
||||
for i in 0..5 {
|
||||
let row = watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20);
|
||||
thriller_ids.push(row.movie_id);
|
||||
rows.push(row);
|
||||
}
|
||||
// …and 3 rewatches of those same thrillers rated 1, which must not count.
|
||||
for (i, id) in thriller_ids.iter().enumerate() {
|
||||
let mut rewatch = watch(&["Thriller"], 1, date(2024, 6, 1 + i as u32), 20);
|
||||
rewatch.movie_id = *id;
|
||||
rows.push(rewatch);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let thrill = insights.iter().find_map(|i| match i {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
delta,
|
||||
sample_count,
|
||||
..
|
||||
} if genre == "Thriller" => Some((*delta, *sample_count)),
|
||||
_ => None,
|
||||
});
|
||||
let (delta, count) = thrill.expect("Thriller lift expected");
|
||||
// 15 first-time rows: user_avg = (10*3 + 5*5)/15 = 55/15 ≈ 3.67,
|
||||
// genre_avg = 5.0, delta ≈ 1.33.
|
||||
assert!((delta - 1.33).abs() < 0.01, "delta was {delta}");
|
||||
assert_eq!(count, 5, "rewatches must not join the genre sample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_lift_below_sample_threshold_is_absent() {
|
||||
let mut rows: Vec<WatchRow> = (0..10)
|
||||
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
// Only 4 thriller ratings — below MIN_GENRE_RATINGS, so no matter how
|
||||
// high they are rated, the genre must not yield an insight.
|
||||
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
assert!(
|
||||
!insights
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insight::RatingLift { genre, .. } if genre == "Thriller")),
|
||||
"a genre with 4 ratings must not yield a lift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_lift_below_delta_threshold_is_absent() {
|
||||
// 5 thrillers rated 4; dramas mix to put the personal average at 3.85,
|
||||
// so the thriller delta is 0.15 < 0.3.
|
||||
let mut rows: Vec<WatchRow> = (0..12)
|
||||
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i % 28), 20))
|
||||
.collect();
|
||||
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
|
||||
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
assert!(!kinds(&insights).contains(&"rating_lift"));
|
||||
}
|
||||
|
||||
// ── Seasonality ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn seasonality_detects_month_peak_against_uniform_baseline() {
|
||||
// 16 horrors: 10 in October across two years, 6 scattered.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..10u32 {
|
||||
let year = if i < 5 { 2023 } else { 2024 };
|
||||
rows.push(watch(&["Horror"], 4, date(year, 10, 1 + i % 27), 21));
|
||||
}
|
||||
for (m, d) in [(1u32, 3u32), (2, 7), (4, 11), (6, 15), (8, 19), (12, 21)] {
|
||||
rows.push(watch(&["Horror"], 4, date(2024, m, d), 21));
|
||||
}
|
||||
// A perfectly spread genre (2 watches per month) must NOT appear.
|
||||
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let horror = insights.iter().find_map(|i| match i {
|
||||
Insight::Seasonality {
|
||||
genre,
|
||||
month,
|
||||
multiplier,
|
||||
sample_count,
|
||||
..
|
||||
} if genre == "Horror" => Some((*month, *multiplier, *sample_count)),
|
||||
_ => None,
|
||||
});
|
||||
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
|
||||
assert_eq!(month, 10);
|
||||
// 10 of 16 watches in one month → (10/16)·12 = 7.5× the uniform baseline.
|
||||
assert!(
|
||||
(multiplier - 7.5).abs() < 1e-9,
|
||||
"multiplier was {multiplier}"
|
||||
);
|
||||
assert_eq!(count, 16);
|
||||
|
||||
assert!(
|
||||
!insights
|
||||
.iter()
|
||||
.any(|i| matches!(i, Insight::Seasonality { genre, .. } if genre == "Drama")),
|
||||
"a spread-out genre must not yield a seasonality insight"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonality_below_sample_threshold_is_absent() {
|
||||
let rows: Vec<WatchRow> = (0..14)
|
||||
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
|
||||
.collect();
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"seasonality"));
|
||||
}
|
||||
|
||||
// ── Medium timing ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn medium_timing_detects_weekend_lift() {
|
||||
let saturday = date(2024, 3, 9); // a Saturday
|
||||
assert_eq!(saturday.weekday(), Weekday::Sat);
|
||||
let friday = date(2024, 3, 8);
|
||||
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
// 6 cinema watches, 5 of them on Saturdays.
|
||||
let cinema_days = [
|
||||
saturday,
|
||||
saturday.with_day(16).unwrap(),
|
||||
saturday.with_day(23).unwrap(),
|
||||
saturday.with_day(30).unwrap(),
|
||||
date(2024, 4, 6), // next month's first Saturday
|
||||
friday,
|
||||
];
|
||||
for day in cinema_days {
|
||||
let mut row = watch(&["Drama"], 4, day, 19);
|
||||
row.watch_medium = Some("cinema".to_string());
|
||||
rows.push(row);
|
||||
}
|
||||
// 10 streaming watches on weekdays only (two Mon–Fri weeks) to keep the
|
||||
// baseline weekend share driven by cinema alone.
|
||||
for i in 0..10u32 {
|
||||
let mut row = watch(&["Drama"], 4, friday, 21);
|
||||
row.watch_medium = Some("streaming".to_string());
|
||||
row.watched_at = date(2024, 3, 4 + i % 5 + 7 * (i / 5))
|
||||
.and_hms_opt(21, 0, 0)
|
||||
.unwrap();
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let cinema = insights.iter().find_map(|i| match i {
|
||||
Insight::MediumTiming {
|
||||
medium,
|
||||
multiplier,
|
||||
sample_count,
|
||||
..
|
||||
} if medium == "cinema" => Some((*multiplier, *sample_count)),
|
||||
_ => None,
|
||||
});
|
||||
let (multiplier, count) = cinema.expect("cinema timing expected");
|
||||
// Baseline weekend share = 5/16; cinema share = 5/6 → lift = (5/6)/(5/16) ≈ 2.67.
|
||||
assert!(
|
||||
(multiplier - 2.67).abs() < 0.01,
|
||||
"multiplier was {multiplier}"
|
||||
);
|
||||
assert_eq!(count, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_timing_skipped_when_medium_mostly_missing() {
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
let first_saturday = date(2024, 3, 9);
|
||||
for i in 0..6u32 {
|
||||
let mut row = watch(
|
||||
&["Drama"],
|
||||
4,
|
||||
first_saturday + chrono::Duration::weeks(i64::from(i)),
|
||||
20,
|
||||
);
|
||||
row.watch_medium = Some("cinema".to_string());
|
||||
rows.push(row);
|
||||
}
|
||||
for i in 0..10u32 {
|
||||
let mut row = watch(&["Drama"], 4, date(2024, 3, 1 + i), 20);
|
||||
row.watch_medium = None; // imports without a medium
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"medium_timing"));
|
||||
}
|
||||
|
||||
// ── Rhythm ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn rhythm_finds_peak_window_and_days() {
|
||||
// 12 watches at 22:30: five Tuesdays, four Wednesdays, three Thursdays.
|
||||
let first_tuesday = date(2024, 3, 5);
|
||||
assert_eq!(first_tuesday.weekday(), Weekday::Tue);
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for day in 0..3u32 {
|
||||
let count = 5 - day; // Tue ×5, Wed ×4, Thu ×3
|
||||
for week in 0..count {
|
||||
rows.push(watch(
|
||||
&["Drama"],
|
||||
4,
|
||||
first_tuesday
|
||||
+ chrono::Duration::days(i64::from(day))
|
||||
+ chrono::Duration::weeks(i64::from(week)),
|
||||
22,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let rhythm = insights.iter().find_map(|i| match i {
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
sample_count,
|
||||
} => Some((
|
||||
*peak_hour_start,
|
||||
*peak_hour_end,
|
||||
top_days.clone(),
|
||||
*sample_count,
|
||||
)),
|
||||
_ => None,
|
||||
});
|
||||
let (start, end, days, count) = rhythm.expect("rhythm expected");
|
||||
// All watches sit at hour 22; the earliest 3-hour window that covers
|
||||
// them starts at 20 (20, 21, 22).
|
||||
assert_eq!(start, 20);
|
||||
assert_eq!(end, 23);
|
||||
// Every day carries ≥60% of the busiest day (5): Tue(5) Wed(4) Thu(3).
|
||||
assert_eq!(days, vec![2, 3, 4], "Tue=2, Wed=3, Thu=4");
|
||||
assert_eq!(count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rhythm_skipped_when_timestamps_are_import_noise() {
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
for i in 0..12u32 {
|
||||
// Exactly midnight — the signature of a CSV import with no time data.
|
||||
let row = WatchRow {
|
||||
movie_id: Uuid::new_v4(),
|
||||
rating: 4,
|
||||
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
|
||||
watch_medium: None,
|
||||
genres: vec!["Drama".to_string()],
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert!(!kinds(&build_insights(&rows)).contains(&"rhythm"));
|
||||
}
|
||||
|
||||
// ── Overall ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_diary_yields_nothing() {
|
||||
assert!(build_insights(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_diary_yields_nothing() {
|
||||
let rows: Vec<WatchRow> = (0..4)
|
||||
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
|
||||
.collect();
|
||||
assert!(build_insights(&rows).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_three_insights_per_category() {
|
||||
// Six genres, all with a strong rating lift — only the three strongest survive.
|
||||
let mut rows: Vec<WatchRow> = Vec::new();
|
||||
let genres = [
|
||||
("Thriller", 5u8),
|
||||
("Horror", 5),
|
||||
("Sci-Fi", 5),
|
||||
("Comedy", 5),
|
||||
("Romance", 5),
|
||||
("Documentary", 5),
|
||||
];
|
||||
for (genre, rating) in genres {
|
||||
rows.extend((0..5).map(|i| watch(&[genre], rating, date(2024, 1, 1 + i), 20)));
|
||||
}
|
||||
// Baseline dramas rated 1 make every lift large.
|
||||
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
|
||||
|
||||
let insights = build_insights(&rows);
|
||||
let rating_lifts = insights
|
||||
.iter()
|
||||
.filter(|i| matches!(i, Insight::RatingLift { .. }))
|
||||
.count();
|
||||
assert_eq!(rating_lifts, 3);
|
||||
}
|
||||
@@ -103,9 +103,6 @@ impl FakeDiaryQuery {
|
||||
.insert(movie.id().value(), (movie, reviews));
|
||||
}
|
||||
|
||||
/// Configures what `query_diary` returns. Without this, `query_diary` keeps
|
||||
/// its original always-empty behavior — this is purely opt-in for tests that
|
||||
/// need to prove entries actually flow through a use case.
|
||||
pub fn set_diary_page(&self, page: Paginated<DiaryEntry>) {
|
||||
*self.diary_page.lock().unwrap() = Some(page);
|
||||
}
|
||||
|
||||
@@ -347,9 +347,6 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
|
||||
|
||||
// ── InMemoryRemoteWatchlistRepository ─────────────────────────────────────────
|
||||
|
||||
/// Unlike `NoopRemoteWatchlistRepository`, this actually stores what it's given —
|
||||
/// needed by tests that must prove a federated-watchlist read returns real seeded
|
||||
/// data, not just an empty default that happens to match by coincidence.
|
||||
pub struct InMemoryRemoteWatchlistRepository {
|
||||
store: Mutex<Vec<RemoteWatchlistEntry>>,
|
||||
}
|
||||
@@ -405,10 +402,6 @@ impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Real adapters derive `uuid` from the actor URL via a SQL-side hash — not
|
||||
/// domain logic, so this fake doesn't replicate it. It simply returns
|
||||
/// everything seeded, which is all callers in this workspace need: tests seed
|
||||
/// exactly the entries for the owner under test.
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
|
||||
52
crates/domain/src/testing/insights.rs
Normal file
52
crates/domain/src/testing/insights.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError, models::insights::WatchRow, ports::InsightsQuery, value_objects::UserId,
|
||||
};
|
||||
|
||||
// ── PanicInsightsQuery ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct PanicInsightsQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl InsightsQuery for PanicInsightsQuery {
|
||||
async fn get_watch_rows(&self, _: &UserId) -> Result<Vec<WatchRow>, DomainError> {
|
||||
panic!("PanicInsightsQuery called")
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryInsightsQuery ───────────────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryInsightsQuery {
|
||||
pub rows_by_user: Mutex<HashMap<uuid::Uuid, Vec<WatchRow>>>,
|
||||
}
|
||||
|
||||
impl InMemoryInsightsQuery {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
rows_by_user: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_rows(user_id: uuid::Uuid, rows: Vec<WatchRow>) -> Arc<Self> {
|
||||
let query = Self::new();
|
||||
query.rows_by_user.lock().unwrap().insert(user_id, rows);
|
||||
query
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InsightsQuery for InMemoryInsightsQuery {
|
||||
async fn get_watch_rows(&self, user_id: &UserId) -> Result<Vec<WatchRow>, DomainError> {
|
||||
Ok(self
|
||||
.rows_by_user
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&user_id.value())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
mod fakes;
|
||||
mod in_memory;
|
||||
mod insights;
|
||||
mod noops;
|
||||
mod panics;
|
||||
mod wrapup;
|
||||
|
||||
pub use fakes::*;
|
||||
pub use in_memory::*;
|
||||
pub use insights::*;
|
||||
pub use noops::*;
|
||||
pub use panics::*;
|
||||
pub use wrapup::*;
|
||||
|
||||
@@ -22,9 +22,6 @@ macro_rules! uuid_id {
|
||||
uuid_id!(MovieId);
|
||||
|
||||
impl MovieId {
|
||||
/// Derives a stable, deterministic UUID from an external metadata ID (e.g. `tmdb:12345`).
|
||||
/// All instances that know a movie by the same external ID will produce the same MovieId,
|
||||
/// enabling remote and local reviews to be linked to the same movie record.
|
||||
pub fn from_external(external_id: &crate::value_objects::ExternalMetadataId) -> Self {
|
||||
Self(Uuid::new_v5(
|
||||
&Uuid::NAMESPACE_URL,
|
||||
@@ -45,8 +42,6 @@ uuid_id!(GoalId);
|
||||
uuid_id!(PersonId);
|
||||
|
||||
impl PersonId {
|
||||
/// Deterministic UUIDv5 from an external person ID string.
|
||||
/// "tmdb:12345" always maps to the same PersonId.
|
||||
pub fn from_external(external_id: &crate::models::person::ExternalPersonId) -> Self {
|
||||
Self(Uuid::new_v5(
|
||||
&Uuid::NAMESPACE_URL,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use super::{SocialIdentity, UserId};
|
||||
|
||||
/// This instance's own identity on the network. Owns every URL and handle
|
||||
/// derivation that used to be spelled with a bare `base_url: &str`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstanceIdentity {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl InstanceIdentity {
|
||||
/// Normalizes away a trailing slash so every derived URL has exactly one.
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
@@ -19,7 +16,6 @@ impl InstanceIdentity {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
/// Host and port, as it appears in a fediverse handle.
|
||||
pub fn host(&self) -> &str {
|
||||
self.base_url
|
||||
.split("://")
|
||||
@@ -47,7 +43,6 @@ impl InstanceIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `actor_url_of`: decides whether an actor URL is ours.
|
||||
pub fn identify(&self, actor_url: &str) -> SocialIdentity {
|
||||
let prefix = format!("{}/users/", self.base_url);
|
||||
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
|
||||
|
||||
@@ -37,8 +37,6 @@ pub struct SocialActor {
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Both directions of the follow edge between a viewer and a target.
|
||||
/// `None` means no edge at all; `Some(Pending)` means requested but not accepted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub struct FollowRelation {
|
||||
pub following: Option<FollowStatus>,
|
||||
|
||||
@@ -25,8 +25,6 @@ impl Username {
|
||||
const MIN_LENGTH: usize = 2;
|
||||
const MAX_LENGTH: usize = 30;
|
||||
|
||||
/// Accepts 2–30 chars: lowercase letters, digits, underscores, hyphens.
|
||||
/// Lowercases input automatically.
|
||||
pub fn new(raw: String) -> Result<Self, DomainError> {
|
||||
let s = raw.trim().to_lowercase();
|
||||
if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH {
|
||||
|
||||
Reference in New Issue
Block a user