Files
movies-diary/docs/adr/0010-conservative-live-insights.md
Gabriel Kaszewski e303ae7f08
Some checks failed
CI / Check / Test (push) Has been cancelled
insights-correlations (#22)
Reviewed-on: #22
2026-10-12 18:56:55 +00:00

63 lines
4.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Insights are live, conservative, and computed by a pure analyzer over denormalized rows
Insights (a new always-on surface, distinct from WrapUp and from plain
`UserTrends`) analyse a user's diary for behavioural patterns — rating lift by
genre, genre seasonality, medium timing, watch-time rhythm. Every insight
compares the user against their own baseline rather than reporting a ranking.
## Decision
- **Live, not precomputed.** Unlike WrapUp (async worker, stored
`report_json`), Insights are computed on every request. The underlying query
is one join over `reviews` + `movies` + genus tables, the same cost class as
existing `StatsRepository` aggregates; a materialization layer is only earned
if diaries grow provably too large for that.
- **Honesty by suppression, not disclaimers.** Insights are conservative: any
candidate below sample-size minimums (≥3–5 ratings per genre for rating
lift, ≥15–20 rows for seasonality, ≥5 rows per medium bucket) or strength
thresholds (+0.3 stars, 1.5×) is silently not shown, and whole categories
collapse when their inputs are unreliable (>~30% `NULL` `watch_medium`;
>50% of rows at exactly `00:00`, which betrays imported CSVs that carry no
real time). An empty-insights page is the correct rendering of a small or
noisy diary — the UI shows an empty state, not filler. This is deliberate;
do not "fix" it by lowering thresholds or adding hedges.
- **Fetch rows, then analyze in the domain.** Rejected the `StatsRepository`
pattern (aggregation in adapter SQL) as the primary carrier of insight logic:
thresholds and baseline comparisons would hide inside SQL, untestable and
duplicated per database. Instead the port
`InsightsQuery::get_watch_rows() -> Vec<WatchRow>` fetches one denormalized
row per review (genres, medium, watched time), analogous to
`WrapUpStatsQuery::get_reviews_with_profiles` but without WrapUp's report-era
fields. A pure analyzer `insights_analyzer::build_insights(rows) -> Vec<Insight>`
owns every threshold and comparison, unit-tested without a database. Rewatch
detection also lives in the analyzer — grouping rows by movie and keeping the
earliest, exactly like `wrapup_analyzer::compute_rewatch_stats` — so there is
one rewatch definition per analyzer and the adapters stay shapeless
"rows in, rows out". **Rating lift uses first-time entries only** (rewatches
inflate genre ratings by choice, not by property), while seasonality and
timing count every entry, rewatch or not.
- **Multi-genre rows are counted per genre, unnormalized.** A review of a
movie with three genres contributes to all three genre distributions. Genre
insights are frequency comparisons, not probabilities over reviews.
- **Private surface for now.** Insights render only on the user's own page
(`/insights`, own data). They expose habits (late-night watching, mediums),
which are more sensitive than the taste statistics already public on
profiles; sharing is an opt-in feature to design later, not a default.
## Considered Options
- **SQL aggregation in adapters (stats pattern)** — rejected for the primary
logic path: threshold rules would fragment into per-database SQL and resist
unit testing; SQL still wins where it is just "rows in, rows out".
- **Insights as WrapUp sections** — rejected: WrapUp is a dated snapshot,
insights decay/shift with every logged review and belong to a live surface;
whether to feed selected insights into future WrapUps is a later product
decision.
- **Rewatch detection in adapter SQL** — rejected: it would fork the rewatch
definition already applied by `wrapup_analyzer::compute_rewatch_stats`; the
analyzer groups by movie and keeps the earliest review, same as WrapUp.
- **Generic `Insight { title, description }` text payload** — rejected: typed
serde-tagged variants (`RatingLift`, `Seasonality`, `MediumTiming`, `Rhythm`,
each carrying `sample_count`) let the SPA render data-backed cards, show
"why nothing here yet", and stay stable across copy changes.