From ee4d1a9a0561d908442ead02eccdb3ef9f0935f1 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Thu, 16 Jul 2026 22:35:47 +0200 Subject: [PATCH 1/8] docs(memories): Tier 2 people_together spec (pair co-occurrence memory) --- ...memory-types-tier2-people-together-spec.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md diff --git a/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md b/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md new file mode 100644 index 0000000000000..a1e1c13f19c2f --- /dev/null +++ b/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md @@ -0,0 +1,234 @@ +# Tier 2 Memory Type: `people_together` ("Anna & Ben") + +> Spec for the first Tier 2 memory type from the +> [memory types roadmap](./2026-07-15-memory-types-roadmap.md) (#5, "You & [person]"). +> Created 2026-07-16. Status: **spec — not yet implemented**. + +## Summary + +A new `MemoryRule` that surfaces **a pair of people (or pets) who were photographed +together a lot in the current calendar month of a past year**. Titled `"Anna & Ben"`, +subtitled `"18 photos together · June 2023"`. + +It reuses the shipped rule-engine spine wholesale. The only genuinely new code is **one +repository query** (faces for a period) and **one pure helper** (`pairCounts`); everything +else — admin/user toggles, `availableMemoryTypes`, dedup, the daily limit, the visibility +filter — derives automatically from registering the type, exactly as the four Tier 1 types +did. + +## Decisions (resolved during brainstorming) + +| Question | Decision | Rationale | +| --------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Who is it about? | **A pair** of named subjects (`A & B`), not "You & X" | The schema has no reliable "which person is the account owner" mapping, so a literal "You" would need a fragile heuristic. A pair needs no self-identification. | +| What triggers it? | **Date-anchored**: current calendar month in a past year (like `month_recap`) | A "these two are often together" fact is timeless, but the engine runs daily into dated memory slots. Anchoring to a past month keeps the surface fresh (rotates monthly), makes `memoryAt` meaningful, and makes dedup trivial. | +| Qualifying threshold | **≥ 6 co-occurring photos** in the month-year **and ≥ 2 distinct days** | Matches `on_this_day_place`'s appetite (it uses 4 assets); the distinct-days guard stops a single event from qualifying a "relationship". | +| Pets eligible? | **Yes** — no `person.type` restriction | Allows `Anna & Rex` (person–pet). Consequence: `Rex & Whiskers` (pet–pet) pairs are also possible; accepted. No special "at least one human" logic. | +| Co-occurrence meaning | Both subjects appear in the **same asset** | Matches the intuitive "photos containing both people". | + +## Data model grounding + +- `person`: `id`, `ownerId`, `name`, `isHidden`, `type` (`'person' | 'pet'`), `thumbnailPath`, … +- `asset_face`: `id`, `assetId`, `personId`, `deletedAt`, `isVisible`, `sourceType`, … +- Named face join (from `person.repository.ts`): `asset_face.personId = person.id`, filtered + `asset_face.deletedAt is null` and `asset_face.isVisible = true`. +- "Named, real subject" filter (from `getBirthdaysForDay` / `getAllForUser`): `person.name != ''`, + `person.isHidden = false`. We deliberately **drop** the `type = 'person'` clause so pets qualify. + +## New repository query — `getMemoryFacesForPeriod` + +`AssetRepository.getMemoryFacesForPeriod(ownerId, { months, takenBefore }): Promise` + +Same spine as the existing `getMemoryAssetsForPeriod`, but joined through `asset_face` → +`person`, returning **one row per (asset, named subject)**: + +```ts +export interface MemoryPeriodFace { + assetId: string; + localDateTime: Date; + year: number; // extract(year from localDateTime at UTC)::int + personId: string; + personName: string; +} +``` + +Filters (all `AND`): + +- `asset.ownerId = ownerId` +- `asset.visibility = Timeline` +- `asset.deletedAt is null` +- `asset.localDateTime <= takenBefore` +- `extract(month from localDateTime at UTC) in (months)` +- `exists (asset_file preview)` — same preview guard as `getMemoryAssetsForPeriod` +- `asset_face.deletedAt is null` +- `asset_face.isVisible = true` +- `person.ownerId = ownerId` +- `person.name != ''` +- `person.isHidden = false` +- (no `person.type` filter — pets included) + +Order by `asset.localDateTime asc`. Decorated with `@GenerateSql` and covered by a medium +(real-DB) test, since it's the one piece unit tests can't fully exercise. + +**Why flat rows, not a SQL self-join:** the codebase grain is "one moderate query returns +rows, TS does the curation" (`on_this_day_place` fetches a period and clusters with a util). +Flat face rows keep the SQL simple/testable and let the pairing logic live in a pure, +trivially-unit-testable helper. The query returns every past year's copy of the month (not +just the ≤ 2 we ultimately emit); pair enumeration is `O(assets × facesPerAsset²)` per year — +negligible for a personal library's month-sized slices, even across many years. + +## New curation helper — `pairCounts` + +Added to `curation.util.ts` (pure, dependency-free, unit-tested like the others): + +```ts +// Structural input — decoupled from the repository type so curation.util stays +// dependency-free (any {assetId, personId, personName, localDateTime} row works). +export interface FaceRow { + assetId: string; + personId: string; + personName: string; + localDateTime: Date; +} + +export interface PairStat { + a: { id: string; name: string }; // ordered so a.id < b.id (stable, order-independent) + b: { id: string; name: string }; + assets: { id: string; localDateTime: Date }[]; // assets containing BOTH, chronological + distinctDays: number; // count of distinct calendar days among those assets +} + +// Given face rows for a single year-bucket, return every co-occurring pair with its +// shared assets and distinct-day count, sorted by shared-asset count desc (deterministic +// tie-break on the ordered id pair). +export const pairCounts = (rows: FaceRow[]): PairStat[]; +``` + +Algorithm: group rows by `assetId` → each asset's **set** of `{personId, name}` (dedupe a +person appearing on the same asset twice via multiple faces); for every unordered pair within +an asset's set, accumulate the asset (`{id, localDateTime}`). Derive `distinctDays` from the +UTC calendar days of those assets. Emit `PairStat[]` sorted by `assets.length` desc, tie-broken +by `` `${a.id}:${b.id}` ``. Carrying the timed assets (not just ids) lets the rule feed +`sampleAssetsByTime` directly — no second lookup. + +## Rule — `people-together.rule.ts` + +``` +id = 'people_together' +MIN_ASSETS = 6 +MIN_DISTINCT_DAYS = 2 +MAX_YEARS = 2 // at most 2 candidates per run, strongest first +ASSET_CAP = 8 + +evaluate({ ownerId, target }): + rows = getMemoryFacesForPeriod(ownerId, { + months: [target.month], + takenBefore: target.endOf('day').toJSDate(), + }) + + byYear = group rows where row.year < target.year, keyed by row.year + + candidates = [] + for [year, yearRows] of byYear: + top = pairCounts(yearRows)[0] // strongest pair that year + if !top: continue + if top.assets.length < MIN_ASSETS: continue + if top.distinctDays < MIN_DISTINCT_DAYS: continue + + mm = zero-padded target.month + count = top.assets.length + candidates.push({ + ruleId: id, + dedupeKey: `people_together:${top.a.id}:${top.b.id}:${year}-${mm}`, + title: `${top.a.name} & ${top.b.name}`, // names ordered by the a.id new PeopleTogetherMemoryRule(deps.assetRepository)`. +3. **`web/src/routes/admin/system-settings/MemoriesSettings.svelte`** — add `'people_together'` + to the hardcoded `memoryTypeKeys` array. +4. **`i18n/en.json`** — 4 keys (others fall back to en): + - `memory_type_people_together_setting`: `"People together memories"` + - `memory_type_people_together_setting_description`: `"Generate memories of two people or pets often photographed together in a past year."` + - `memory_type_people_together`: `"People together"` + - `memory_type_people_together_description`: `"Two people or pets often photographed together in a past year."` + +(Exact copy is a knob; finalize during implementation.) + +## Test plan (TDD, mirroring Tier 1) + +Ordered slices, each red→green: + +1. **`pairCounts` util spec** — single asset with 2 subjects → one pair; 3 subjects → 3 pairs; + a subject with two faces on one asset counted once; distinct-day counting; deterministic sort + & id-ordered pairs; empty input → `[]`. +2. **Rule spec** (`people-together.rule.spec.ts`, mocked repo): + - below `MIN_ASSETS` → no candidate + - `MIN_ASSETS` met but all on one day (`distinctDays < 2`) → no candidate + - clean qualifying pair → title/subtitle/dedupeKey/score/memoryAt/`visibleForDays: 1` + - two competing pairs same year → the higher-count pair wins that year + - multi-year → sorted by score, capped at `MAX_YEARS` + - current/future-year rows (`year >= target.year`) ignored + - a pet pair and a person–pet pair both qualify (pets included) + - `dedupeKey` identical regardless of input row order (id-ordering) + - `assetIds` capped at `ASSET_CAP` and chronologically sampled +3. **Repository medium test** — `getMemoryFacesForPeriod` honors visibility/deleted/preview, + `asset_face.isVisible`/`deletedAt`, `person.isHidden`, `person.name != ''`, and **includes + pets**; excludes other owners. +4. **Metadata/registry expectation updates** — `people_together` present, default-enabled, + admin-configurable; `availableMemoryTypes` includes it. +5. **Admin settings spec** (`MemoriesSettings.spec.ts`) — the new toggle renders with a real + (non-blank) label/description. + +## Edge cases & determinism + +- **Groups (3+ always together):** only the top _pair_ surfaces. Group memories are out of scope. +- **Same person, multiple faces on one asset:** deduped to one subject per asset in `pairCounts`. +- **Ties:** `pairCounts` and the candidate sort both tie-break deterministically (ordered id + pair / score then implicit order), so repeated runs are stable — same discipline as `dominantBy`. +- **Overlap with `birthday`:** different trigger (birthday is a person's birth date; this is a + past month's co-occurrence), different `ruleId`/`dedupeKey` — they can co-exist and dedup + independently. + +## Out of scope (YAGNI) + +- "You & X" owner identification. +- Group (3+) memories. +- Localizing memory **content** (titles/subtitles stay English server-side, like all rules). +- Cross-owner / shared-space people (rules operate on `ownerId`'s own people, as today). +- Tuning `RULE_DAILY_LIMIT` (unchanged at 2). + +## Roadmap bookkeeping + +On landing, mark #5 **Shipped — `people_together`** in +[`2026-07-15-memory-types-roadmap.md`](./2026-07-15-memory-types-roadmap.md) and link this spec, +mirroring the Tier 1 row treatment. From dfb39962e98f5151acfb25378d6f70ca8f0864f4 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Thu, 16 Jul 2026 22:54:41 +0200 Subject: [PATCH 2/8] =?UTF-8?q?docs(memories):=20harden=20people=5Ftogethe?= =?UTF-8?q?r=20spec=20=E2=80=94=20TDD/BDD,=20full=20edge=20coverage,=20/im?= =?UTF-8?q?pl-loop=20slices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...memory-types-tier2-people-together-spec.md | 589 +++++++++++++----- 1 file changed, 421 insertions(+), 168 deletions(-) diff --git a/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md b/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md index a1e1c13f19c2f..3de6d7121ff76 100644 --- a/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md +++ b/docs/plans/2026-07-16-memory-types-tier2-people-together-spec.md @@ -1,89 +1,174 @@ -# Tier 2 Memory Type: `people_together` ("Anna & Ben") +# Tier 2 Memory Type: `people_together` — Design & Test Spec + +> Implements the first 🟡 Tier-2 rule from the +> [memory types roadmap](./2026-07-15-memory-types-roadmap.md) (#5, "You & [person]"), +> reframed to a **pair** memory: two people (or pets) often photographed together in a past +> year's copy of the current month. +> Approach: **test-driven, behavior-driven, full edge-case coverage.** +> Created 2026-07-16. Status: **spec — not yet implemented.** + +## 1. Goal & non-goals + +**Goal:** add one low-risk `MemoryRule` (`people_together`) that surfaces a pair of named +subjects who co-occur in many photos of the current calendar month in a past year — titled +`"Anna & Ben"`, subtitled `"18 photos together · June 2023"`. It reuses the shipped rule +engine, adds **one** new repository query and **one** pure curation helper, and needs **no +engine/service change** (the `visibleForDays` machinery it uses already shipped in Tier 1). + +**Non-goals (this slice):** + +- No "You & X" owner identification — the schema has no reliable account-owner→person mapping, + so we do a **pair** (A & B), which needs no self-identification. +- No group (3+) memories — only the strongest **pair** per year surfaces. +- No ML, embeddings, tags, camera/gear grouping (later tiers). +- No localization of memory _content_ (titles/subtitles stay English, matching every existing + rule). Settings _labels_ are localized. +- No change to the memory _viewer_ (web/mobile render rule memories generically). +- No change to `memory.service.ts`, `RULE_DAILY_LIMIT`, the per-day multi-day cap, or the + generation/cleanup scheduling. `people_together` is a pure function of `(ownerId, target, +query rows)` and plugs into the existing machinery. + +## 2. Design decisions (please confirm on review) + +Each has a recommended default; flag any you want changed and I'll revise before implementation. + +| ID | Decision | Chosen default | Alternative | +| --- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| D1 | **Who is it about** | A **pair** of named subjects (`A & B`) | "You & X" (needs a fragile "which person is me" heuristic — rejected) | +| D2 | **Trigger cadence + window** | Date-anchored to **this calendar month in a past year**; fires on **day 20**, stays **visible 7 days** (`visibleForDays: 7`) | Daily fire (breaks — see below); or first-together anniversary; or the 1st | +| D3 | **Qualifying threshold** | **≥ 6 co-occurring photos** in the month-year **and ≥ 2 distinct days** | 10/3 (stricter, rarer) or 4/2 (looser, weaker "together" story) | +| D4 | **Pets eligible** | **Yes** — no `person.type` filter; allows `Anna & Rex` **and** `Rex & Whiskers` (pet–pet) | Exclude pets; or require ≥ 1 human in the pair | +| D5 | **Query approach** | One flat query returns **face rows**; pairing/curation happens in **TS** (`pairCounts`) | SQL self-join returning pre-aggregated pairs | +| D6 | **Title name order** | Ordered by **person id** (`a.id < b.id`) so title/`dedupeKey`/`context` agree and reruns are stable | Alphabetical by name (ambiguous — two people can share a name) | +| D7 | **Default enabled** | `defaultEnabled: true`, `adminConfigurable: true` | Ship OFF by default (more conservative) | +| D8 | **Scoring** | `100 + count*3 + recencyBonus(year, target.year)` — same family as `on_this_day_place`, so it competes fairly for the 2 daily slots | Any other numbers | + +**Why a trigger day + window, not daily fire (D2 rationale — this is the subtle one):** the +`dedupeKey` is **month-level** (`people_together:a:b:2023-06`) because the memory is about the +whole month, not a single day (contrast `on_this_day_place`, whose key includes the day and +which legitimately regenerates daily). If the rule fired every day with `visibleForDays: 1`, +`hasRuleMemory` would let it insert on the first day it won a slot and then **block it for the +rest of the month** — the memory would flash for one arbitrary day and vanish. The shipped +recaps avoid this by firing on a **specific day with a multi-day window** (`month_recap` +day 1 / 7d, `favorites_throwback` day 15 / 7d). `people_together` joins that stagger on +**day 20 / 7d** — a free day so it never collides with `month_recap`'s single day-1 shot (the +per-day multi-day cap in `memory.service` allows only one multi-day rule to insert per day, so +sharing a trigger day would let the higher-scored rule starve the other, which only fires that +one day). Day 20 is a tunable constant, not load-bearing. + +## 3. Architecture -> Spec for the first Tier 2 memory type from the -> [memory types roadmap](./2026-07-15-memory-types-roadmap.md) (#5, "You & [person]"). -> Created 2026-07-16. Status: **spec — not yet implemented**. +``` +memory.service.ts (UNCHANGED — already honors visibleForDays and the per-day multi-day cap) + └─ createRuleMemories → rule.evaluate({ ownerId, target }) + └─ PeopleTogetherMemoryRule (id "people_together") + → assetRepository.getMemoryFacesForPeriod(ownerId, { months, takenBefore }) [NEW] + → curation.util: pairCounts (NEW), sampleAssetsByTime, medianTime, recencyBonus, monthName +``` + +The rule constructor takes only `Pick` (like +`on_this_day_place` takes `Pick<…, 'getMemoryAssetsForPeriod'>`), keeping unit tests trivial to +mock. `hasRuleMemory(ownerId, ruleId, dedupeKey)` in the service already guarantees a given +(pair, year-month) memory inserts at most once, so the rule needs no `memoryRepository`. -## Summary +### 3.1 New/changed files -A new `MemoryRule` that surfaces **a pair of people (or pets) who were photographed -together a lot in the current calendar month of a past year**. Titled `"Anna & Ben"`, -subtitled `"18 photos together · June 2023"`. +**Server — source** -It reuses the shipped rule-engine spine wholesale. The only genuinely new code is **one -repository query** (faces for a period) and **one pure helper** (`pairCounts`); everything -else — admin/user toggles, `availableMemoryTypes`, dedup, the daily limit, the visibility -filter — derives automatically from registering the type, exactly as the four Tier 1 types -did. +| File | Change | +| --------------------------------------------------- | ------------------------------------------------------------------------- | +| `src/repositories/asset.repository.ts` | Add `getMemoryFacesForPeriod` + `MemoryPeriodFace` interface | +| `src/services/memory-rules/curation.util.ts` | Add `pairCounts` + `FaceRow`/`PairStat` types (join the existing helpers) | +| `src/services/memory-rules/people-together.rule.ts` | New rule | +| `src/services/memory-rules/memory-type.metadata.ts` | Add 1 `MEMORY_TYPE_METADATA` entry (`people_together`) | +| `src/services/memory-rules/memory-type.registry.ts` | Import rule; add 1 `RULE_FACTORIES` entry | -## Decisions (resolved during brainstorming) +**Server — tests** -| Question | Decision | Rationale | -| --------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Who is it about? | **A pair** of named subjects (`A & B`), not "You & X" | The schema has no reliable "which person is the account owner" mapping, so a literal "You" would need a fragile heuristic. A pair needs no self-identification. | -| What triggers it? | **Date-anchored**: current calendar month in a past year (like `month_recap`) | A "these two are often together" fact is timeless, but the engine runs daily into dated memory slots. Anchoring to a past month keeps the surface fresh (rotates monthly), makes `memoryAt` meaningful, and makes dedup trivial. | -| Qualifying threshold | **≥ 6 co-occurring photos** in the month-year **and ≥ 2 distinct days** | Matches `on_this_day_place`'s appetite (it uses 4 assets); the distinct-days guard stops a single event from qualifying a "relationship". | -| Pets eligible? | **Yes** — no `person.type` restriction | Allows `Anna & Rex` (person–pet). Consequence: `Rex & Whiskers` (pet–pet) pairs are also possible; accepted. No special "at least one human" logic. | -| Co-occurrence meaning | Both subjects appear in the **same asset** | Matches the intuitive "photos containing both people". | +| File | Change | +| --------------------------------------------------------- | --------------------------------------------------------------------------- | +| `.../people-together.rule.spec.ts` | New (unit, BDD) | +| `.../curation.util.spec.ts` | Extend: `pairCounts` cases (§6.2) | +| `.../memory-type.metadata.spec.ts` | Extend: assert `people_together` key, defaults, `getMemoryTypeKeyForMemory` | +| `.../memory-type.registry.spec.ts` | Extend: factory builds `PeopleTogetherMemoryRule` for the key | +| `test/repositories/asset.repository.mock.ts` | Add `getMemoryFacesForPeriod: vitest.fn()` | +| `test/medium/specs/repositories/asset.repository.spec.ts` | New `describe('getMemoryFacesForPeriod')` medium test (real DB, §6.5) | +| `test/medium/specs/services/memory.service.spec.ts`\* | Add one end-to-end generation test for `people_together` (§6.6) | -## Data model grounding +\* same medium spec that holds the Tier 1 `month_recap`/`on_this_day_place` generation tests; +match its existing shape. -- `person`: `id`, `ownerId`, `name`, `isHidden`, `type` (`'person' | 'pet'`), `thumbnailPath`, … -- `asset_face`: `id`, `assetId`, `personId`, `deletedAt`, `isVisible`, `sourceType`, … -- Named face join (from `person.repository.ts`): `asset_face.personId = person.id`, filtered - `asset_face.deletedAt is null` and `asset_face.isVisible = true`. -- "Named, real subject" filter (from `getBirthdaysForDay` / `getAllForUser`): `person.name != ''`, - `person.isHidden = false`. We deliberately **drop** the `type = 'person'` clause so pets qualify. +**Web** -## New repository query — `getMemoryFacesForPeriod` +| File | Change | +| ---------------------------------------------------------- | --------------------------------------------------------------- | +| `src/routes/admin/system-settings/MemoriesSettings.svelte` | Add `'people_together'` to the hardcoded `memoryTypeKeys` array | +| `i18n/en.json` | 4 new keys (admin label+desc, user label+desc) | -`AssetRepository.getMemoryFacesForPeriod(ownerId, { months, takenBefore }): Promise` +**Verify (no code expected):** mobile memory-type settings enumeration. Tier 1 concluded +mobile reads `availableMemoryTypes` and needs no per-type edit; re-confirm and record N/A, or +add the keys if that changed. Captured in §8. -Same spine as the existing `getMemoryAssetsForPeriod`, but joined through `asset_face` → -`person`, returning **one row per (asset, named subject)**: +## 4. New repository query — `getMemoryFacesForPeriod` ```ts export interface MemoryPeriodFace { assetId: string; - localDateTime: Date; - year: number; // extract(year from localDateTime at UTC)::int + localDateTime: Date; // interpreted at UTC, matching getMemoryAssetsForPeriod + year: number; // EXTRACT(year FROM localDateTime AT TIME ZONE 'UTC')::int personId: string; personName: string; } -``` -Filters (all `AND`): - -- `asset.ownerId = ownerId` -- `asset.visibility = Timeline` -- `asset.deletedAt is null` -- `asset.localDateTime <= takenBefore` -- `extract(month from localDateTime at UTC) in (months)` -- `exists (asset_file preview)` — same preview guard as `getMemoryAssetsForPeriod` -- `asset_face.deletedAt is null` -- `asset_face.isVisible = true` -- `person.ownerId = ownerId` -- `person.name != ''` -- `person.isHidden = false` -- (no `person.type` filter — pets included) - -Order by `asset.localDateTime asc`. Decorated with `@GenerateSql` and covered by a medium -(real-DB) test, since it's the one piece unit tests can't fully exercise. - -**Why flat rows, not a SQL self-join:** the codebase grain is "one moderate query returns -rows, TS does the curation" (`on_this_day_place` fetches a period and clusters with a util). -Flat face rows keep the SQL simple/testable and let the pairing logic live in a pure, -trivially-unit-testable helper. The query returns every past year's copy of the month (not -just the ≤ 2 we ultimately emit); pair enumeration is `O(assets × facesPerAsset²)` per year — -negligible for a personal library's month-sized slices, even across many years. - -## New curation helper — `pairCounts` +// asset.repository.ts +getMemoryFacesForPeriod( + ownerId: string, + options: { + months: number[]; // 1..12, calendar months to include + takenBefore: Date; // exclude current-day/future assets + }, +): Promise +``` -Added to `curation.util.ts` (pure, dependency-free, unit-tested like the others): +**Query shape** (same spine as `getMemoryAssetsForPeriod`, joined through faces → people; +one row per (asset, named subject)): + +- `asset` `INNER JOIN asset_face ON asset_face.assetId = asset.id` + `INNER JOIN person ON person.id = asset_face.personId`. +- `asset.ownerId = ownerId`, `asset.visibility = Timeline`, `asset.deletedAt is null`. +- `EXISTS` a `Preview` `asset_file` (same guard the other memory queries use). +- `asset.localDateTime <= takenBefore`. +- `EXTRACT(MONTH FROM (localDateTime at time zone 'UTC')) = ANY(months)`. +- `asset_face.deletedAt is null`, `asset_face.isVisible = true`. +- `person.ownerId = ownerId`, `person.name != ''`, `person.isHidden = false`. +- **No `person.type` filter** — pets (`type = 'pet'`) qualify (D4). +- Select `asset.id as assetId`, `asset.localDateTime`, `year` (extracted), `person.id as +personId`, `person.name as personName`. +- Order by `asset.localDateTime` ascending (rules re-sort/sample anyway; keeps medium-test + assertions readable). +- **No flat total `LIMIT`.** The rule derives per-year, per-pair counts from these rows to test + thresholds (`>= 6`); a total cap ordered by time could silently drop whole year-groups and + corrupt those counts. The slice is naturally bounded (one month, one owner) and runs in a + background job. If a pathological library ever makes row count a problem, add a **per-year** + lateral cap (like `getByDayOfYear`), never a flat total cap — noted in §8. +- Decorated with `@GenerateSql` (so `make sql` snapshots it) using `DummyValue`s. + +The rule passes `takenBefore = target.endOf('day')` and **drops `year >= target.year`** in TS +(prior years only). `takenBefore` is a defensive guard against future-dated assets; the +year-drop is what excludes the current year. + +## 5. Rule behavior + +Shared conventions: `ruleId === metadata key === 'people_together'`. All constants below are the +values the tests pin; treat them as the spec's contract. + +### 5.1 Curation helper — `pairCounts` (`curation.util.ts`) + +Pure, dependency-free, unit-tested like the existing helpers. ```ts -// Structural input — decoupled from the repository type so curation.util stays -// dependency-free (any {assetId, personId, personName, localDateTime} row works). +// Structural input, decoupled from the repository type so curation.util stays +// dependency-free (MemoryPeriodFace is assignable to it). export interface FaceRow { assetId: string; personId: string; @@ -95,140 +180,308 @@ export interface PairStat { a: { id: string; name: string }; // ordered so a.id < b.id (stable, order-independent) b: { id: string; name: string }; assets: { id: string; localDateTime: Date }[]; // assets containing BOTH, chronological - distinctDays: number; // count of distinct calendar days among those assets + distinctDays: number; // count of distinct UTC calendar days among those assets } -// Given face rows for a single year-bucket, return every co-occurring pair with its -// shared assets and distinct-day count, sorted by shared-asset count desc (deterministic -// tie-break on the ordered id pair). export const pairCounts = (rows: FaceRow[]): PairStat[]; ``` -Algorithm: group rows by `assetId` → each asset's **set** of `{personId, name}` (dedupe a -person appearing on the same asset twice via multiple faces); for every unordered pair within -an asset's set, accumulate the asset (`{id, localDateTime}`). Derive `distinctDays` from the -UTC calendar days of those assets. Emit `PairStat[]` sorted by `assets.length` desc, tie-broken -by `` `${a.id}:${b.id}` ``. Carrying the timed assets (not just ids) lets the rule feed -`sampleAssetsByTime` directly — no second lookup. +**Algorithm:** -## Rule — `people-together.rule.ts` +1. Group rows by `assetId`; for each asset build the **set** of `{id, name}` subjects (a person + with two faces on one asset collapses to one entry — no self-pair, no double count). +2. For every **unordered** pair of distinct subjects within an asset's set, key it by the two + ids sorted ascending; accumulate the asset (`{id, localDateTime}`) under that pair. +3. For each pair, compute `distinctDays` = number of distinct UTC calendar days across its + assets; sort each pair's `assets` chronologically. +4. Return `PairStat[]` sorted by `assets.length` **desc**, tie-broken by the ordered id pair + (`` `${a.id}:${b.id}` `` ascending) so the result is deterministic across runs and input + orderings. + +Carrying timed assets (not just ids) lets the rule feed `sampleAssetsByTime`/`medianTime` +directly — no second lookup. + +### 5.2 `people_together` rule (`people-together.rule.ts`) ``` id = 'people_together' -MIN_ASSETS = 6 -MIN_DISTINCT_DAYS = 2 -MAX_YEARS = 2 // at most 2 candidates per run, strongest first -ASSET_CAP = 8 +TRIGGER_DAY = 20 // staggered after month_recap(1) & favorites(15) +MIN_ASSETS = 6 // co-occurring photos in the month-year +MIN_DISTINCT_DAYS = 2 // not a single event +MAX_YEARS = 2 // at most 2 candidates per run, strongest first +ASSET_CAP = 8 evaluate({ ownerId, target }): + # Guard FIRST — before touching the repository (asserted by the "no repo call" test) + if target.day !== TRIGGER_DAY: return [] + rows = getMemoryFacesForPeriod(ownerId, { months: [target.month], takenBefore: target.endOf('day').toJSDate(), }) byYear = group rows where row.year < target.year, keyed by row.year - + mm = zero-padded target.month candidates = [] + for [year, yearRows] of byYear: - top = pairCounts(yearRows)[0] // strongest pair that year + top = pairCounts(yearRows)[0] # strongest pair that year if !top: continue if top.assets.length < MIN_ASSETS: continue if top.distinctDays < MIN_DISTINCT_DAYS: continue - mm = zero-padded target.month count = top.assets.length candidates.push({ - ruleId: id, + ruleId: 'people_together', dedupeKey: `people_together:${top.a.id}:${top.b.id}:${year}-${mm}`, - title: `${top.a.name} & ${top.b.name}`, // names ordered by the a.id r.score - l.score).slice(0, MAX_YEARS) +``` + +**Worked score example (pinned in the spec test):** `count = 6`, `year = 2023`, +`target = 2026-06-20` → `recencyBonus(2023, 2026) = max(0, 10 - 3) = 7` → +`score = 100 + 18 + 7 = 125`. + +**Per-day-cap interaction (already implemented in Tier 1 §3.2):** `people_together` is a +multi-day rule (`visibleForDays > 1`), so at most **one** of its `MAX_YEARS` candidates inserts +on the trigger day; the top-scored year wins, the runner-up is dropped that month (the rule +only fires on day 20). Emitting up to 2 is defensive — it gives the service a ranked fallback if +the top candidate collides with an already-inserted memory during a multi-day catch-up run. + +## 6. Test plan (TDD / BDD) + +### 6.0 TDD discipline (red → green → refactor per unit) + +Build bottom-up in the §9 slice order so each unit is real before its consumer. For **every** +unit: write the spec, run it and watch it **fail for the right reason** (module/method/key +absent), implement the minimum to pass, refactor with the test green. A test that passes on its +first run isn't exercising the new behavior — treat that as a red flag. + +### 6.1 Conventions (match the existing rule specs) + +Write tests **first**. Mirror `on-this-day-place.rule.spec.ts` / `birthday.rule.spec.ts`: +construct the rule directly with an inline `vi.fn()` mock cast `as never` (no `newTestService` +for the rule unit), drive it with a fixed `DateTime.fromISO('2026-06-20', { zone: 'utc' })` +target — never `DateTime.now()` — and assert the candidate with `toMatchObject`, **including the +exact numeric `score`** (existing specs pin exact scores, e.g. `254`; ours pins `125` etc.). +Registry/metadata specs use their existing direct-import harnesses. + +### 6.2 Unit — `pairCounts` (extend `curation.util.spec.ts`) + +BDD (`describe` = "given …", `it` = "then …"): + +- given an asset with **2** subjects → one pair with that asset. +- given an asset with **3** subjects → **3** pairs (all unordered combinations). +- given an asset with **1** subject → **no** pair (no self-pair). +- given one subject appearing via **two faces on the same asset** → counted once; produces no + self-pair and does not inflate any pair's count. +- given the same pair across **3 assets on 2 calendar days** → `assets.length === 3`, + `distinctDays === 2`, assets in chronological order. +- given two pairs with different counts → sorted by `assets.length` desc. +- given two pairs with **equal** counts → deterministic order by the ordered id pair. +- given rows in shuffled input order → identical output (order-independent); `a.id < b.id` always. +- given empty input → `[]`. + +### 6.3 Unit — `people-together.rule.spec.ts` + +- given `target.day !== 20` → emits `[]` and **does not** call the repo (guard-first). +- given day 20 and a prior-year copy of this month with a pair in **≥ 6 photos across ≥ 2 days** + → one candidate; `title`/`subtitle`/`memoryAt`/`dedupeKey`/`score(125)`/`visibleForDays: 7` + /`context` match §5.2. +- given a pair with **exactly 6** photos / **2** days → included (inclusive boundary). +- given a pair with **5** photos → that year skipped (`MIN_ASSETS`). +- given a pair with **6** photos all on **one day** → skipped (`MIN_DISTINCT_DAYS`). +- given two competing pairs in the same year → the **higher-count** pair wins that year. +- given qualifying pairs across **3** prior years → only the top **`MAX_YEARS (2)`**, score-sorted. +- given current/future-year rows (`year >= target.year`) → ignored (prior years only). +- given a **pet–pet** pair (both `type: 'pet'`) and a **person–pet** pair, each ≥ thresholds → + both qualify (pets included, D4). +- given more than **8** co-occurring photos → `assetIds.length === 8`, chronological, evenly + sampled. +- given the pair's rows in reversed input order → identical `dedupeKey`/`title` (id-ordering). +- given a newer and older year with equal count → newer outscores older (`recencyBonus`). +- given an empty face list / no pair clears the gate → `[]`. + +### 6.4 Registry & metadata specs (extend existing) + +- `memory-type.metadata.spec.ts`: + - `people_together` present with `kind: 'rule'`, `defaultEnabled: true`, `adminConfigurable: true`. + - `buildDefaultMemoryTypeMap()` includes `people_together → true`. + - `getMemoryTypeKeyForMemory(MemoryType.Rule, { ruleId: 'people_together' })` → `'people_together'`. + - `getAdminAvailableMemoryTypeKeys({})` (empty config) includes `people_together`. + - `isMemoryTypeEnabledForUser(undefined, 'people_together')` → true. +- `memory-type.registry.spec.ts`: + - `createMemoryRules(['people_together'], deps)` returns one `PeopleTogetherMemoryRule` with + matching `id`. + - a config omitting the key does not instantiate it. + +### 6.5 Medium test — `getMemoryFacesForPeriod` (real DB) + +Using `test/medium` (real Postgres via testcontainers), seed assets + faces + people with known +`localDateTime`, `person.name`, `person.type`, `person.isHidden`, `asset_face.isVisible`, then assert: + +- `months` filter returns only in-month rows; multi-month `months` unions correctly. +- one row **per (asset, person)** — an asset with two named people yields two rows. +- `takenBefore` excludes later assets. +- `year` is the correct **UTC** year. +- excludes: unnamed people (`name = ''`), hidden people (`isHidden = true`), invisible faces + (`asset_face.isVisible = false`), soft-deleted faces (`asset_face.deletedAt`), assets without a + Preview file, deleted / non-Timeline assets, **another owner's** assets/people. +- **includes pets** (`person.type = 'pet'`) — a named pet's faces come back. +- results ordered by `localDateTime` ascending. + +### 6.6 End-to-end generation medium test (real DB) + +Mirror the Tier 1 `month_recap`/`on_this_day_place` generation tests in the medium +`memory.service` spec — proves the rule works through the actual generation path, not just in +isolation: + +- **positive:** seed a past-year June where two named people co-occur in ≥ 6 photos across ≥ 2 + days; run the generation path with `target` on **2026-06-20**; assert a `people_together` + memory is created with `showAt = 2026-06-20 00:00`, `hideAt = 2026-06-26 23:59:59` (7-day + window), the expected asset set, and the `"A & B"` title. +- **negative:** the same library but only 5 co-occurring photos (or all on one day) → **no** + `people_together` memory generated. + +### 6.7 Edge cases consolidated (each must have a test) + +| Edge case | Owning test | Expected | +| ---------------------------------------------------- | ----------------------- | -------------------------------------------- | +| Wrong trigger day (`day !== 20`) | rule spec §6.3 | `[]`, **no repo call** | +| Empty library / no faces | rule spec §6.3 | `[]` | +| Only current/future-year rows | rule spec §6.3 | `[]` (prior years only) | +| Pair below `MIN_ASSETS` (5) | rule spec §6.3 | year skipped | +| Pair at exactly 6 photos / 2 days | rule spec §6.3 | included (inclusive boundary) | +| 6 photos but a single day (`distinctDays < 2`) | rule spec §6.3 | skipped | +| More qualifying years than `MAX_YEARS` | rule spec §6.3 | capped at 2, score-sorted | +| Co-occurring photos above `ASSET_CAP` (8) | rule spec §6.3 | `assetIds` capped, chronological | +| Two competing pairs same year | rule spec §6.3 | higher-count pair wins | +| Pair-count tie | pairCounts §6.2 | deterministic (ordered id pair) | +| One person, two faces on one asset | pairCounts §6.2 | counted once; no self-pair | +| Single subject on an asset | pairCounts §6.2 | no pair | +| `dedupeKey`/title order-independence (pair symmetry) | pairCounts + rule | identical key/title regardless of order | +| Pets included (pet–pet, person–pet) | rule §6.3 + medium §6.5 | pairs qualify; pet faces returned | +| Unnamed / hidden people, invisible/deleted faces | medium §6.5 | excluded from rows | +| Another owner's people/assets | medium §6.5 | excluded | +| Multi-day window (`hideAt` spans 7 days) | generation §6.6 | `hideAt = showAt + 6d, endOf('day')` | +| Per-day multi-day cap (only 1 of MAX_YEARS inserts) | reasoned §5.2 | documented; Tier 1 §3.2 machinery | +| Two people sharing a name | reasoned §2 D6 | title id-ordered; accepted (`"Anna & Anna"`) | + +## 7. Verification gates (before PR) + +- `cd server && pnpm test -- --run src/services/memory-rules/` → rule + `pairCounts` + + registry/metadata specs green. +- `pnpm test:medium` for the new repo query **and** the generation test (Docker DB up). +- `make sql` (DB up) → regenerate the `getMemoryFacesForPeriod` SQL snapshot; commit it. + **Never run `make sql` without a running DB** — it deletes query files. +- `make check-server` (tsc) + `make lint-server` + `prettier --check` on **every** modified + server file (source included — eslint-green ≠ prettier-green). +- Web: from `web/`, `check:typescript` + `check:svelte` + `pnpm lint`. +- **i18n completeness:** all 4 new `en.json` keys must exist — the settings components read them + via `$t(...)`, so a missing key renders a **blank label at runtime, not a compile error**. + Grep each of the 4 keys after editing. Only `en.json` is required; other locales fall back. +- `prettier --write` on this doc under `docs/plans/` (Docs CI is strict). +- **No e2e added** — parity with `birthday`/`recent_trip` and the Tier 1 rules (unit + medium, + no dedicated e2e). Add one later only if the generation path regresses. +- Manual smoke (optional): `make dev`, enable the type, seed a library where two named people + co-occur in a past-year copy of the current month, run the `MemoryGenerate` job with the + clock at day 20, confirm the memory appears and the toggle hides it. + +## 8. Open tasks / follow-ups + +- [ ] Confirm §2 design decisions (esp. **D2** trigger day 20 + 7-day window, **D4** pet–pet + pairs allowed, **D3** 6/2 thresholds). +- [ ] Re-verify mobile memory-type settings enumeration; Tier 1 found it reads + `availableMemoryTypes` (no per-type edit). Record N/A or add keys if that changed. +- [ ] Out of scope unless profiling demands it: the query filters `extract(month …)`, which the + existing `date_trunc('MONTH', …)` functional index does **not** serve, and adds + `asset_face`/`person` joins — so it scans the owner's Timeline faces for the month and + filters in-heap (background job, low cadence — acceptable). If a huge library makes this + hurt, add a matching functional index and/or a **per-year** lateral `LIMIT` (never a flat + total cap, §4). +- [ ] Possible future extension: require ≥ 1 human in the pair, or add group (3+) memories — + both out of scope now (D4 / §1). + +## 9. Implementation slices (for `/impl-loop`) + +Each slice is independently implementable and leaves the tree **green and shippable**. +Foundations (Slices 1–2) land first because the rule depends on them; Slice 3 wires the rule +end-to-end (rule → registry → metadata → admin toggle → i18n) so enabling it produces a working, +user-visible memory type; Slice 4 proves it through the real generation path and closes parity. + +**TDD is mandatory in every slice:** write the spec, run it and confirm it **fails red for the +intended reason**, implement the minimum to pass, confirm **green**, then refactor with tests +green. Assert **exact** `score` values (per the `birthday`/`on_this_day_place` convention). +Detail for each item lives in the referenced §sections; this section defines scope, order, +dependencies, and done-criteria only. + +### Slice 1 — `pairCounts` curation helper + +- **Deps:** none (pure function). +- **Build:** `pairCounts` + `FaceRow`/`PairStat` types in `curation.util.ts` (§5.1); extend + `curation.util.spec.ts` with the §6.2 cases. +- **Red→green:** the new `pairCounts` cases fail (no export) → implement → green. +- **Verify:** `cd server && pnpm test -- --run src/services/memory-rules/curation.util.spec.ts`; `make check-server`. +- **Done:** `pairCounts` spec green; exported signatures match §5.1. +- **Commit:** `feat(memories): pairCounts curation helper for people-together`. + +### Slice 2 — `getMemoryFacesForPeriod` query + +- **Deps:** none. Enables the rule. +- **Build:** `MemoryPeriodFace` + `getMemoryFacesForPeriod` in `asset.repository.ts` (§4); add + `getMemoryFacesForPeriod: vitest.fn()` to `asset.repository.mock.ts`; new + `describe('getMemoryFacesForPeriod')` medium test (§6.5). +- **Red→green:** medium test fails (no method) → implement → green. +- **Verify:** `pnpm test:medium` (Docker DB up) for the new describe; `make sql` (DB up) to + snapshot the query — **never without a running DB**; commit the snapshot; `make check-server`. +- **Done:** medium test green (incl. pet-inclusion + exclusion filters); mock updated; SQL + snapshot committed. +- **Commit:** `feat(memories): getMemoryFacesForPeriod repository query`. + +### Slice 3 — `people_together` rule (end-to-end) + +- **Deps:** Slices 1, 2. +- **Build:** `people-together.rule.ts` + `people-together.rule.spec.ts` (§5.2, §6.3, edge cases + §6.7); register in `memory-type.registry.ts` (`RULE_FACTORIES` + import) and + `memory-type.metadata.ts` (`MEMORY_TYPE_METADATA`, `defaultEnabled: true`); extend + `memory-type.registry.spec.ts` + `memory-type.metadata.spec.ts` (§6.4); add `'people_together'` + to `memoryTypeKeys` in `MemoriesSettings.svelte`; add the 4 `en.json` keys (§7 i18n gate). +- **Red→green:** rule spec fails (no rule) → implement rule green → wire registry/metadata + (their specs green) → web/i18n. +- **Verify:** rule + registry/metadata specs green; `make check-server`; from `web/`: + `check:typescript` + `check:svelte` + `pnpm lint`; grep the 4 i18n keys exist. +- **Done:** unit + registry/metadata green; the admin toggle renders a real (non-blank) + label/description. +- **Commit:** `feat(memories): people-together memory type`. + +### Slice 4 — Generation test, mobile parity, full gate & roadmap status + +- **Deps:** Slice 3. +- **Build:** the end-to-end generation medium test (§6.6, positive + negative); re-verify the + §3.1 mobile enumeration (edit if it hardcodes a list, else record "no change needed"); flip + #5 to **Shipped — `people_together`** in the + [roadmap](./2026-07-15-memory-types-roadmap.md) and link this spec (mirroring the Tier 1 rows). +- **Verify:** the full §7 gate — `pnpm test:medium` (generation test), `make check-server`, + `make lint-server`, `prettier --check` on all modified server files, web checks, docs prettier. +- **Done:** whole suite green; generation proven end-to-end; mobile parity resolved (edited or + explicitly N/A); roadmap updated. +- **Commit:** `chore(memories): people-together generation test + roadmap status`. + +### Slice dependency graph + +``` +1 pairCounts ─┐ + ├─→ 3 rule (end-to-end) ─→ 4 generation test + mobile/docs +2 query ──────┘ ``` -Notes: - -- Title name order follows the `a.id < b.id` pairing (deterministic), **not** alphabetical — - keeps title, `dedupeKey`, and `context` consistent with each other. (Alphabetical-by-name was - considered but two people can share a name; ordering by id is unambiguous.) -- `pairCounts` returns `PairStat.assetIds` already, but `sampleAssetsByTime` needs - `{id, localDateTime}`; the rule maps the pair's asset ids back to their rows (or `pairCounts` - returns timed assets). Implementation detail for the plan — either is fine. - -## Candidate → memory (unchanged plumbing) - -The service already turns `MemoryRuleCandidate` into a `MemoryType.Rule` record, dedupes by -`dedupeKey` + `hasRuleMemory(ownerId, ruleId, dedupeKey)`, sorts all rules' candidates by -`score`, and inserts up to `RULE_DAILY_LIMIT` (2) per day. `people_together` competes in that -same pool; its `score` family (`100 + count*3 + recency`) matches `on_this_day_place` so it -neither dominates nor is dominated by design. - -## Registration, settings, i18n (boilerplate) - -1. **`memory-type.metadata.ts`** — add `{ key: 'people_together', kind: 'rule', defaultEnabled: true, adminConfigurable: true }`. -2. **`memory-type.registry.ts`** — import the rule; add - `people_together: (deps) => new PeopleTogetherMemoryRule(deps.assetRepository)`. -3. **`web/src/routes/admin/system-settings/MemoriesSettings.svelte`** — add `'people_together'` - to the hardcoded `memoryTypeKeys` array. -4. **`i18n/en.json`** — 4 keys (others fall back to en): - - `memory_type_people_together_setting`: `"People together memories"` - - `memory_type_people_together_setting_description`: `"Generate memories of two people or pets often photographed together in a past year."` - - `memory_type_people_together`: `"People together"` - - `memory_type_people_together_description`: `"Two people or pets often photographed together in a past year."` - -(Exact copy is a knob; finalize during implementation.) - -## Test plan (TDD, mirroring Tier 1) - -Ordered slices, each red→green: - -1. **`pairCounts` util spec** — single asset with 2 subjects → one pair; 3 subjects → 3 pairs; - a subject with two faces on one asset counted once; distinct-day counting; deterministic sort - & id-ordered pairs; empty input → `[]`. -2. **Rule spec** (`people-together.rule.spec.ts`, mocked repo): - - below `MIN_ASSETS` → no candidate - - `MIN_ASSETS` met but all on one day (`distinctDays < 2`) → no candidate - - clean qualifying pair → title/subtitle/dedupeKey/score/memoryAt/`visibleForDays: 1` - - two competing pairs same year → the higher-count pair wins that year - - multi-year → sorted by score, capped at `MAX_YEARS` - - current/future-year rows (`year >= target.year`) ignored - - a pet pair and a person–pet pair both qualify (pets included) - - `dedupeKey` identical regardless of input row order (id-ordering) - - `assetIds` capped at `ASSET_CAP` and chronologically sampled -3. **Repository medium test** — `getMemoryFacesForPeriod` honors visibility/deleted/preview, - `asset_face.isVisible`/`deletedAt`, `person.isHidden`, `person.name != ''`, and **includes - pets**; excludes other owners. -4. **Metadata/registry expectation updates** — `people_together` present, default-enabled, - admin-configurable; `availableMemoryTypes` includes it. -5. **Admin settings spec** (`MemoriesSettings.spec.ts`) — the new toggle renders with a real - (non-blank) label/description. - -## Edge cases & determinism - -- **Groups (3+ always together):** only the top _pair_ surfaces. Group memories are out of scope. -- **Same person, multiple faces on one asset:** deduped to one subject per asset in `pairCounts`. -- **Ties:** `pairCounts` and the candidate sort both tie-break deterministically (ordered id - pair / score then implicit order), so repeated runs are stable — same discipline as `dominantBy`. -- **Overlap with `birthday`:** different trigger (birthday is a person's birth date; this is a - past month's co-occurrence), different `ruleId`/`dedupeKey` — they can co-exist and dedup - independently. - -## Out of scope (YAGNI) - -- "You & X" owner identification. -- Group (3+) memories. -- Localizing memory **content** (titles/subtitles stay English server-side, like all rules). -- Cross-owner / shared-space people (rules operate on `ownerId`'s own people, as today). -- Tuning `RULE_DAILY_LIMIT` (unchanged at 2). - -## Roadmap bookkeeping - -On landing, mark #5 **Shipped — `people_together`** in -[`2026-07-15-memory-types-roadmap.md`](./2026-07-15-memory-types-roadmap.md) and link this spec, -mirroring the Tier 1 row treatment. +Slices 1 and 2 have no interdependencies and may be built in any order (or parallel); 3 requires +1 + 2; 4 requires 3. From 81204b1a879f82e7def4b0d19f8c6316ae01e533 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Thu, 16 Jul 2026 23:09:04 +0200 Subject: [PATCH 3/8] feat(memories): pairCounts curation helper for people-together --- .../memory-rules/curation.util.spec.ts | 156 ++++++++++++++++++ .../services/memory-rules/curation.util.ts | 71 ++++++++ 2 files changed, 227 insertions(+) diff --git a/server/src/services/memory-rules/curation.util.spec.ts b/server/src/services/memory-rules/curation.util.spec.ts index 31abaabfa2c49..933592bdcd632 100644 --- a/server/src/services/memory-rules/curation.util.spec.ts +++ b/server/src/services/memory-rules/curation.util.spec.ts @@ -1,8 +1,10 @@ import { DateTime } from 'luxon'; import { dominantBy, + FaceRow, medianTime, monthName, + pairCounts, pickEvenlySpaced, recencyBonus, sampleAssetsByTime, @@ -10,6 +12,13 @@ import { const asset = (id: string, iso: string) => ({ id, localDateTime: DateTime.fromISO(iso, { zone: 'utc' }).toJSDate() }); +const face = (assetId: string, personId: string, personName: string, iso: string): FaceRow => ({ + assetId, + personId, + personName, + localDateTime: DateTime.fromISO(iso, { zone: 'utc' }).toJSDate(), +}); + describe('pickEvenlySpaced', () => { it('returns [] when count is zero or negative', () => { expect(pickEvenlySpaced([1, 2, 3], 0)).toEqual([]); @@ -153,3 +162,150 @@ describe('recencyBonus', () => { expect(recencyBonus(2000, 2026)).toBe(0); }); }); + +describe('pairCounts', () => { + describe('given an asset with 2 subjects', () => { + it('then returns one pair containing that asset', () => { + const rows = [face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), face('a1', 'p2', 'Ben', '2023-06-10T10:00:00')]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + a: { id: 'p1', name: 'Anna' }, + b: { id: 'p2', name: 'Ben' }, + assets: [{ id: 'a1', localDateTime: rows[0]!.localDateTime }], + distinctDays: 1, + }); + }); + }); + + describe('given an asset with 3 subjects', () => { + it('then returns 3 pairs (all unordered combinations)', () => { + const rows = [ + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + face('a1', 'p3', 'Cara', '2023-06-10T10:00:00'), + ]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(3); + const keys = result.map((pair) => `${pair.a.id}:${pair.b.id}`).sort(); + expect(keys).toEqual(['p1:p2', 'p1:p3', 'p2:p3']); + for (const pair of result) { + expect(pair.assets).toHaveLength(1); + expect(pair.assets[0]!.id).toBe('a1'); + } + }); + }); + + describe('given an asset with 1 subject', () => { + it('then returns no pair (no self-pair)', () => { + const rows = [face('a1', 'p1', 'Anna', '2023-06-10T10:00:00')]; + + expect(pairCounts(rows)).toEqual([]); + }); + }); + + describe('given one subject appearing via two faces on the same asset, plus a second subject', () => { + it('then produces exactly one pair, counted once (no self-pair, no inflation)', () => { + const rows = [ + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + ]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(1); + expect(result[0]!.a).toEqual({ id: 'p1', name: 'Anna' }); + expect(result[0]!.b).toEqual({ id: 'p2', name: 'Ben' }); + expect(result[0]!.assets).toHaveLength(1); + }); + }); + + describe('given the same pair across 3 assets on 2 distinct UTC calendar days', () => { + it('then assets.length is 3, distinctDays is 2, and assets are chronological ascending', () => { + const rows = [ + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + face('a2', 'p1', 'Anna', '2023-06-10T15:00:00'), + face('a2', 'p2', 'Ben', '2023-06-10T15:00:00'), + face('a3', 'p1', 'Anna', '2023-06-11T09:00:00'), + face('a3', 'p2', 'Ben', '2023-06-11T09:00:00'), + ]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(1); + const [pair] = result; + expect(pair!.assets.map((a) => a.id)).toEqual(['a1', 'a2', 'a3']); + expect(pair!.distinctDays).toBe(2); + }); + }); + + describe('given two pairs with different counts', () => { + it('then sorts by assets.length desc', () => { + const rows = [ + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + face('a2', 'p1', 'Anna', '2023-06-11T10:00:00'), + face('a2', 'p2', 'Ben', '2023-06-11T10:00:00'), + face('a3', 'p1', 'Anna', '2023-06-12T10:00:00'), + face('a3', 'p3', 'Cara', '2023-06-12T10:00:00'), + ]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ a: { id: 'p1' }, b: { id: 'p2' } }); + expect(result[0]!.assets).toHaveLength(2); + expect(result[1]).toMatchObject({ a: { id: 'p1' }, b: { id: 'p3' } }); + expect(result[1]!.assets).toHaveLength(1); + }); + }); + + describe('given two pairs with equal counts', () => { + it('then orders deterministically by the ordered id pair ascending', () => { + const rows = [ + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + face('a1', 'p3', 'Cara', '2023-06-10T10:00:00'), + face('a2', 'p1', 'Anna', '2023-06-11T10:00:00'), + face('a2', 'p2', 'Ben', '2023-06-11T10:00:00'), + ]; + + const result = pairCounts(rows); + + expect(result).toHaveLength(2); + expect(result.map((pair) => `${pair.a.id}:${pair.b.id}`)).toEqual(['p1:p2', 'p2:p3']); + }); + }); + + describe('given rows in shuffled input order', () => { + it('then returns identical output to sorted input, and every pair keeps a.id < b.id', () => { + const inOrder = [ + face('a1', 'p1', 'Anna', '2023-06-10T10:00:00'), + face('a1', 'p2', 'Ben', '2023-06-10T10:00:00'), + face('a1', 'p3', 'Cara', '2023-06-10T10:00:00'), + face('a2', 'p1', 'Anna', '2023-06-11T10:00:00'), + face('a2', 'p2', 'Ben', '2023-06-11T10:00:00'), + ]; + const shuffled = [inOrder[3]!, inOrder[1]!, inOrder[4]!, inOrder[0]!, inOrder[2]!]; + + const resultInOrder = pairCounts(inOrder); + const resultShuffled = pairCounts(shuffled); + + expect(resultShuffled).toEqual(resultInOrder); + for (const pair of resultInOrder) { + expect(pair.a.id < pair.b.id).toBe(true); + } + }); + }); + + describe('given empty input', () => { + it('then returns []', () => { + expect(pairCounts([])).toEqual([]); + }); + }); +}); diff --git a/server/src/services/memory-rules/curation.util.ts b/server/src/services/memory-rules/curation.util.ts index 6f6371127fd6f..a186a433fd4de 100644 --- a/server/src/services/memory-rules/curation.util.ts +++ b/server/src/services/memory-rules/curation.util.ts @@ -101,3 +101,74 @@ const MONTH_NAMES = [ /** The English name of a 1-based month, used in memory titles. */ export const monthName = (month: number): string => MONTH_NAMES[month - 1]!; + +// Structural input, decoupled from the repository type so curation.util stays dependency-free. +export interface FaceRow { + assetId: string; + personId: string; + personName: string; + localDateTime: Date; +} + +export interface PairStat { + a: { id: string; name: string }; // ordered so a.id < b.id (stable, order-independent) + b: { id: string; name: string }; + assets: { id: string; localDateTime: Date }[]; // assets containing BOTH, chronological + distinctDays: number; // count of distinct UTC calendar days among those assets +} + +/** + * For every unordered pair of distinct subjects that co-occur on at least one asset, collect the + * assets where both appear. A person with two faces on the same asset collapses to one subject + * (no self-pair, no double count). Returned pairs are sorted by co-occurrence count desc, then by + * the ordered id pair ascending, so the result is deterministic regardless of input row order. + */ +export const pairCounts = (rows: FaceRow[]): PairStat[] => { + const subjectsByAsset = new Map>(); + const assetTimes = new Map(); + + for (const row of rows) { + const subjects = subjectsByAsset.get(row.assetId) ?? new Map(); + subjects.set(row.personId, { id: row.personId, name: row.personName }); + subjectsByAsset.set(row.assetId, subjects); + assetTimes.set(row.assetId, row.localDateTime); + } + + const pairs = new Map< + string, + { a: { id: string; name: string }; b: { id: string; name: string }; assets: Map } + >(); + + for (const [assetId, subjects] of subjectsByAsset) { + const subjectList = [...subjects.values()]; + for (let i = 0; i < subjectList.length; i++) { + for (let j = i + 1; j < subjectList.length; j++) { + const [a, b] = + subjectList[i]!.id < subjectList[j]!.id + ? [subjectList[i]!, subjectList[j]!] + : [subjectList[j]!, subjectList[i]!]; + const key = `${a.id}:${b.id}`; + const pair = pairs.get(key) ?? { a, b, assets: new Map() }; + pair.assets.set(assetId, assetTimes.get(assetId)!); + pairs.set(key, pair); + } + } + } + + const stats: PairStat[] = [...pairs.values()].map(({ a, b, assets }) => { + const sortedAssets = [...assets.entries()] + .map(([id, localDateTime]) => ({ id, localDateTime })) + .sort((left, right) => left.localDateTime.getTime() - right.localDateTime.getTime()); + const distinctDays = new Set(sortedAssets.map((asset) => asset.localDateTime.toISOString().slice(0, 10))).size; + return { a, b, assets: sortedAssets, distinctDays }; + }); + + return stats.sort((left, right) => { + if (right.assets.length !== left.assets.length) { + return right.assets.length - left.assets.length; + } + const leftKey = `${left.a.id}:${left.b.id}`; + const rightKey = `${right.a.id}:${right.b.id}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); +}; From 881b1539cabd1ac2ae840e4a68da5386fb56d60e Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Thu, 16 Jul 2026 23:18:05 +0200 Subject: [PATCH 4/8] feat(memories): getMemoryFacesForPeriod repository query --- server/src/queries/asset.repository.sql | 42 ++++ server/src/repositories/asset.repository.ts | 42 ++++ .../repositories/asset.repository.spec.ts | 186 ++++++++++++++++++ .../repositories/asset.repository.mock.ts | 1 + 4 files changed, 271 insertions(+) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index d95d8ea4f0d1f..ba36d8412ceaa 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -295,6 +295,48 @@ where order by "asset"."localDateTime" asc +-- AssetRepository.getMemoryFacesForPeriod +select + "asset"."id" as "assetId", + "asset"."localDateTime", + "person"."id" as "personId", + "person"."name" as "personName", + extract( + year + from + (asset."localDateTime" at time zone 'UTC') + )::int as "year" +from + "asset" + inner join "asset_face" on "asset_face"."assetId" = "asset"."id" + inner join "person" on "person"."id" = "asset_face"."personId" +where + "asset"."ownerId" = $1 + and "asset"."visibility" = $2 + and "asset"."deletedAt" is null + and "asset"."localDateTime" <= $3 + and extract( + month + from + (asset."localDateTime" at time zone 'UTC') + )::int in ($4) + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $5 + and "person"."ownerId" = $6 + and "person"."name" != $7 + and "person"."isHidden" = $8 + and exists ( + select + "asset_file"."assetId" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $9 + ) +order by + "asset"."localDateTime" asc + -- AssetRepository.getOwnedManifestAssets select "asset"."id", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8ac868ab07909..abb06c1808c3c 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -179,6 +179,14 @@ export interface MemoryPeriodAsset { isFavorite: boolean; } +export interface MemoryPeriodFace { + assetId: string; + localDateTime: Date; + year: number; + personId: string; + personName: string; +} + export interface MemoryPeriodOptions { /** calendar months (1–12) to include */ months: number[]; @@ -982,6 +990,40 @@ export class AssetRepository { .execute(); } + @GenerateSql({ params: [DummyValue.UUID, { months: [6], takenBefore: DummyValue.DATE }] }) + getMemoryFacesForPeriod( + ownerId: string, + { months, takenBefore }: { months: number[]; takenBefore: Date }, + ): Promise { + return this.db + .selectFrom('asset') + .innerJoin('asset_face', 'asset_face.assetId', 'asset.id') + .innerJoin('person', 'person.id', 'asset_face.personId') + .select(['asset.id as assetId', 'asset.localDateTime', 'person.id as personId', 'person.name as personName']) + .select(sql`extract(year from (asset."localDateTime" at time zone 'UTC'))::int`.as('year')) + .where('asset.ownerId', '=', ownerId) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where('asset.localDateTime', '<=', takenBefore) + .where(sql`extract(month from (asset."localDateTime" at time zone 'UTC'))::int`, 'in', months) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .where('person.ownerId', '=', ownerId) + .where('person.name', '!=', '') + .where('person.isHidden', '=', false) + .where((eb) => + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.assetId') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Preview), + ), + ) + .orderBy('asset.localDateTime', 'asc') + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, 1000, DummyValue.UUID] }) getOwnedManifestAssets(ownerId: string, limit: number, cursor?: string) { return this.db diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 93a84e06ecc98..ed469d5a05760 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -64,6 +64,28 @@ const seedPeriodAsset = async ( return asset; }; +const seedPerson = async ( + ctx: ReturnType['ctx'], + ownerId: string, + { + name = 'Test Person', + type = 'person', + isHidden = false, + }: { name?: string; type?: string; isHidden?: boolean } = {}, +) => { + const { person } = await ctx.newPerson({ ownerId, name, type, isHidden }); + return person; +}; + +const seedFace = async ( + ctx: ReturnType['ctx'], + assetId: string, + personId: string, + { isVisible = true, deletedAt = null }: { isVisible?: boolean; deletedAt?: Date | null } = {}, +) => { + await ctx.newAssetFace({ assetId, personId, isVisible, deletedAt }); +}; + const createTimelineAssetWithPeople = async ( ctx: ReturnType['ctx'], ownerId: string, @@ -1019,6 +1041,170 @@ describe(AssetRepository.name, () => { }); }); + describe('getMemoryFacesForPeriod', () => { + describe('given assets in and out of the requested months', () => { + it('then returns only rows in the requested months, unioning multi-month filters', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const person = await seedPerson(ctx, user.id, { name: 'Anna' }); + + const june = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-10T12:00:00Z') }); + await seedFace(ctx, june.id, person.id); + const july = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-07-10T12:00:00Z') }); + await seedFace(ctx, july.id, person.id); + const august = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-08-10T12:00:00Z') }); + await seedFace(ctx, august.id, person.id); + const september = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-09-10T12:00:00Z') }); + await seedFace(ctx, september.id, person.id); // excluded — wrong month + + const result = await sut.getMemoryFacesForPeriod(user.id, { + months: [6, 7, 8], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result.map((r) => r.assetId).toSorted()).toEqual([june.id, july.id, august.id].toSorted()); + }); + }); + + describe('given an asset with two named subjects', () => { + it('then returns one row per (asset, person)', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const asset = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-10T12:00:00Z') }); + const anna = await seedPerson(ctx, user.id, { name: 'Anna' }); + const ben = await seedPerson(ctx, user.id, { name: 'Ben' }); + await seedFace(ctx, asset.id, anna.id); + await seedFace(ctx, asset.id, ben.id); + + const result = await sut.getMemoryFacesForPeriod(user.id, { + months: [6], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.personName).toSorted()).toEqual(['Anna', 'Ben']); + expect(result.every((r) => r.assetId === asset.id)).toBe(true); + }); + }); + + describe('given an asset taken after takenBefore', () => { + it('then excludes that asset', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const person = await seedPerson(ctx, user.id, { name: 'Anna' }); + const early = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-10T12:00:00Z') }); + await seedFace(ctx, early.id, person.id); + const late = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2025-06-10T12:00:00Z') }); + await seedFace(ctx, late.id, person.id); + + const result = await sut.getMemoryFacesForPeriod(user.id, { + months: [6], + takenBefore: new Date('2024-01-01T00:00:00Z'), + }); + + expect(result.map((r) => r.assetId)).toEqual([early.id]); + expect(result[0].year).toBe(2023); + }); + }); + + describe('given an asset at a UTC month/year boundary', () => { + it('then extracts the year in UTC', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const person = await seedPerson(ctx, user.id, { name: 'Anna' }); + // Local time is Dec 31 23:30 in UTC-1, but the stored localDateTime column (interpreted + // as a naive UTC wall-clock reading) falls on Jan 1 the next year. + const asset = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2024-01-01T00:30:00Z') }); + await seedFace(ctx, asset.id, person.id); + + const result = await sut.getMemoryFacesForPeriod(user.id, { + months: [1], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result.map((r) => r.year)).toEqual([2024]); + }); + }); + + describe('given exclusion cases', () => { + it('excludes unnamed people, hidden people, invisible faces, soft-deleted faces, assets without a preview, soft-deleted assets, non-timeline assets, and another owner entirely; includes pets; orders by localDateTime asc', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + + const unnamed = await seedPerson(ctx, user.id, { name: '' }); + const hidden = await seedPerson(ctx, user.id, { name: 'Hidden', isHidden: true }); + const normal = await seedPerson(ctx, user.id, { name: 'Zoe' }); + const pet = await seedPerson(ctx, user.id, { name: 'Rex', type: 'pet' }); + const otherOwnersPerson = await seedPerson(ctx, other.id, { name: 'Stranger' }); + + // unnamed person + const unnamedAsset = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-05T12:00:00Z') }); + await seedFace(ctx, unnamedAsset.id, unnamed.id); + + // hidden person + const hiddenAsset = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-06T12:00:00Z') }); + await seedFace(ctx, hiddenAsset.id, hidden.id); + + // invisible face + const invisibleFaceAsset = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-06-07T12:00:00Z'), + }); + await seedFace(ctx, invisibleFaceAsset.id, normal.id, { isVisible: false }); + + // soft-deleted face + const deletedFaceAsset = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-06-08T12:00:00Z'), + }); + await seedFace(ctx, deletedFaceAsset.id, normal.id, { deletedAt: new Date() }); + + // asset without a preview file + const noPreviewAsset = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-06-09T12:00:00Z'), + withPreview: false, + }); + await seedFace(ctx, noPreviewAsset.id, normal.id); + + // soft-deleted asset + const deletedAsset = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-06-10T12:00:00Z'), + deleted: true, + }); + await seedFace(ctx, deletedAsset.id, normal.id); + + // non-timeline (archived) asset + const archivedAsset = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-06-11T12:00:00Z'), + visibility: AssetVisibility.Archive, + }); + await seedFace(ctx, archivedAsset.id, normal.id); + + // another owner's asset + person entirely + const otherAsset = await seedPeriodAsset(ctx, other.id, { localDateTime: new Date('2023-06-12T12:00:00Z') }); + await seedFace(ctx, otherAsset.id, otherOwnersPerson.id); + + // qualifying rows, seeded out of chronological order to prove ordering + const later = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-20T12:00:00Z') }); + await seedFace(ctx, later.id, normal.id); + const earlier = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-15T12:00:00Z') }); + await seedFace(ctx, earlier.id, normal.id); + const petAsset = await seedPeriodAsset(ctx, user.id, { localDateTime: new Date('2023-06-16T12:00:00Z') }); + await seedFace(ctx, petAsset.id, pet.id); + + const result = await sut.getMemoryFacesForPeriod(user.id, { + months: [6], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result.map((r) => ({ assetId: r.assetId, personName: r.personName }))).toEqual([ + { assetId: earlier.id, personName: 'Zoe' }, + { assetId: petAsset.id, personName: 'Rex' }, + { assetId: later.id, personName: 'Zoe' }, + ]); + }); + }); + }); + describe('getTimeBucket', () => { it('should order assets by local day first and fileCreatedAt within each day', async () => { const { ctx, sut } = setup(); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 85850d0e1a9ec..73dc71956d8cc 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -17,6 +17,7 @@ export const newAssetRepositoryMock = (): Mocked Date: Thu, 16 Jul 2026 23:32:52 +0200 Subject: [PATCH 5/8] feat(memories): people-together memory type --- i18n/en.json | 4 + .../memory-rules/memory-type.metadata.spec.ts | 13 ++ .../memory-rules/memory-type.metadata.ts | 1 + .../memory-rules/memory-type.registry.ts | 2 + .../memory-rules/people-together.rule.spec.ts | 177 ++++++++++++++++++ .../memory-rules/people-together.rule.ts | 72 +++++++ server/src/services/server.service.spec.ts | 2 + server/src/utils/preferences.spec.ts | 2 + .../system-settings/MemoriesSettings.spec.ts | 1 + .../system-settings/MemoriesSettings.svelte | 1 + 10 files changed, 275 insertions(+) create mode 100644 server/src/services/memory-rules/people-together.rule.spec.ts create mode 100644 server/src/services/memory-rules/people-together.rule.ts diff --git a/i18n/en.json b/i18n/en.json index d64214252f7bc..ba2ef9f371560 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -321,6 +321,8 @@ "memory_type_on_this_day_place_setting_description": "Generate memories from photos taken on this date in a past year that are concentrated in one place.", "memory_type_on_this_day_setting": "On this day memories", "memory_type_on_this_day_setting_description": "Generate memories from photos taken on this date in previous years.", + "memory_type_people_together_setting": "People together memories", + "memory_type_people_together_setting_description": "Generate memories of two people or pets often photographed together in a past year.", "memory_type_recent_trip_setting": "Recent trip memories", "memory_type_recent_trip_setting_description": "Generate memories from places recently visited away from home.", "memory_type_season_recap_setting": "Season recap memories", @@ -1996,6 +1998,8 @@ "memory_type_on_this_day_description": "Photos taken on this date in previous years.", "memory_type_on_this_day_place": "On this day, in a place", "memory_type_on_this_day_place_description": "A past year's photos from this date, when they cluster in one place.", + "memory_type_people_together": "People together", + "memory_type_people_together_description": "Two people or pets often photographed together in a past year.", "memory_type_recent_trip": "Recent trips", "memory_type_recent_trip_description": "Memories from places you recently visited away from home.", "memory_type_season_recap": "Season recap", diff --git a/server/src/services/memory-rules/memory-type.metadata.spec.ts b/server/src/services/memory-rules/memory-type.metadata.spec.ts index 838ae4ea533ba..0aa5996f9c9ea 100644 --- a/server/src/services/memory-rules/memory-type.metadata.spec.ts +++ b/server/src/services/memory-rules/memory-type.metadata.spec.ts @@ -25,6 +25,7 @@ describe('memory-type.metadata', () => { { key: 'favorites_throwback', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'on_this_day_place', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'season_recap', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'people_together', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, ]); }); @@ -44,6 +45,7 @@ describe('memory-type.metadata', () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ]); }); }); @@ -58,6 +60,7 @@ describe('memory-type.metadata', () => { favorites_throwback: true, on_this_day_place: true, season_recap: true, + people_together: true, }); }); }); @@ -81,6 +84,10 @@ describe('memory-type.metadata', () => { expect(getMemoryTypeKeyForMemory(MemoryType.Rule, { ruleId: 'birthday' })).toBe('birthday'); }); + it('maps Rule to people_together', () => { + expect(getMemoryTypeKeyForMemory(MemoryType.Rule, { ruleId: 'people_together' })).toBe('people_together'); + }); + it('returns undefined for Rule without a string ruleId', () => { expect(getMemoryTypeKeyForMemory(MemoryType.Rule, {})).toBeUndefined(); expect(getMemoryTypeKeyForMemory(MemoryType.Rule, null)).toBeUndefined(); @@ -99,6 +106,7 @@ describe('memory-type.metadata', () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ]), ); }); @@ -141,6 +149,7 @@ describe('memory-type.metadata', () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ]), ); }); @@ -151,6 +160,10 @@ describe('memory-type.metadata', () => { expect(isMemoryTypeEnabledForUser(undefined, 'birthday')).toBe(true); }); + it('defaults to enabled for people_together', () => { + expect(isMemoryTypeEnabledForUser(undefined, 'people_together')).toBe(true); + }); + it('honors an explicit override', () => { expect(isMemoryTypeEnabledForUser({ birthday: false }, 'birthday')).toBe(false); }); diff --git a/server/src/services/memory-rules/memory-type.metadata.ts b/server/src/services/memory-rules/memory-type.metadata.ts index bb278b12fd081..1544269e34eac 100644 --- a/server/src/services/memory-rules/memory-type.metadata.ts +++ b/server/src/services/memory-rules/memory-type.metadata.ts @@ -20,6 +20,7 @@ export const MEMORY_TYPE_METADATA: MemoryTypeMetadata[] = [ { key: 'favorites_throwback', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'on_this_day_place', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'season_recap', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'people_together', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, ]; export const MEMORY_TYPE_KEYS = MEMORY_TYPE_METADATA.map((m) => m.key); diff --git a/server/src/services/memory-rules/memory-type.registry.ts b/server/src/services/memory-rules/memory-type.registry.ts index f9325acd32440..348439777bad8 100644 --- a/server/src/services/memory-rules/memory-type.registry.ts +++ b/server/src/services/memory-rules/memory-type.registry.ts @@ -7,6 +7,7 @@ import { MemoryRule } from 'src/services/memory-rules/memory-rule.interface'; import { MEMORY_TYPE_METADATA } from 'src/services/memory-rules/memory-type.metadata'; import { MonthRecapMemoryRule } from 'src/services/memory-rules/month-recap.rule'; import { OnThisDayPlaceMemoryRule } from 'src/services/memory-rules/on-this-day-place.rule'; +import { PeopleTogetherMemoryRule } from 'src/services/memory-rules/people-together.rule'; import { RecentTripMemoryRule } from 'src/services/memory-rules/recent-trip.rule'; import { SeasonRecapMemoryRule } from 'src/services/memory-rules/season-recap.rule'; @@ -24,6 +25,7 @@ const RULE_FACTORIES: Record MemoryRule> = { favorites_throwback: (deps) => new FavoritesThrowbackMemoryRule(deps.assetRepository), on_this_day_place: (deps) => new OnThisDayPlaceMemoryRule(deps.assetRepository), season_recap: (deps) => new SeasonRecapMemoryRule(deps.assetRepository), + people_together: (deps) => new PeopleTogetherMemoryRule(deps.assetRepository), }; /** instantiate the rule-kind memory rules whose key is in `enabledKeys` (in registry order, deduped) */ diff --git a/server/src/services/memory-rules/people-together.rule.spec.ts b/server/src/services/memory-rules/people-together.rule.spec.ts new file mode 100644 index 0000000000000..cd73d02f28cf7 --- /dev/null +++ b/server/src/services/memory-rules/people-together.rule.spec.ts @@ -0,0 +1,177 @@ +import { DateTime } from 'luxon'; +import { MemoryPeriodFace } from 'src/repositories/asset.repository'; +import { PeopleTogetherMemoryRule } from 'src/services/memory-rules/people-together.rule'; + +const target = DateTime.fromISO('2026-06-20', { zone: 'utc' }); + +let seq = 0; + +const face = (assetId: string, personId: string, personName: string, iso: string): MemoryPeriodFace => { + const localDateTime = DateTime.fromISO(iso, { zone: 'utc' }); + return { assetId, personId, personName, localDateTime: localDateTime.toJSDate(), year: localDateTime.year }; +}; + +/** + * One row-pair (both subjects on the same asset) per entry in `days` (a day-of-month string), + * so `days.length` controls the photo count and the number of *distinct* entries controls + * `distinctDays`. + */ +const pairRows = ( + year: number, + a: { id: string; name: string }, + b: { id: string; name: string }, + days: string[], +): MemoryPeriodFace[] => { + const rows: MemoryPeriodFace[] = []; + for (const day of days) { + const assetId = `asset-${seq++}`; + const iso = `${year}-06-${day}T10:00:00`; + rows.push(face(assetId, a.id, a.name, iso), face(assetId, b.id, b.name, iso)); + } + return rows; +}; + +const ruleWith = (rows: MemoryPeriodFace[]) => { + const assetRepository = { getMemoryFacesForPeriod: vi.fn().mockResolvedValue(rows) }; + return { rule: new PeopleTogetherMemoryRule(assetRepository as never), assetRepository }; +}; + +const anna = { id: 'p1', name: 'Anna' }; +const ben = { id: 'p2', name: 'Ben' }; + +describe(PeopleTogetherMemoryRule.name, () => { + beforeEach(() => { + seq = 0; + }); + + it('given target.day !== 20, then returns [] and does not call the repository', async () => { + const wrongDay = DateTime.fromISO('2026-06-19', { zone: 'utc' }); + const { rule, assetRepository } = ruleWith([]); + const result = await rule.evaluate({ ownerId: 'user-1', target: wrongDay }); + expect(result).toEqual([]); + expect(assetRepository.getMemoryFacesForPeriod).not.toHaveBeenCalled(); + }); + + it('given day 20 and a qualifying pair (6 photos, 2 days) in 2023, then emits one candidate matching the spec', async () => { + const rows = pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11']); + const { rule, assetRepository } = ruleWith(rows); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(assetRepository.getMemoryFacesForPeriod).toHaveBeenCalledWith('user-1', { + months: [6], + takenBefore: target.endOf('day').toJSDate(), + }); + expect(candidate).toMatchObject({ + ruleId: 'people_together', + dedupeKey: 'people_together:p1:p2:2023-06', + title: 'Anna & Ben', + subtitle: '6 photos together · June 2023', + score: 125, // 100 + 6*3 + recencyBonus(2023,2026)=7 + visibleForDays: 7, + context: { year: 2023, personAId: 'p1', personBId: 'p2', count: 6 }, + }); + expect(candidate.assetIds).toHaveLength(6); + // memoryAt is the median moment of the pair's shared assets (three on 06-10, three on 06-11 → lower-middle is 06-10). + expect(candidate.memoryAt.toISODate()).toBe('2023-06-10'); + }); + + it('given exactly 6 photos across exactly 2 days, then the year qualifies (inclusive boundary)', async () => { + const rows = pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11']); + const { rule } = ruleWith(rows); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + }); + + it('given only 5 co-occurring photos, then that year is skipped (MIN_ASSETS)', async () => { + const rows = pairRows(2023, anna, ben, ['10', '10', '10', '11', '11']); + const { rule } = ruleWith(rows); + expect(await rule.evaluate({ ownerId: 'user-1', target })).toEqual([]); + }); + + it('given 6 photos all on one day, then that year is skipped (MIN_DISTINCT_DAYS)', async () => { + const rows = pairRows(2023, anna, ben, ['10', '10', '10', '10', '10', '10']); + const { rule } = ruleWith(rows); + expect(await rule.evaluate({ ownerId: 'user-1', target })).toEqual([]); + }); + + it('given two competing pairs in the same year, then the higher-count pair wins that year', async () => { + const carl = { id: 'p3', name: 'Carl' }; + const dana = { id: 'p4', name: 'Dana' }; + const rows = [ + ...pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11']), // 6 photos + ...pairRows(2023, carl, dana, ['12', '12', '12', '13', '13', '13', '14']), // 7 photos + ]; + const { rule } = ruleWith(rows); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Carl & Dana'); + }); + + it('given qualifying pairs across 3 prior years, then only the top MAX_YEARS (2) survive, score-sorted', async () => { + const rows = [ + ...pairRows(2021, anna, ben, ['10', '10', '10', '11', '11', '11']), // 6 photos + ...pairRows(2022, anna, ben, ['10', '10', '10', '11', '11', '11', '12']), // 7 photos + ...pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11', '12', '12']), // 8 photos + ]; + const { rule } = ruleWith(rows); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(2); + expect(result.map((c) => c.context?.year)).toEqual([2023, 2022]); + }); + + it('given only current/future-year rows, then returns [] (prior years only)', async () => { + const rows = [ + ...pairRows(2026, anna, ben, ['10', '10', '10', '11', '11', '11']), + ...pairRows(2027, anna, ben, ['10', '10', '10', '11', '11', '11']), + ]; + const { rule } = ruleWith(rows); + expect(await rule.evaluate({ ownerId: 'user-1', target })).toEqual([]); + }); + + it('given a pet-pet pair and a person-pet pair each above threshold, then both qualify', async () => { + const rex = { id: 'p5', name: 'Rex' }; + const whiskers = { id: 'p6', name: 'Whiskers' }; + const rows = [ + ...pairRows(2022, rex, whiskers, ['10', '10', '10', '11', '11', '11']), // pet-pet, 2022 + ...pairRows(2023, anna, rex, ['10', '10', '10', '11', '11', '11']), // person-pet, 2023 + ]; + const { rule } = ruleWith(rows); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(2); + expect(result.map((c) => c.title).toSorted()).toEqual(['Anna & Rex', 'Rex & Whiskers']); + }); + + it('given more than 8 co-occurring photos, then assetIds is capped at 8', async () => { + const rows = pairRows(2023, anna, ben, ['10', '11', '12', '13', '14', '15', '16', '17', '18', '19']); + const { rule } = ruleWith(rows); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.assetIds).toHaveLength(8); + }); + + it('given the pair rows in reversed input order, then the dedupeKey/title are identical', async () => { + const rows = pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11']); + const { rule } = ruleWith(rows.toReversed()); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate).toMatchObject({ dedupeKey: 'people_together:p1:p2:2023-06', title: 'Anna & Ben' }); + }); + + it('given equal counts in a newer and an older year, then the newer year scores higher', async () => { + const rows = [ + ...pairRows(2022, anna, ben, ['10', '10', '10', '11', '11', '11']), + ...pairRows(2023, anna, ben, ['10', '10', '10', '11', '11', '11']), + ]; + const { rule } = ruleWith(rows); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(2); + expect(result[0].context).toMatchObject({ year: 2023 }); + expect(result[0].score).toBe(125); // 100 + 18 + recencyBonus(2023,2026)=7 + expect(result[1].context).toMatchObject({ year: 2022 }); + expect(result[1].score).toBe(124); // 100 + 18 + recencyBonus(2022,2026)=6 + expect(result[0].score).toBeGreaterThan(result[1].score); + }); + + it('given no rows, then returns []', async () => { + const { rule } = ruleWith([]); + expect(await rule.evaluate({ ownerId: 'user-1', target })).toEqual([]); + }); +}); diff --git a/server/src/services/memory-rules/people-together.rule.ts b/server/src/services/memory-rules/people-together.rule.ts new file mode 100644 index 0000000000000..8d0752b6ea382 --- /dev/null +++ b/server/src/services/memory-rules/people-together.rule.ts @@ -0,0 +1,72 @@ +import { DateTime } from 'luxon'; +import { AssetRepository, MemoryPeriodFace } from 'src/repositories/asset.repository'; +import { + medianTime, + monthName, + pairCounts, + recencyBonus, + sampleAssetsByTime, +} from 'src/services/memory-rules/curation.util'; +import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; + +/** "Anna & Ben" — a pair often photographed together in a past year's copy of this month. */ +export class PeopleTogetherMemoryRule implements MemoryRule { + readonly id = 'people_together'; + private static readonly TRIGGER_DAY = 20; + private static readonly MIN_ASSETS = 6; + private static readonly MIN_DISTINCT_DAYS = 2; + private static readonly MAX_YEARS = 2; + private static readonly ASSET_CAP = 8; + + constructor(private assetRepository: Pick) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { + if (target.day !== PeopleTogetherMemoryRule.TRIGGER_DAY) { + return []; + } + + const rows = await this.assetRepository.getMemoryFacesForPeriod(ownerId, { + months: [target.month], + takenBefore: target.endOf('day').toJSDate(), + }); + + const byYear = new Map(); + for (const row of rows) { + if (row.year >= target.year) { + continue; + } + const yearRows = byYear.get(row.year) ?? []; + yearRows.push(row); + byYear.set(row.year, yearRows); + } + + const mm = String(target.month).padStart(2, '0'); + const candidates: MemoryRuleCandidate[] = []; + + for (const [year, yearRows] of byYear) { + const top = pairCounts(yearRows)[0]; + if ( + !top || + top.assets.length < PeopleTogetherMemoryRule.MIN_ASSETS || + top.distinctDays < PeopleTogetherMemoryRule.MIN_DISTINCT_DAYS + ) { + continue; + } + + const count = top.assets.length; + candidates.push({ + ruleId: this.id, + dedupeKey: `people_together:${top.a.id}:${top.b.id}:${year}-${mm}`, + title: `${top.a.name} & ${top.b.name}`, + subtitle: `${count} photos together · ${monthName(target.month)} ${year}`, + score: 100 + count * 3 + recencyBonus(year, target.year), + assetIds: sampleAssetsByTime(top.assets, PeopleTogetherMemoryRule.ASSET_CAP), + memoryAt: DateTime.fromJSDate(medianTime(top.assets), { zone: 'utc' }), + visibleForDays: 7, + context: { year, personAId: top.a.id, personBId: top.b.id, count }, + }); + } + + return candidates.toSorted((left, right) => right.score - left.score).slice(0, PeopleTogetherMemoryRule.MAX_YEARS); + } +} diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 761c610a0f566..d71289b763c58 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -201,6 +201,7 @@ describe(ServerService.name, () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ], }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -223,6 +224,7 @@ describe(ServerService.name, () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ]); }); diff --git a/server/src/utils/preferences.spec.ts b/server/src/utils/preferences.spec.ts index c92d22b91f600..7f08d0c805b47 100644 --- a/server/src/utils/preferences.spec.ts +++ b/server/src/utils/preferences.spec.ts @@ -23,6 +23,7 @@ const getDefaultPreferences = (): UserPreferences => ({ favorites_throwback: true, on_this_day_place: true, season_recap: true, + people_together: true, }, }, people: { @@ -177,6 +178,7 @@ describe('getPreferences', () => { favorites_throwback: true, on_this_day_place: true, season_recap: true, + people_together: true, }); }); diff --git a/web/src/routes/admin/system-settings/MemoriesSettings.spec.ts b/web/src/routes/admin/system-settings/MemoriesSettings.spec.ts index 88390b8c9e12f..14d282113349a 100644 --- a/web/src/routes/admin/system-settings/MemoriesSettings.spec.ts +++ b/web/src/routes/admin/system-settings/MemoriesSettings.spec.ts @@ -102,6 +102,7 @@ describe('MemoriesSettings', () => { favorites_throwback: true, on_this_day_place: true, season_recap: true, + people_together: true, }, }, }); diff --git a/web/src/routes/admin/system-settings/MemoriesSettings.svelte b/web/src/routes/admin/system-settings/MemoriesSettings.svelte index 0af810dc82e76..37ee6cba33a78 100644 --- a/web/src/routes/admin/system-settings/MemoriesSettings.svelte +++ b/web/src/routes/admin/system-settings/MemoriesSettings.svelte @@ -17,6 +17,7 @@ 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ]; const disabled = $derived(featureFlagsManager.value.configFile); From 306c06e9333c5bb2029072b2f114fb60828554fc Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Thu, 16 Jul 2026 23:42:24 +0200 Subject: [PATCH 6/8] chore(memories): people-together generation test + roadmap status --- docs/plans/2026-07-15-memory-types-roadmap.md | 14 ++-- .../specs/services/memory.service.spec.ts | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-07-15-memory-types-roadmap.md b/docs/plans/2026-07-15-memory-types-roadmap.md index 7e41419057a29..16777348e4bf4 100644 --- a/docs/plans/2026-07-15-memory-types-roadmap.md +++ b/docs/plans/2026-07-15-memory-types-roadmap.md @@ -51,12 +51,14 @@ Spec: [`2026-07-15-memory-types-tier1-spec.md`](./2026-07-15-memory-types-tier1- ### 🟡 Tier 2 — Easy (planned) -| # | Idea | Surfaces | Effort | Impact | Notes | -| --- | ----------------------- | -------------------------------------------------------- | ------ | ---------- | ------------------------------------------------------------- | -| 5 | You & [person] | Two named people who co-occur often | 🟡 | High | Self-join `asset_face` on `assetId`, two `personId`s | -| 6 | Trip anniversary | A _past_ trip resurfaced on its anniversary | 🟡 | High | Reuse location-cluster logic anchored to the on-this-day date | -| 7 | Themed / classification | "Sunsets", "Food", "Beach days" from auto-classification | 🟡 | High | Query `tag_asset`; depends on classification being enabled | -| 8 | Shot on [camera/lens] | Gear nostalgia grouped by `make`/`model` | 🟡 | Low-medium | Niche; photographers only | +| # | Idea | Surfaces | Effort | Impact | Notes | +| --- | ----------------------- | -------------------------------------------------------- | ------ | ---------- | -------------------------------------------------------------------- | +| 5 | You & [person] | Two named people who co-occur often | 🟡 | High | **Shipped** — `people_together` (reframed to a pair, month-anchored) | +| 6 | Trip anniversary | A _past_ trip resurfaced on its anniversary | 🟡 | High | Reuse location-cluster logic anchored to the on-this-day date | +| 7 | Themed / classification | "Sunsets", "Food", "Beach days" from auto-classification | 🟡 | High | Query `tag_asset`; depends on classification being enabled | +| 8 | Shot on [camera/lens] | Gear nostalgia grouped by `make`/`model` | 🟡 | Low-medium | Niche; photographers only | + +Spec (#5): [`2026-07-16-memory-types-tier2-people-together-spec.md`](./2026-07-16-memory-types-tier2-people-together-spec.md) ### 🟠 Tier 3 — Medium (planned) diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index c1ee78df23c65..4d75a4bfd8466 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -758,6 +758,79 @@ describe(MemoryService.name, () => { }); }); + describe('onMemoriesCreate — people_together (end-to-end generation)', () => { + it('creates a people_together memory for two people co-occurring across a past June', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 6, day: 20 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person: anna } = await ctx.newPerson({ ownerId: user.id, name: 'Anna' }); + const { person: ben } = await ctx.newPerson({ ownerId: user.id, name: 'Ben' }); + + // 6 ungeotagged June 2023 photos across 6 distinct days (no city -> no on_this_day_place; + // none of them land on day 20 -> no on_this_day_place either), both people in every photo. + const assetIds: string[] = []; + for (let day = 5; day <= 10; day++) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-06-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true }); + assetIds.push(asset.id); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + + // Title/dedupeKey/context are ordered by person id (D6) — the ids are random UUIDs, so + // derive the expected order from the created people rather than hardcoding "Anna & Ben". + const [first, second] = [anna, ben].toSorted((a, b) => (a.id < b.id ? -1 : 1)); + + expect(memories).toEqual([ + expect.objectContaining({ + type: MemoryType.Rule, + memoryAt: expect.any(Date), + showAt: now.startOf('day').toJSDate(), + hideAt: now.startOf('day').plus({ days: 6 }).endOf('day').toJSDate(), + data: expect.objectContaining({ + ruleId: 'people_together', + title: `${first.name} & ${second.name}`, + subtitle: '6 photos together · June 2023', + context: expect.objectContaining({ + year: 2023, + count: 6, + personAId: first.id, + personBId: second.id, + }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...assetIds].toSorted()); + }); + + it('does not create a people_together memory below the minimum co-occurring photo count', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 6, day: 20 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person: anna } = await ctx.newPerson({ ownerId: user.id, name: 'Anna' }); + const { person: ben } = await ctx.newPerson({ ownerId: user.id, name: 'Ben' }); + + // Only 5 co-occurring photos across 5 distinct days — below MIN_ASSETS (6). + for (let day = 5; day <= 9; day++) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-06-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true }); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'people_together')).toBe(false); + }); + }); + describe('onMemoriesCleanup', () => { it('should run without error', async () => { const { sut } = setup(); From e05a6f9a1ba6eef62db8a193483fcd7868761ce8 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Fri, 17 Jul 2026 13:55:00 +0200 Subject: [PATCH 7/8] fix(memories): add people_together to e2e server-config fixture + docs --- docs/docs/features/memories.md | 1 + docs/docs/install/config-file.md | 1 + e2e/src/specs/server/api/server.e2e-spec.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/docs/features/memories.md b/docs/docs/features/memories.md index 86c3452a53eab..5a1e3e736809a 100644 --- a/docs/docs/features/memories.md +++ b/docs/docs/features/memories.md @@ -82,6 +82,7 @@ The built-in types each have a stable key used in configuration: | `favorites_throwback` | Favorite moments | Your favorite photos from this calendar month in a past year | | `on_this_day_place` | On this day, in a place | A past year's on-this-day photos when they cluster in one place | | `season_recap` | Season recap | A past meteorological season, shown when the new season begins | +| `people_together` | People together | Two people or pets often photographed together in a past year | All default to **on**. diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index c37fa6db6bf35..dc3d7198db545 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -325,6 +325,7 @@ The `memories` section configures generated memory retention and which memory ty - `favorites_throwback` — your favorite photos from this calendar month in a past year - `on_this_day_place` — a past year's on-this-day photos concentrated in one place - `season_recap` — a recap of a past meteorological season + - `people_together` — two people or pets often photographed together in a past year For example, to disable recent trips globally and leave the rest on: diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 969340bcaa5a5..169dac6041e4f 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -153,6 +153,7 @@ describe('/server', () => { 'favorites_throwback', 'on_this_day_place', 'season_recap', + 'people_together', ], }); }); From 400d0bbc22244c7a1d62dff86199537ddd19f425 Mon Sep 17 00:00:00 2001 From: Pierre Marais Date: Sun, 26 Jul 2026 11:30:44 +0200 Subject: [PATCH 8/8] =?UTF-8?q?feat(memories):=20Tier=203=20=E2=80=94=20tr?= =?UTF-8?q?ip=20anniversary,=20themed=20(smart=20search),=20video=20moment?= =?UTF-8?q?s=20(#812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(memories): spec tier 3 memory types (trip anniversary, themed, video moments) * docs(memories): fix 24 review defects in tier 3 spec Adversarial review found blockers verified against the code: - MemoryPeriodAsset new fields are required -> 4 fixture factories break tsc - themed firing daily with a multi-day window starves ALL rules (remainingSlots==0 returns early, memory.service.ts:130) - themed capped to 1 candidate makes older years permanently unreachable - searchSmart filters fileCreatedAt, not localDateTime (database.ts:725) - Luxon silently clamps Feb 29 -> Feb 28, breaking leap-year anniversaries - uncapped favoriteCount inverted the trip precedence invariant - recencyBonus maxes at 9 for past years, not 10 * feat(memories): return asset type and duration from getMemoryAssetsForPeriod Additive filter + two new required MemoryPeriodAsset fields, needed by the video_moments rule (slice 2). The fields are required deliberately so every construction site states the asset kind; the four existing rule-spec fixture factories are updated accordingly. TDD: 3 medium tests red (type/duration undefined, type filter not honored) -> 67/67 green. Unit suite 127/127 unchanged. SQL snapshot regenerated. * feat(memories): add video_moments memory type Videos filmed in this calendar month of a past year, triggered on day 8 with a 5-day window (staggered against days 1/15/20 so multi-day rules never contend for a trigger day). Memorability = a 3s-180s duration band, with favourites selected first and a capped favourite score bonus so it cannot outrank a trip anniversary. Registered at all 16 memory-type sites incl. the e2e server-config fixture that the server unit suite does not cover. TDD: spec red (module not found) -> 14/14 green; 217/217 server tests, tsc clean, eslint clean, web 3/3. Also corrects an unreachable '1 video' pluralization case in the spec: MIN_ASSETS=3 makes count===1 impossible for a fired candidate. * fix(mobile): force autoplay for videos in the memory viewer The memory card builds NativeVideoViewer with showControls: false, so a user who has viewer.autoPlayVideo disabled saw a frozen first frame with no way to start playback. Adds an opt-in forceAutoPlay flag (default false, so the ~40 other call sites keep honouring the global preference) and sets it in the memory card. Pre-existing since videos could already land in memories; user-visible now that video_moments surfaces them deliberately. Tests are construction-only by design: mounting DriftMemoryCard under flutter test throws two unavoidable environment exceptions (its remote image provider does not route through dart:io so MockHttpOverrides misses it, and NativeVideoViewer opens a platform video controller in initState). The risk here is the parameter contract and its default, which is what is covered; the single call-site literal is compile-checked by dart analyze. dart analyze --fatal-infos clean; 2/2 tests pass. * refactor(memories): extract shared trip detection and curation helpers Pulls the trip logic private to recent-trip.rule.ts into a pure, unit-tested trip.util.ts that trip_anniversary (slice 5) shares, and introduces the canonical placeKeyOf. placeKeyOf matters beyond tidiness: on_this_day_place built its place key as `${country ?? ''}:${city}` and recent_trip as `${country}:${city ?? ''}`. Slice 5 relies on both place-based rules emitting an IDENTICAL dedupe key so the engine's seenDedupeKeys collapses them; with the divergent formats that collapse would have silently never fired. recent_trip keeps its exact behaviour: curateTripAssets(assets, 10) preserves today's ceiling, and its spec passes byte-identical as the regression guard. findTripStartingOn is the one piece of genuinely new logic. TDD: spec red (module not found) -> 29/29 green; 171/171 memory-rules, tsc + eslint clean. * feat(memories): add trip_anniversary memory type Resurfaces a past multi-day trip on the anniversary of the day it began, detected fresh from location data so imported historical libraries are covered (recent_trip only ever fired for trips taken while already running Gallery). Cheap-probe-first: one on-this-day query prunes the common case before any cluster query runs. A trip is confirmed by starting the cluster window 5 days BEFORE the anniversary, so a cluster whose firstDate lands on the anniversary provably had no photos there beforehand -- a genuine arrival, not a mid-stay. Also makes on_this_day_place share the place_day: dedupe namespace so the engine's existing seenDedupeKeys collapses the two rules (they fire on the same signal and would otherwise take both daily slots with near-identical content). That collapse needs a TOTAL score precedence, so on_this_day_place's previously-unbounded count term is capped at 30: its max becomes 199 while trip_anniversary's floor is 275. Asserted as an invariant test derived from both rules' exported constants, not hardcoded numbers. Luxon silently clamps Feb 29 -> Feb 28 in non-leap years, so the rule skips years where the clamp changed the date rather than comparing the wrong day. TDD: spec red (module not found) -> 18/18 green, incl. mutation checks confirming each guard is load-bearing. 266/266 server, tsc + eslint clean, web 3/3. * feat(memories): add theme catalog and smart-search port for themed memories Themed memories ride the CLIP embeddings smart search already computes per asset -- no auto-classification dependency, no new ML model, no new infra. The rule (slice 7) depends only on a narrow ThemeSearchPort, so it unit-tests against a fake with no ML service and no DB. The adapter behind it memoizes each theme's embedding per (modelName, language), because encodeText is an ML HTTP call and createMemoryRules runs once per user per day -- encoding inside the rule would mean one round-trip per user per night. Theme text is user-independent, so it is encoded once and reused. searchAssetBuilder filters takenAfter/takenBefore on asset.fileCreatedAt (utils/database.ts:725-726), not localDateTime, which every memory rule buckets by. The adapter widens the window 2 days each side so no in-year asset is lost to that skew; the rule then filters precisely by localDateTime. resolveEmbedding never throws: smart search disabled or ML unreachable both yield null, which the rule treats as 'no candidates'. TDD: both specs red (module not found) -> 6/6 and 8/8 green; 260/260, tsc + eslint clean; SDK regenerated (themeMaxDistance only). * feat(memories): add themed memory type backed by smart search 'Sunsets from 2023' -- a curated 6-theme vocabulary matched against the CLIP embeddings smart search already computes. No auto-classification dependency. Fires on day 22 with one theme per MONTH. Both choices are load-bearing: a daily-firing rule with a multi-day window would hold both of the engine's 2 slots for a week at a time, and createRuleMemories returns early when remainingSlots is 0 -- meaning NO rule evaluates at all -- which could permanently starve trip_anniversary. Month-based rotation is also stable across year and leap boundaries, where day-of-year is not (365 % 6 != 0). Emits ALL qualifying years (capped at 3), not just the best: hasRuleMemory dedup happens in the engine after the rule returns, and recencyBonus always favours the newest year, so a single-candidate rule would make older years permanently unreachable once the newest year's memory existed. Re-filters results by localDateTime year because the port's bounds hit asset.fileCreatedAt, applying MIN_ASSETS after that filter. MemoryService memoizes the adapter behind an overridable createThemeSearchPort() seam, so a theme is encoded once per process rather than once per user per night, and slice 8's medium test can inject a stub without a live ML service. TDD: spec red (module not found) -> 13/13 green. Full server suite 4984 passed; memory.service.spec.ts untouched (its arg-agnostic spies still hold); tsc + eslint clean; web 3/3. * test(memories): end-to-end generation coverage for tier 3 memory types Eight medium tests against a real DB, each rule with a positive case and negatives that fail for a DIFFERENT reason than the positive passes: video_moments (wrong trigger day / out-of-band duration), trip_anniversary (single-day cluster fails MIN_TRIP_DAYS specifically, with home inference and asset count held fixed), themed (null embedding), plus a slot-budget test guarding the RULE_DAILY_LIMIT early-return that would otherwise let a multi-day rule starve every other rule. themed is exercised through the createThemeSearchPort seam with a stub, so no live ML service is needed. Also closes a pre-existing connection-pool leak in this file: every test's beforeEach opened a new Kysely pool via getKyselyDB() and none were closed. Harmless at 18 tests, it hit 'too many clients already' at 26. Adds an afterEach that destroys each pool. Documents themeMaxDistance in config-file.md and notes that Themes requires smart search. NOT DONE — threshold calibration (spec 4.2) needs an RC on the personal instance and is a human-run gate; 0.3 remains an unvalidated placeholder. * chore(open-api): regenerate Dart client for themeMaxDistance The TypeScript SDK was regenerated with the config field but the Dart client was not, so CI's OpenAPI Clients job failed on a dirty system_config_memories_dto.dart. make open-api generates BOTH clients; running only open-api-typescript leaves the Dart side stale. * feat(memories): person throwback — resurface a chapter with someone dormant 12+ months (#9) (#831) * docs(memories): spec for person_throwback memory type (#9) * docs(memories): fix trigger-day starvation, score band, and read-skew gaps in person_throwback spec * docs(memories): fix memoryAt type, trim dead fields, pin worked score, add impl-loop slices * feat(memories): add densestChapter window helper * docs(memories): correct i18n key shape and web spec sites; add slice plans * feat(memories): add dormant-person and chapter-window queries * feat(memories): add person_throwback memory rule * docs(memories): correct row-3 chapter year and chapterYear derivation * feat(memories): register person_throwback memory type * feat(web): expose person_throwback in memory settings * test(memories): end-to-end coverage for person_throwback + docs * docs: format slice plan files for docs prettier gate * fix(memories): make themed memories fire, and tune person_throwback dormancy `memories.themeMaxDistance` shipped at 0.3, which emits zero themed memories on a real library: a sweep over 65,685 embeddings returned nothing at 0.3 and 0.5, and genuine matches at 0.75. The default was calibrated on the scale of the image-to-image thresholds used elsewhere (duplicateDetection 0.01, facialRecognition 0.5), but this is a text-to-image distance, where CLIP's modality gap floors the value near ~0.6 even for a perfect match — so 0.3 is unreachable. Default is now 0.75, matching what the admin UI already recommends for `machineLearning.clip.maxDistance` (same metric, same embeddings). `updateConfig` persists only the diff against defaults, so installs that never overrode the value pick this up on upgrade. person_throwback's dormancy window was a hardcoded 12 months, which made the rule effectively unfireable: a 65k-asset library with 12 named people had a most-dormant gap of only 9 months. It is now `memories.personThrowbackDormancyMonths`, defaulting to 6 (range 1-120), so a wrong threshold is a settings change rather than a redeploy. Both knobs were previously config-file-only. They are now in Administration > Settings > Memories, indented directly under the memory type they affect and gated on that type being enabled, so it is unambiguous which type each knob tunes. Adds a test asserting all 12 server-registered types render a switch, guarding against the UI list drifting from the server registry. --- docs/docs/features/memories.md | 31 +- docs/docs/install/config-file.md | 12 + docs/plans/2026-07-15-memory-types-roadmap.md | 32 +- .../2026-07-19-memory-types-tier3-spec.md | 896 ++++++++++++++++++ ...2026-07-22-memory-person-throwback-spec.md | 647 +++++++++++++ .../2026-07-19-memory-types-tier3-slice-1.md | 113 +++ .../2026-07-19-memory-types-tier3-slice-2.md | 140 +++ .../2026-07-19-memory-types-tier3-slice-3.md | 69 ++ .../2026-07-19-memory-types-tier3-slice-4.md | 152 +++ .../2026-07-19-memory-types-tier3-slice-5.md | 142 +++ .../2026-07-19-memory-types-tier3-slice-6.md | 164 ++++ .../2026-07-19-memory-types-tier3-slice-7.md | 169 ++++ .../2026-07-19-memory-types-tier3-slice-8.md | 134 +++ .../2026-07-22-person-throwback-slice-1.md | 111 +++ .../2026-07-22-person-throwback-slice-2.md | 161 ++++ .../2026-07-22-person-throwback-slice-3.md | 149 +++ .../2026-07-22-person-throwback-slice-4.md | 93 ++ .../2026-07-22-person-throwback-slice-5.md | 85 ++ .../2026-07-22-person-throwback-slice-6.md | 115 +++ e2e/src/specs/server/api/server.e2e-spec.ts | 4 + i18n/en.json | 20 + .../asset_viewer/video_viewer.widget.dart | 9 +- .../widgets/memory/memory_card.widget.dart | 1 + .../lib/model/system_config_memories_dto.dart | 30 +- .../memory/memory_card_widget_test.dart | 53 ++ open-api/immich-openapi-specs.json | 14 + packages/sdk/src/fetch-client.ts | 4 + server/src/config.ts | 8 + server/src/dtos/system-config.dto.ts | 13 + server/src/queries/asset.repository.sql | 63 ++ server/src/queries/person.repository.sql | 38 + server/src/repositories/asset.repository.ts | 103 +- server/src/repositories/person.repository.ts | 50 + .../memory-rules/chapter.util.spec.ts | 97 ++ .../src/services/memory-rules/chapter.util.ts | 48 + .../favorites-throwback.rule.spec.ts | 3 + .../memory-rules/memory-type.metadata.spec.ts | 20 + .../memory-rules/memory-type.metadata.ts | 4 + .../memory-rules/memory-type.registry.spec.ts | 28 +- .../memory-rules/memory-type.registry.ts | 21 + .../memory-rules/month-recap.rule.spec.ts | 3 + .../on-this-day-place.rule.spec.ts | 24 +- .../memory-rules/on-this-day-place.rule.ts | 27 +- .../person-throwback.rule.spec.ts | 285 ++++++ .../memory-rules/person-throwback.rule.ts | 140 +++ .../services/memory-rules/recent-trip.rule.ts | 118 +-- .../memory-rules/season-recap.rule.spec.ts | 3 + .../memory-rules/theme-search.adapter.spec.ts | 171 ++++ .../memory-rules/theme-search.adapter.ts | 90 ++ .../memory-rules/theme-search.port.ts | 17 + .../memory-rules/theme.catalog.spec.ts | 52 + .../services/memory-rules/theme.catalog.ts | 19 + .../services/memory-rules/themed.rule.spec.ts | 230 +++++ .../src/services/memory-rules/themed.rule.ts | 88 ++ .../trip-anniversary.rule.spec.ts | 480 ++++++++++ .../memory-rules/trip-anniversary.rule.ts | 141 +++ .../services/memory-rules/trip.util.spec.ts | 245 +++++ server/src/services/memory-rules/trip.util.ts | 143 +++ .../memory-rules/video-moments.rule.spec.ts | 237 +++++ .../memory-rules/video-moments.rule.ts | 89 ++ server/src/services/memory.service.ts | 31 +- server/src/services/server.service.spec.ts | 8 + .../services/system-config.service.spec.ts | 18 + server/src/utils/preferences.spec.ts | 8 + .../repositories/asset.repository.spec.ts | 125 +++ .../specs/services/memory.service.spec.ts | 671 ++++++++++++- .../repositories/asset.repository.mock.ts | 2 + .../system-settings/MemoriesSettings.spec.ts | 110 ++- .../system-settings/MemoriesSettings.svelte | 43 + 69 files changed, 7494 insertions(+), 170 deletions(-) create mode 100644 docs/plans/2026-07-19-memory-types-tier3-spec.md create mode 100644 docs/plans/2026-07-22-memory-person-throwback-spec.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-1.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-2.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-3.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-4.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-5.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-6.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-7.md create mode 100644 docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-8.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-1.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-2.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-3.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-4.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-5.md create mode 100644 docs/superpowers/plans/2026-07-22-person-throwback-slice-6.md create mode 100644 mobile/test/widgets/memory/memory_card_widget_test.dart create mode 100644 server/src/services/memory-rules/chapter.util.spec.ts create mode 100644 server/src/services/memory-rules/chapter.util.ts create mode 100644 server/src/services/memory-rules/person-throwback.rule.spec.ts create mode 100644 server/src/services/memory-rules/person-throwback.rule.ts create mode 100644 server/src/services/memory-rules/theme-search.adapter.spec.ts create mode 100644 server/src/services/memory-rules/theme-search.adapter.ts create mode 100644 server/src/services/memory-rules/theme-search.port.ts create mode 100644 server/src/services/memory-rules/theme.catalog.spec.ts create mode 100644 server/src/services/memory-rules/theme.catalog.ts create mode 100644 server/src/services/memory-rules/themed.rule.spec.ts create mode 100644 server/src/services/memory-rules/themed.rule.ts create mode 100644 server/src/services/memory-rules/trip-anniversary.rule.spec.ts create mode 100644 server/src/services/memory-rules/trip-anniversary.rule.ts create mode 100644 server/src/services/memory-rules/trip.util.spec.ts create mode 100644 server/src/services/memory-rules/trip.util.ts create mode 100644 server/src/services/memory-rules/video-moments.rule.spec.ts create mode 100644 server/src/services/memory-rules/video-moments.rule.ts diff --git a/docs/docs/features/memories.md b/docs/docs/features/memories.md index 5a1e3e736809a..6050f40bd50c1 100644 --- a/docs/docs/features/memories.md +++ b/docs/docs/features/memories.md @@ -73,19 +73,30 @@ A user receives a memory type only when it is **both** globally available **and* The built-in types each have a stable key used in configuration: -| Type key | Setting label | Controls | -| --------------------- | ----------------------- | ----------------------------------------------------------------------- | -| `on_this_day` | On this day | "N years ago" photo memories | -| `birthday` | Birthdays | Birthday rule memories for named people | -| `recent_trip` | Recent trips | Recent trip rule memories | -| `month_recap` | This month | A past year's photos from this calendar month, shown early in the month | -| `favorites_throwback` | Favorite moments | Your favorite photos from this calendar month in a past year | -| `on_this_day_place` | On this day, in a place | A past year's on-this-day photos when they cluster in one place | -| `season_recap` | Season recap | A past meteorological season, shown when the new season begins | -| `people_together` | People together | Two people or pets often photographed together in a past year | +| Type key | Setting label | Controls | +| --------------------- | ----------------------- | --------------------------------------------------------------------------- | +| `on_this_day` | On this day | "N years ago" photo memories | +| `birthday` | Birthdays | Birthday rule memories for named people | +| `recent_trip` | Recent trips | Recent trip rule memories | +| `month_recap` | This month | A past year's photos from this calendar month, shown early in the month | +| `favorites_throwback` | Favorite moments | Your favorite photos from this calendar month in a past year | +| `on_this_day_place` | On this day, in a place | A past year's on-this-day photos when they cluster in one place | +| `season_recap` | Season recap | A past meteorological season, shown when the new season begins | +| `people_together` | People together | Two people or pets often photographed together in a past year | +| `video_moments` | Video moments | Videos you filmed in this month of a past year | +| `trip_anniversary` | Trip anniversaries | A past trip resurfaced on the anniversary of the day it began | +| `themed` | Themes | Photo themes like sunsets, food, and beach days, found automatically | +| `person_throwback` | Times with someone | A warm chapter with someone who has not appeared in your photos for a while | All default to **on**. +`themed` (Themes) additionally requires [Smart Search](/features/searching) to be enabled — it matches photos to a rotating monthly theme (sunsets, food, beach days, etc.) via CLIP embeddings. If smart search is disabled or the machine learning service is unavailable, Gallery simply skips the rule for that night; it does not surface an error. + +Two of these types are tunable in **Administration → Settings → Memories**, or via the [config file](/install/config-file): + +- **Theme match threshold** (`memories.themeMaxDistance`, default `0.75`) — how close a photo must be to the month's theme. This is a text-to-image CLIP distance, so it is much larger than a face-matching threshold; values under `0.5` usually yield no themed memories at all. +- **Person throwback dormancy** (`memories.personThrowbackDormancyMonths`, default `6`) — how long someone must be absent from your photos before `person_throwback` can resurface them. + ### Per-user toggles Each user manages their own memory types from **Account Settings → Features → Memories**. Below the master memory switch, a toggle appears for every memory type the admin has made available. Turning one off: diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index dc3d7198db545..b7fc13983a40d 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -163,8 +163,10 @@ The default configuration looks like this: }, "memories": { "birthday": true, + "personThrowbackDormancyMonths": 6, "recentTrips": true, "retentionDays": 365, + "themeMaxDistance": 0.75, "types": {} }, "metadata": { @@ -326,6 +328,10 @@ The `memories` section configures generated memory retention and which memory ty - `on_this_day_place` — a past year's on-this-day photos concentrated in one place - `season_recap` — a recap of a past meteorological season - `people_together` — two people or pets often photographed together in a past year + - `video_moments` — videos filmed in this calendar month in a past year + - `trip_anniversary` — a past trip resurfaced on the anniversary of the day it began + - `themed` — photo themes like sunsets, food, and beach days, found automatically via smart search + - `person_throwback` — a warm chapter with someone who has not appeared in your photos for a while For example, to disable recent trips globally and leave the rest on: @@ -337,6 +343,12 @@ For example, to disable recent trips globally and leave the rest on: } ``` +`themeMaxDistance` is the maximum CLIP cosine distance for the `themed` memory type (sunsets, food, beach days, etc. — found via smart search, not tags). It only takes effect for values `0 < x < 2`; the default is `0.75`. Setting it to `0` disables the quality gate entirely, so every smart-search result within a themed year is accepted regardless of similarity. + +This is a **text-to-image** distance, so it sits far higher than the image-to-image thresholds used for duplicate detection (`0.01`) or facial recognition (`0.5`) — CLIP's modality gap means even a perfect textual match rarely scores below `~0.6`. Values under `0.5` will typically produce **no themed memories at all**. If themed memories stop appearing, raise this in small steps rather than lowering it. `themed` requires smart search to be enabled — see the [Memories docs](/features/memories). + +`personThrowbackDormancyMonths` is how many months a person must be absent from your photos before the `person_throwback` memory type can resurface them. The default is `6`; valid values are `1`–`120`. Lower values surface more people — including some you still see regularly — while higher values concentrate the memory on people who have genuinely dropped out of your library. The gap itself is never shown in the memory and never affects ranking. + The config file only controls **global availability**. Within each available type, every user can still enable or disable it for themselves in their account settings. Disabling a type globally removes it from every user's settings and immediately hides existing unsaved memories of that type (saved memories are kept). The per-type switches do not control whether the nightly task runs. To disable all generated memories, set `nightlyTasks.generateMemories` to `false`. diff --git a/docs/plans/2026-07-15-memory-types-roadmap.md b/docs/plans/2026-07-15-memory-types-roadmap.md index 16777348e4bf4..5803a22bba4d5 100644 --- a/docs/plans/2026-07-15-memory-types-roadmap.md +++ b/docs/plans/2026-07-15-memory-types-roadmap.md @@ -51,30 +51,30 @@ Spec: [`2026-07-15-memory-types-tier1-spec.md`](./2026-07-15-memory-types-tier1- ### 🟡 Tier 2 — Easy (planned) -| # | Idea | Surfaces | Effort | Impact | Notes | -| --- | ----------------------- | -------------------------------------------------------- | ------ | ---------- | -------------------------------------------------------------------- | -| 5 | You & [person] | Two named people who co-occur often | 🟡 | High | **Shipped** — `people_together` (reframed to a pair, month-anchored) | -| 6 | Trip anniversary | A _past_ trip resurfaced on its anniversary | 🟡 | High | Reuse location-cluster logic anchored to the on-this-day date | -| 7 | Themed / classification | "Sunsets", "Food", "Beach days" from auto-classification | 🟡 | High | Query `tag_asset`; depends on classification being enabled | -| 8 | Shot on [camera/lens] | Gear nostalgia grouped by `make`/`model` | 🟡 | Low-medium | Niche; photographers only | +| # | Idea | Surfaces | Effort | Impact | Notes | +| --- | ----------------------- | -------------------------------------------------------- | ------ | ---------- | ------------------------------------------------------------------------------------ | +| 5 | You & [person] | Two named people who co-occur often | 🟡 | High | **Shipped** — `people_together` (reframed to a pair, month-anchored) | +| 6 | Trip anniversary | A _past_ trip resurfaced on its anniversary | 🟡 | High | **Shipped** — `trip_anniversary` | +| 7 | Themed / classification | "Sunsets", "Food", "Beach days" from auto-classification | 🟡 | High | **Shipped** — `themed` (reframed onto smart-search CLIP embeddings, not `tag_asset`) | +| 8 | Shot on [camera/lens] | Gear nostalgia grouped by `make`/`model` | 🟡 | Low-medium | Niche; photographers only | Spec (#5): [`2026-07-16-memory-types-tier2-people-together-spec.md`](./2026-07-16-memory-types-tier2-people-together-spec.md) ### 🟠 Tier 3 — Medium (planned) -| # | Idea | Surfaces | Effort | Impact | Notes | -| --- | ------------------------ | -------------------------------------------------- | ------ | -------------- | --------------------------------------------------------- | -| 9 | Someone you haven't seen | A person whose most-recent photo is > N months old | 🟠 | High but risky | Sensitivity risk (deceased people); needs a careful frame | -| 10 | Your pet [name] | Leverages Gallery's pet detection | 🟠 | High | Fork differentiator; needs a look at how pets are stored | -| 11 | Video moments | Memorable videos, not just stills | 🟠 | Medium | Query easy; memory viewer must play video well | +| # | Idea | Surfaces | Effort | Impact | Notes | +| --- | ------------------------ | -------------------------------------------------- | ------ | -------------- | ---------------------------------------------------------------------------------- | +| 9 | Someone you haven't seen | A person whose most-recent photo is > N months old | 🟠 | High but risky | **Shipped** — `person_throwback` (reframed: gap is a silent selector, never shown) | +| 10 | Your pet [name] | Leverages Gallery's pet detection | 🟠 | High | Fork differentiator; needs a look at how pets are stored | +| 11 | Video moments | Memorable videos, not just stills | 🟠 | Medium | **Shipped** — `video_moments` | ### 🔴 Tier 4 — Hard (north star) -| # | Idea | Surfaces | Effort | Impact | Notes | -| --- | ------------------------ | ----------------------------------------------------------- | ------ | --------- | --------------------------------------------------------------- | -| 12 | Semantic themes (CLIP) | "Time in nature", "City lights" with no tag, via embeddings | 🔴 | Very high | The real Apple/Google magic; keep the rule interface plug-ready | -| 13 | "Best of" aesthetic rank | Auto-picks your most beautiful shots | 🔴 | High | Needs an aesthetic-scoring model (none today) | -| 14 | Named trip stories | Full trip recap with map + day-by-day route | 🔴 | High | A feature, not a rule | +| # | Idea | Surfaces | Effort | Impact | Notes | +| --- | ------------------------ | ----------------------------------------------------------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 12 | Semantic themes (CLIP) | "Time in nature", "City lights" with no tag, via embeddings | 🔴 | Very high | The real Apple/Google magic; keep the rule interface plug-ready. Note: `themed` (PR #812) already rides smart-search CLIP embeddings — the remaining work here is vocabulary breadth and `themeMaxDistance` calibration, not new infrastructure | +| 13 | "Best of" aesthetic rank | Auto-picks your most beautiful shots | 🔴 | High | Needs an aesthetic-scoring model (none today) | +| 14 | Named trip stories | Full trip recap with map + day-by-day route | 🔴 | High | A feature, not a rule | ## Sequencing diff --git a/docs/plans/2026-07-19-memory-types-tier3-spec.md b/docs/plans/2026-07-19-memory-types-tier3-spec.md new file mode 100644 index 0000000000000..f93cd2f67ce1d --- /dev/null +++ b/docs/plans/2026-07-19-memory-types-tier3-spec.md @@ -0,0 +1,896 @@ +# Tier 3 Memory Types — Design & Test Spec + +> Implements roadmap items **#6 Trip anniversary**, **#7 Themed** (reframed onto smart search), +> and **#11 Video moments** from the +> [memory types roadmap](./2026-07-15-memory-types-roadmap.md). +> Stacked on **PR #792** (`feat/memory-types-tier2`) → **PR #789** (`feat/memory-types-tier1`). +> Branch: `feat/memory-types-tier3`. +> Approach: **test-driven, behavior-driven, full edge-case coverage.** +> Created 2026-07-19. Revised 2026-07-19 after adversarial review (24 defects fixed). +> Status: **spec — not yet implemented.** + +## 1. Goal & non-goals + +**Goal:** add three new `MemoryRule`s to the shipped rule engine: + +| Key | Memory | Trigger day | Window | +| ------------------ | ----------------------------------------------------------- | ---------------------------------------- | ------ | +| `trip_anniversary` | "Your trip to Rome" · "3 years ago · 42 photos over 5 days" | any (anniversary of a past trip's start) | 3–7 d | +| `themed` | "Sunsets from 2023" · "18 photos" | **22** | 5 d | +| `video_moments` | "Video moments from July 2023" · "6 videos" | **8** | 5 d | + +Plus one mobile fix: the memory viewer force-autoplays video regardless of the user's global +`viewer.autoPlayVideo` setting. + +**Non-goals (this batch):** + +- No engine change to `memory.service.ts` scheduling, `RULE_DAILY_LIMIT`, the multi-day slot cap, or + cleanup. All three rules are pure functions of `(ownerId, target, injected deps)`. +- No open-ended semantic discovery (roadmap #12). `themed` uses a **fixed, curated vocabulary**. +- No localization of memory _content_ (titles/subtitles stay English, matching every existing rule). +- No new ML model. `themed` reuses embeddings CLIP **already computed** for smart search. No + dependency on the fork's auto-classification / `tag_asset`. +- No mobile memory auto-advance timer (pre-existing gap, affects every memory type — §9). +- No web memory-viewer change: it already plays video with a duration-aware progress timer. +- No `MemoryType` enum or `memory` table schema change. + +## 2. Design decisions + +| # | Decision | Rationale | +| --- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| D1 | Trip anniversary **queries location data fresh**, not stored `recent_trip` memories | Covers imported historical libraries; `recent_trip` only fired for trips taken while already running Gallery | +| D2 | `themed` rides **smart-search CLIP embeddings**, not auto-classification | Embeddings already exist per asset; no classification dependency, no new infra | +| D3 | `themed` is **period-scoped to a past year**, not all-time | Keeps the throwback identity; each `(theme, year)` fires once, so dedupe is natural | +| D4 | Small vocabulary, **one theme per month** (not per day) | Bounds cost **and** prevents slot starvation (§3.6) — the critical fix from review | +| D5 | `trip_anniversary` and `on_this_day_place` **share a dedupe namespace** | They collide by construction; a shared key lets the engine's `seenDedupeKeys` collapse them with **zero engine change** (§3.3) | +| D6 | Mobile: **force autoplay** in the memory card | Videos in memories sit frozen on frame 1 unless global autoplay is on | +| D7 | `themed` returns **images only** | Clean separation from `video_moments` | +| D8 | Rules **export their constants** | Private statics make the §3.3 precedence invariant untestable (review #10) | + +## 3. Architecture + +### 3.1 Every site a new memory type touches + +Traced from `people_together` (added in PR #792). **All 16 sites**, per key: + +| # | File | What changes | +| --- | --------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| 1 | `server/src/services/memory-rules/memory-type.metadata.ts` | `MEMORY_TYPE_METADATA` entry | +| 2 | `server/src/services/memory-rules/memory-type.metadata.spec.ts` | assert key, kind, defaults | +| 3 | `server/src/services/memory-rules/memory-type.registry.ts` | `RULE_FACTORIES` entry (+ `MemoryRuleDeps` for `themed`) | +| 4 | `server/src/services/memory-rules/memory-type.registry.spec.ts` | factory builds the right rule + **completeness-guard count** | +| 5 | `server/src/services/memory-rules/.rule.ts` | the rule | +| 6 | `server/src/services/memory-rules/.rule.spec.ts` | unit/BDD spec | +| 7 | `server/src/utils/preferences.spec.ts` | default per-user type map gains the key | +| 8 | `server/src/services/server.service.spec.ts` | **TWO** `availableMemoryTypes` assertions (default + admin-disabled case) | +| 9 | `server/test/medium/specs/services/memory.service.spec.ts` | end-to-end generation medium test | +| 10 | `e2e/src/specs/server/api/server.e2e-spec.ts` | **`availableMemoryTypes` fixture — the server unit suite does NOT catch this** | +| 11 | `web/src/routes/admin/system-settings/MemoriesSettings.svelte` | hardcoded `memoryTypeKeys` array | +| 12 | `web/src/routes/admin/system-settings/MemoriesSettings.spec.ts` | switch count **and** the full `types` object literal in the save-payload test | +| 13 | `i18n/en.json` | 4 keys per type (§3.7) | +| 14 | `docs/docs/features/memories.md` | user-facing type list | +| 15 | `docs/docs/install/config-file.md` | `memories.types` config keys | +| 16 | `docs/plans/2026-07-15-memory-types-roadmap.md` | Status column → **Shipped** | + +**These files serialize the slices.** Rows 1, 4, 7, 8, 10, 11, 12 are shared lists touched by every +key, so Slices 2 → 5 → 7 **must run in order**, each adding exactly one key. Expected +`availableMemoryTypes` (registry order) after each: + +``` +base (tier2): on_this_day, birthday, recent_trip, month_recap, favorites_throwback, + on_this_day_place, season_recap, people_together [8] +after Slice 2: … people_together, video_moments [9] +after Slice 5: … video_moments, trip_anniversary [10] +after Slice 7: … trip_anniversary, themed [11] +``` + +The registry completeness guard asserts one rule per `kind: 'rule'` entry, so its expected count is +**8 → 9 → 10** rule-kind entries (`on_this_day` is not rule-kind). + +### 3.2 New / changed source files, with exact signatures + +| File | Change | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `src/repositories/asset.repository.ts` | `getMemoryAssetsForPeriod`: select `type` + `duration`, add optional `type` filter (§3.5) | +| `src/services/memory-rules/trip.util.ts` | **New**, pure (signatures below) | +| `src/services/memory-rules/recent-trip.rule.ts` | Refactor onto `trip.util.ts` | +| `src/services/memory-rules/on-this-day-place.rule.ts` | Shared `placeKeyOf` + shared dedupe namespace + capped score; export constants | +| `src/services/memory-rules/trip-anniversary.rule.ts` | **New** rule | +| `src/services/memory-rules/theme.catalog.ts` | **New** — vocabulary + `themeForMonth` | +| `src/services/memory-rules/theme-search.port.ts` | **New** — `ThemeSearchPort` | +| `src/services/memory-rules/theme-search.adapter.ts` | **New** — port impl | +| `src/services/memory-rules/themed.rule.ts` | **New** rule | +| `src/services/memory-rules/video-moments.rule.ts` | **New** rule | +| `src/services/memory-rules/memory-type.registry.ts` | `MemoryRuleDeps` gains `themeSearchPort`; 3 factories | +| `src/services/memory.service.ts` | Build + memoize the adapter; pass into `getMemoryRules` deps | +| `mobile/.../asset_viewer/video_viewer.widget.dart` | `NativeVideoViewer` gains `forceAutoPlay` (default `false`) | +| `mobile/.../memory/memory_card.widget.dart` | `DriftMemoryCard` passes `forceAutoPlay: true` | + +**`trip.util.ts` — exact exports** (constants exported per D8): + +```ts +export const BURST_WINDOW_MS = 2 * 60 * 1000; +export const SMALL_TRIP_MAX = 6; +export const HOME_DOMINANCE_RATIO = 1.25; + +export interface TripThresholds { + minAssets: number; + minDays: number; +} + +/** Canonical place key. BOTH place-based rules must use this (§3.3). */ +export const placeKeyOf = (country: string | null, city: string | null): string => + `${country ?? ''}:${city ?? ''}`.toLowerCase(); + +/** Top cluster, or null when it has no country or a different-country runner-up is within the ratio. */ +export const inferHome = (clusters: MemoryLocationCluster[]) => MemoryLocationCluster | null; + +export const isAwayFromHome = (item: MemoryLocationCluster, home: MemoryLocationCluster) => boolean; + +/** + * The strongest cluster whose UTC calendar day of `firstDate` equals `anniversary`'s UTC day, + * is away from home, and meets both thresholds. Ties break on higher assetCount, then on + * placeKeyOf ascending (deterministic). Returns null when none qualify or `clusters` is empty. + */ +export const findTripStartingOn = ( + clusters: MemoryLocationCluster[], + anniversary: DateTime, + home: MemoryLocationCluster, + thresholds: TripThresholds, +) => MemoryLocationCluster | null; + +/** Burst-collapse + per-day coverage sampling, capped at `cap`. Chronological, duplicate-free. */ +export const curateTripAssets = (assets: MemoryAsset[], cap: number) => string[]; +``` + +`curateTripAssets` ports `recent-trip.rule.ts`'s private logic verbatim **except** that the internal +`getTripTargetSize` ladder (7 / 8 / 10) is now additionally clamped by the `cap` argument, so a +caller can request fewer than 10. `recent_trip` calls it with `cap: 10` to preserve today's behavior +exactly. + +### 3.3 Cross-rule dedupe: the shared place-day namespace (D5) + +`trip_anniversary` and `on_this_day_place` fire on the same signal, and with `RULE_DAILY_LIMIT = 2` +they would occupy both slots with near-identical content. + +The engine already resolves this: `createRuleMemories` (`memory.service.ts:136-155`) builds +`seenDedupeKeys` over the **flattened, score-sorted candidate list from all rules** and skips any +candidate whose key was already taken. Two rules emitting the **same `dedupeKey`** therefore collapse +to the higher-scoring one, with **no engine change**. Both emit: + +``` +place_day:${year}-${mm}-${dd}:${placeKeyOf(country, city)} +``` + +⚠️ **The formats must match byte-for-byte or the collapse silently never fires.** Today the rules +diverge — `on_this_day_place` uses `` `${country ?? ''}:${city}` `` (`on-this-day-place.rule.ts:5`), +`recent_trip` uses `` `${country}:${city ?? ''}` `` (`recent-trip.rule.ts:61`). The canonical +`placeKeyOf` (§3.2) replaces both. + +**Scoring invariant (makes the precedence total, not probabilistic).** `on_this_day_place` currently +scores `100 + count * 3 + recencyBonus` with **no cap on `count`** (`on-this-day-place.rule.ts:57`) — +a heavily-photographed day scores 250+, beating any fixed trip base precisely when the trip was well +documented. Two coupled changes: + +1. **Cap `on_this_day_place`:** `100 + Math.min(count, 30) * 3 + recencyBonus`. Range **[112, 199]** + — min `count` is 4; `recencyBonus` maxes at **9**, not 10, because + `recencyBonus = max(0, 10 - (targetYear - year))` (`curation.util.ts:85`) and the rule skips + `year >= target.year`. +2. **Floor `trip_anniversary` above it:** base `260` ⇒ minimum `260 + 2*4 + 7 + 0 = 275`. + +**275 > 199** unconditionally. §6.3 asserts this by invoking **both real rules** with boundary +fixtures (possible only because D8 exports the constants). + +`on_this_day_place` ships in the **unmerged** PR #789, so the key and score changes carry no +migration risk; its spec pins exact scores and is updated in the same slice. + +> **Accepted edge:** on days 2..N of a multi-day trip's anniversary, `on_this_day_place` may still +> surface "On this day in Rome" while the trip memory lingers. The dedupe only collapses same-day +> collisions. Mild, different photos, not worth an engine change. + +### 3.4 `themed` dependency wiring — the `ThemeSearchPort` seam + +Two constraints: encoding must **not** happen per user (`createMemoryRules` runs once per user per +day, `memory.service.ts:203`), and the rule must stay unit-testable without ML or a DB. One narrow +port, with a memoizing adapter behind it: + +```ts +export interface ThemeSearchAsset { + id: string; + localDateTime: Date; +} + +export interface ThemeSearchPort { + /** null when smart search is disabled or the embedding cannot be produced. Never throws. */ + resolveEmbedding(themeKey: string, query: string): Promise; + /** Assets ordered by similarity, best first. `takenAfter`/`takenBefore` are JS Dates. */ + searchByEmbedding(params: { + ownerId: string; + embedding: string; + takenAfter: Date; + takenBefore: Date; + size: number; + }): Promise; +} +``` + +The rule converts Luxon → `Date` via `.toJSDate()` at this boundary. The rule is deliberately +**unaware of `maxDistance`**: the adapter already reads config (for `isSmartSearchEnabled`) and owns +the threshold, so tuning never touches rule code or rule tests. + +**Verified facts:** `encodeText(text, { modelName, language? })` returns the **pgvector-serialized +string** (`ClipTextualResponse = { [ModelTask.SEARCH]: string }`) — exactly what +`SmartSearchOptions.embedding` expects. `searchSmart` takes a precomputed embedding, needs **no ML +service**, and supports `userIds`, `takenAfter`/`takenBefore`, `type`, `visibility`. `maxDistance` +applies a cosine ceiling in SQL (`search.repository.ts:428-430`), active only when +`0 < maxDistance < 2`; the product default `clip.maxDistance` is `0` (disabled), so the adapter must +pass its own. `searchSmart` returns **no per-asset distance**, so quality gating rests entirely on +`maxDistance`. + +#### 3.4.1 ⚠️ `takenAfter`/`takenBefore` filter `fileCreatedAt`, not `localDateTime` + +`searchAssetBuilder` maps both bounds to **`asset.fileCreatedAt`** (`database.ts:725-726`), whereas +every memory rule and `getMemoryAssetsForPeriod` bucket by **`localDateTime`**. Year-bucketing on +`fileCreatedAt` would mis-assign assets near Jan 1 / Dec 31 and could give a "2023" memory a 2024 +`memoryAt`. + +**Resolution (two-step, both required):** + +1. The adapter searches a **2-day-widened** window (`takenAfter - 2d`, `takenBefore + 2d`) so no + in-year asset is missed by the `fileCreatedAt` skew. +2. The **rule** then filters the returned assets to exactly year `Y` by + `DateTime.fromJSDate(a.localDateTime, { zone: 'utc' }).year === Y`, and applies `MIN_ASSETS` + **after** that filter. + +This makes `localDateTime` authoritative for the memory's identity while `fileCreatedAt` is only a +coarse prefilter. + +### 3.5 Repository change — `getMemoryAssetsForPeriod` + +```ts +export interface MemoryPeriodAsset { + id: string; + localDateTime: Date; + year: number; + country: string | null; + city: string | null; + isFavorite: boolean; + type: AssetType; // NEW — required + duration: number | null; // NEW — required; milliseconds +} + +export interface MemoryPeriodOptions { + months: number[]; + day?: number; + favoritesOnly?: boolean; + type?: AssetType; // NEW + takenBefore: Date; +} +``` + +Implementation: add `'asset.type'` and `'asset.duration'` to `.select([...])`; add `type` to the +**destructured params** at `asset.repository.ts:892`; add +`.$if(type !== undefined, (qb) => qb.where('asset.type', '=', type!))`. + +> ⚠️ **Not source-compatible.** The two new fields are **required**, so the four existing spec +> fixture factories that build `MemoryPeriodAsset` literals fail `tsc` until updated: +> `month-recap.rule.spec.ts:7-16`, `favorites-throwback.rule.spec.ts:7-16`, +> `on-this-day-place.rule.spec.ts:13-23`, `season-recap.rule.spec.ts:9-19`. Slice 1 updates all four +> (`type: AssetType.Image, duration: null`). Required-not-optional is deliberate: it forces every +> construction site to state the asset kind. + +> ⚠️ `make sql` **deletes every query file when no database is running.** Run it only against a live +> dev DB and confirm the `asset.repository.sql` diff contains only the new columns and predicate. + +### 3.6 Slot budget — why `themed` must not fire daily + +`createRuleMemories` computes `remainingSlots = RULE_DAILY_LIMIT - existingRuleMemories.length` and +**returns early when it is 0** (`memory.service.ts:128-132`) — meaning **no rule evaluates at all** +that day. Every shipped rule fires on exactly one day per month; a `themed` rule firing _daily_ with +a multi-day window would hold both slots for a week at a time and could permanently starve +`trip_anniversary`, which can fall on any day. + +Therefore `themed` gets a **trigger day (22)** and a **5-day** window, and `video_moments` uses a +5-day window too. Resulting monthly coverage: + +| Rule | Trigger | Visible | +| --------------------- | ------- | ------- | +| `month_recap` | 1 | 1–7 | +| `video_moments` | **8** | 8–12 | +| `favorites_throwback` | 15 | 15–21 | +| `people_together` | 20 | 20–26 | +| `themed` | **22** | 22–26 | + +Days 13–14 and 27–end are always free, and no day carries more than two lingering multi-day rules — +so `trip_anniversary` can always win a slot outside 20–26. The residual contention on 20–26 is +inherent to `RULE_DAILY_LIMIT = 2` and is recorded as a follow-up (§9), not fixed here. + +### 3.7 i18n + +4 keys per type in `i18n/en.json` only (the repo's `i18n/` is shared by web and mobile; new keys need +only the EN source): + +``` +memory_type_ memory_type__description +admin.memory_type__setting admin.memory_type__setting_description +``` + +| key | user label | user description | +| ------------------ | ------------------ | --------------------------------------------------------------------- | +| `trip_anniversary` | Trip anniversaries | Past trips resurfaced on the anniversary of the day they began. | +| `themed` | Themes | Photo themes like sunsets, food, and beach days, found automatically. | +| `video_moments` | Video moments | Videos you filmed in this month of a past year. | + +## 4. Rule behavior + +### 4.1 `trip_anniversary` + +**Shape:** `class TripAnniversaryMemoryRule implements MemoryRule`, `id = 'trip_anniversary'`, +ctor `(assetRepository: Pick)`. + +**Exported constants** (D8): `MIN_PROBE_ASSETS = 3`, `MIN_PROBE_DOMINANCE = 0.6`, +`MAX_PROBE_YEARS = 4`, `GAP_DAYS = 5`, `TRIP_WINDOW_DAYS = 21`, `MIN_TRIP_ASSETS = 7`, +`MIN_TRIP_DAYS = 2`, `HOME_BASELINE_DAYS = 90`, `ASSET_CAP = 10`, `MAX_CANDIDATES = 2`, +`SCORE_BASE = 260`. + +`ASSET_CAP = 10` matches `curateTripAssets`'s own ceiling, so the cap is genuinely reachable and the +§6.3 cap assertion is not tautological (review #8). + +**Algorithm:** + +1. **Probe (1 cheap query; prunes the common case).** + `getMemoryAssetsForPeriod(ownerId, { months: [target.month], day: target.day, takenBefore: target.endOf('day').toJSDate() })`. + Bucket by year; drop `year >= target.year` and assets whose `city` is null/blank. Per year run + `dominantBy(assets, (a) => placeKeyOf(a.country, a.city))`; keep years where + `items.length >= MIN_PROBE_ASSETS && ratio >= MIN_PROBE_DOMINANCE`. + **No qualifying year ⇒ return `[]` immediately** (zero cluster queries). + Take the most recent `MAX_PROBE_YEARS` qualifying years. + +2. **Leap-year guard.** `const anniversary = target.set({ year: Y }).startOf('day')`. Luxon + **silently clamps** Feb 29 → Feb 28 in a non-leap year (`DateTime.utc(2024,2,29).set({year:2023})` + → `2023-02-28`, `isValid: true`), which would compare against the wrong day. **Skip the year when + `anniversary.day !== target.day || anniversary.month !== target.month`.** + +3. **Confirm (2 cluster queries per surviving year).** + - **Home:** `getMemoryLocationClusters(ownerId, { takenAfter: (anniversary - HOME_BASELINE_DAYS).toJSDate(), takenBefore: (anniversary - GAP_DAYS - 1 day).endOf('day').toJSDate() })` + → `inferHome(clusters)`; `null` ⇒ skip the year. + - **Trip window:** `getMemoryLocationClusters(ownerId, { takenAfter: (anniversary - GAP_DAYS).toJSDate(), takenBefore: (anniversary + TRIP_WINDOW_DAYS).endOf('day').toJSDate() })` + → `findTripStartingOn(clusters, anniversary, home, { minAssets: MIN_TRIP_ASSETS, minDays: MIN_TRIP_DAYS })`. + + **Why the pre-window works.** The window starts `GAP_DAYS` **before** the anniversary, so a + cluster whose `firstDate` lands on the anniversary day provably had **no photos at that place in + the preceding `GAP_DAYS`** — a genuine arrival, not a mid-stay. Such a cluster has no pre-window + assets, so its `assetCount`/`dayCount` are purely in-window and usable as the trip's size. + + **Day comparison is UTC-explicit** (review #13): `firstDate` is a raw + `min(asset."localDateTime")` (`asset.repository.ts:821`), **not** date-truncated. Compare with + `DateTime.fromJSDate(cluster.firstDate, { zone: 'utc' }).hasSame(anniversary, 'day')`. + +4. **Build.** `getMemoryAssetsForLocation(ownerId, { country, city, takenAfter: firstDate, takenBefore: lastDate })`, + then `curateTripAssets(assets, ASSET_CAP)`. + +**Candidate:** + +| Field | Value | +| ---------------- | -------------------------------------------------------------------------------------------- | +| `dedupeKey` | `place_day:${Y}-${mm}-${dd}:${placeKeyOf(country, city)}` — **shared** (§3.3) | +| `title` | `Your trip to ${city}, ${country}`; `Your trip to ${country}` when city is null | +| `subtitle` | `${n} year${n === 1 ? '' : 's'} ago · ${assetCount} photos over ${dayCount} days` | +| `score` | `SCORE_BASE + dayCount * 4 + Math.min(assetCount, 20) + recencyBonus(Y, target.year)` | +| `memoryAt` | `DateTime.fromJSDate(cluster.firstDate, { zone: 'utc' })` | +| `visibleForDays` | `Math.min(Math.max(dayCount, 3), 7)` | +| `context` | `{ year: Y, placeKey, placeLabel, country, city, assetCount, dayCount, tripStart, tripEnd }` | + +Emit at most `MAX_CANDIDATES`, sorted by score desc. + +### 4.2 `themed` + +**Shape:** `class ThemedMemoryRule implements MemoryRule`, `id = 'themed'`, +ctor `(themeSearchPort: ThemeSearchPort)`. + +**Catalog** (`theme.catalog.ts`) — 6 themes: + +| key | CLIP prompt | label | +| ------------ | ----------------------------- | ----------- | +| `sunset` | `a beautiful sunset` | Sunsets | +| `beach` | `a beach with sand and ocean` | Beach days | +| `food` | `a plate of food at a meal` | Food | +| `mountains` | `mountains and hiking trails` | Mountains | +| `snow` | `a snowy winter landscape` | Snow days | +| `city_night` | `a city skyline at night` | City lights | + +**Rotation is by month, not day-of-year** (review #22 — day-of-year is not stable across year or leap +boundaries since `365 % 6 !== 0`): + +```ts +export const themeForMonth = (month: number): Theme => THEMES[(month - 1) % THEMES.length]!; +``` + +Deterministic for a given calendar month, forever. Each theme recurs twice a year (6 themes, +12 months). + +**Exported constants:** `TRIGGER_DAY = 22`, `MAX_YEARS_BACK = 3`, `FETCH_SIZE = 40`, +`MIN_ASSETS = 8`, `ASSET_CAP = 16`, `VISIBLE_FOR_DAYS = 5`, `MAX_CANDIDATES = 3`, `SCORE_BASE = 70`. + +**Algorithm:** + +1. `if (target.day !== TRIGGER_DAY) return []` — §3.6. +2. `theme = themeForMonth(target.month)`. +3. `embedding = await port.resolveEmbedding(theme.key, theme.query)`; **`null` ⇒ return `[]`** without + calling `searchByEmbedding`. +4. For each `Y` in `target.year - 1 .. target.year - MAX_YEARS_BACK`: + - `takenAfter = DateTime.utc(Y, 1, 1).startOf('day')`, `takenBefore = DateTime.utc(Y, 12, 31).endOf('day')`. + **No `min(..., target)` clamp** — the year range excludes the current year, so the clamp was + dead code (review #11). + - `assets = await port.searchByEmbedding({ ownerId, embedding, takenAfter: takenAfter.toJSDate(), takenBefore: takenBefore.toJSDate(), size: FETCH_SIZE })` + (the adapter widens by 2 days — §3.4.1). + - **Filter to exactly year `Y` by `localDateTime`** (§3.4.1), then skip if `< MIN_ASSETS`. +5. Emit a candidate per qualifying year, sorted by score desc, **capped at `MAX_CANDIDATES` (3)**. + +> **Why not 1 candidate** (review #4): `hasRuleMemory` filtering happens in the **engine, after** the +> rule returns (`memory.service.ts:160-163`). `recencyBonus` monotonically favours the most recent +> year, so a 1-candidate rule would emit only the newest year forever — once `themed:sunset:2025` +> exists it is blocked, and 2024/2023 become **unreachable**. Emitting all qualifying years lets the +> engine fall through to the next-best. + +**Candidate:** + +| Field | Value | +| ---------------- | ----------------------------------------------------------------- | +| `dedupeKey` | `themed:${theme.key}:${Y}` | +| `title` | `${theme.label} from ${Y}` | +| `subtitle` | `${count} photos` | +| `score` | `SCORE_BASE + Math.min(count, 25) + recencyBonus(Y, target.year)` | +| `assetIds` | `sampleAssetsByTime(filtered, ASSET_CAP)` | +| `memoryAt` | `DateTime.fromJSDate(medianTime(filtered), { zone: 'utc' })` | +| `visibleForDays` | `VISIBLE_FOR_DAYS` | +| `context` | `{ year: Y, theme: theme.key, count }` | + +`count` = the number of assets surviving the year filter (**before** `ASSET_CAP` sampling). + +**Adapter (`MemoryThemeSearchAdapter`):** + +- `resolveEmbedding`: `getConfig({ withCache: true })`; return `null` when + `!isSmartSearchEnabled(config.machineLearning)`. Cache key + **`${modelName}:${language ?? 'default'}:${themeKey}`** — the language is part of the key so a + non-English CLIP deployment cannot serve a stale English embedding (review #16). This batch always + passes `language: undefined` (model default); the key still records it. On `encodeText` rejection, + log and return `null`. +- `searchByEmbedding`: `searchSmart({ page: 1, size }, { embedding, userIds: [ownerId], takenAfter: takenAfter - 2d, takenBefore: takenBefore + 2d, type: AssetType.Image, visibility: AssetVisibility.Timeline, maxDistance })`, + mapping rows to `{ id, localDateTime }`. + +**Threshold.** `memories.themeMaxDistance` in system config, tunable without a deploy and exposed in +**Administration → Settings → Memories**. + +> **Corrected after calibration (2026-07-26).** This shipped at `0.30`, which emits **zero** themed +> memories on a real library: calibration against 65,685 embeddings found `0.30` and `0.50` both +> return nothing, while `0.75` returns genuine matches. `0.30` was picked on the scale of the +> _image-to-image_ thresholds used elsewhere (`duplicateDetection` `0.01`, `facialRecognition` +> `0.5`), but this is a **text-to-image** distance — CLIP's modality gap floors it near `~0.6` even +> for a perfect match, so `0.30` is unreachable. The default is now **`0.75`**, matching the value +> the admin UI already recommends for `machineLearning.clip.maxDistance` (the same metric over the +> same embeddings). The Slice 8 calibration grid (`0.22 / 0.26 / 0.30 / 0.34`) was likewise on the +> wrong scale; a corrected sweep should span `0.55`–`0.95`. + +> **Accepted edge:** `searchSmart` inner-joins `smart_search`, so only ML-processed assets are +> reachable, and it does not verify a Preview `asset_file` (unlike the other memory queries). In +> practice thumbnailing precedes CLIP encoding, so an asset with an embedding effectively always has +> a preview. Documented, not defended. + +### 4.3 `video_moments` + +**Shape:** `class VideoMomentsMemoryRule implements MemoryRule`, `id = 'video_moments'`, +ctor `(assetRepository: Pick)`. + +**Exported constants:** `TRIGGER_DAY = 8`, `MIN_DURATION_MS = 3_000`, `MAX_DURATION_MS = 180_000`, +`MIN_ASSETS = 3`, `MAX_YEARS = 3`, `ASSET_CAP = 8`, `VISIBLE_FOR_DAYS = 5`, +`MAX_FAVORITE_BONUS = 10`, `SCORE_BASE = 60`. + +**Algorithm:** + +1. `if (target.day !== TRIGGER_DAY) return []`. +2. `getMemoryAssetsForPeriod(ownerId, { months: [target.month], type: AssetType.Video, takenBefore: target.endOf('day').toJSDate() })`. +3. Bucket by year; drop `year >= target.year`. +4. **Memorability band:** keep assets with + `duration !== null && duration >= MIN_DURATION_MS && duration <= MAX_DURATION_MS`. Drops accidental + taps and long screen recordings. `duration` is an **integer of milliseconds** + (`asset.table.ts:96-97`; the `ChangeDurationToInteger` migration converts `HH:MM:SS.mmm` → ms). +5. Skip years with `< MIN_ASSETS` survivors. +6. **Selection** — favourites first, deterministic: + - `favourites` and `others`, each sorted chronologically. + - If `favourites.length >= ASSET_CAP`: `selected = pickEvenlySpaced(favourites, ASSET_CAP)` + (review #15 — defines the previously-undefined negative-remainder case). + - Else: `selected = [...favourites, ...pickEvenlySpaced(others, ASSET_CAP - favourites.length)]`. + - Sort `selected` chronologically for the final `assetIds`. + +**Definitions (review #14 — both were ambiguous):** + +- **`count`** = survivors of the band filter for that year, **before** `ASSET_CAP` selection. +- **`favoriteCount`** = `isFavorite` survivors of the band filter, **before** selection. + +**Candidate:** + +| Field | Value | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `dedupeKey` | `video_moments:${Y}-${MM}` | +| `title` | `Video moments from ${monthName(month)} ${Y}` | +| `subtitle` | `${count} video${count === 1 ? '' : 's'}` | +| `score` | `SCORE_BASE + Math.min(count, 15) * 2 + Math.min(favoriteCount, MAX_FAVORITE_BONUS) * 3 + recencyBonus(Y, target.year)` | +| `memoryAt` | `DateTime.fromJSDate(medianTime(selected), { zone: 'utc' })` | +| `visibleForDays` | `VISIBLE_FOR_DAYS` | +| `context` | `{ year: Y, month, count, favoriteCount }` | + +**Worked score example** (pin this exact value in a test): 9 in-band videos, 3 favourites, `Y = 2023`, +`target.year = 2026` ⇒ `60 + min(9,15)*2 + min(3,10)*3 + max(0, 10-3)` = `60 + 18 + 9 + 7` = **94**. + +The favourite bonus is **capped** (review #9): uncapped, 80 favourite videos would score 339 and beat +`trip_anniversary`'s proven 275 floor. Capped maximum is `60 + 30 + 30 + 9 = 129 < 275`, preserving +§3.3's precedence. + +### 4.4 Mobile — force autoplay (D6) + +`NativeVideoViewer` gates playback on the global setting (`video_viewer.widget.dart:221-222`). +Because `DriftMemoryCard` builds it with `showControls: false`, a user with autoplay off gets a +**frozen first frame and no play button**. Changes: + +1. `NativeVideoViewer` gains `final bool forceAutoPlay;` (constructor default `false`) — no existing + call site changes behavior. +2. Gate becomes `if (widget.forceAutoPlay || autoPlayVideo || widget.asset.isMotionPhoto)`. +3. `DriftMemoryCard` (`memory_card.widget.dart:63-70`) passes `forceAutoPlay: true`. + +## 5. TDD discipline + +Every slice follows **red → green → refactor**: + +1. Write the listed tests first. Run the named command; confirm they fail **for the expected reason**. + Record the failure summary in the commit body. +2. Implement the minimal code to pass. +3. Re-run; confirm green. Run the slice's type/lint gate. +4. Commit with the slice's message. + +**Anti-tautology rules:** + +- Every rule spec pins **exact** `title`, `subtitle`, `dedupeKey` and at least one **exact `score`**. +- Every threshold gets a **both-sides boundary pair** (fails at `n-1`, passes at `n`). +- Every negative case must fail for a **different reason** than the positive passes — and where a + rule short-circuits early, assert the **downstream call was never made**, so the test cannot pass + because an earlier guard fired (this is the trap in the `trip_anniversary` cases). + +## 6. Test plan + +### 6.1 Conventions + +Unit specs live beside the rule; `vitest`; no DB. Deps are hand-rolled fakes or `vitest.fn()`. +`target` is a Luxon UTC `DateTime` built with explicit `DateTime.utc(...)`. Assert on the returned +`MemoryRuleCandidate[]`. `dedupeKey` **stability**: the same period evaluated on two different +`target` days yields an identical key. + +### 6.2 `trip.util.spec.ts` (pure — Slice 4) + +**`placeKeyOf`** — the §3.3 collision contract: + +- `('Italy','Rome')` → `'italy:rome'`; `('ITALY','ROME')` → same (case-insensitive). +- `(null,'Rome')` → `':rome'`; `('Italy',null)` → `'italy:'` — **both null positions**, exactly what + the two divergent implementations got wrong. + +**`inferHome`:** top cluster when dominant · `null` when top has no `country` · `null` when a +different-country runner-up has `assetCount >= top/1.25` · top when the runner-up is **same-country** +· `null` on `[]`. + +**`isAwayFromHome`:** different country → true · same country + different non-null city → true · +same country + same city → false · home city null → false · candidate city null → false. + +**`findTripStartingOn`:** + +- picks a cluster whose `firstDate` is on the anniversary day and meets both thresholds. +- **rejects `firstDate` one day before** and **one day after** the anniversary. +- **UTC boundary:** a `firstDate` of `23:30Z` on the anniversary day **qualifies** (proves the + comparison is UTC-day-based, not instant-based — review #13). +- boundary pairs: rejects `assetCount = minAssets - 1`, accepts `= minAssets`; rejects + `dayCount = 1`, accepts `= 2`. +- rejects a qualifying cluster that is **not** away from home. +- two qualifying clusters on the same day → returns the higher `assetCount`; equal counts → the + lower `placeKeyOf` (deterministic). +- `null` on `[]`. + +**`curateTripAssets`:** collapses within the 2-minute burst window · returns all when +`<= SMALL_TRIP_MAX` after collapsing · covers distinct days before topping up · **never exceeds the +`cap` argument**, including `cap` below the internal ladder (e.g. `cap: 4`) · output is chronological +and duplicate-free. + +### 6.3 `trip-anniversary.rule.spec.ts` (BDD given/when/then) + +Every case needs **three** fixtures across **two** repository methods with different date windows, so +these are written given/when/then to keep setup, trigger and assertion distinct. + +- **Fires.** _Given_ a probe with one dominant city in `Y`, a home baseline in a different country, + and a trip cluster whose `firstDate` is the anniversary with 8 assets over 3 days, _When_ evaluated + on the anniversary, _Then_ exactly one candidate with pinned `title`, `subtitle`, `score`, + `memoryAt` (= `firstDate`), `visibleForDays`, and a `place_day:` `dedupeKey`. +- **Shared-key contract.** _Given_ the **same** probe fixture fed to both rules, _When_ both evaluate, + _Then_ `trip_anniversary`'s `dedupeKey` **equals** `on_this_day_place`'s for that year/day/place — + asserted against the other rule's **real output**, never a hand-written string. +- **Scoring invariant.** _Given_ `trip_anniversary` at its **minimum** (`MIN_TRIP_DAYS`, + `MIN_TRIP_ASSETS`, oldest year) and `on_this_day_place` at its **maximum** (`count >= 30`, most + recent past year), _Then_ the trip score is strictly greater. Uses both rules' **real** scoring via + exported constants, so retuning either cannot silently invert §3.3. +- **Probe short-circuit.** _Given_ no past year with a dominant city, _Then_ `[]` **and + `getMemoryLocationClusters` was never called**. +- **Ambiguous home.** _Given_ a probe that **does** qualify, a baseline whose different-country + runner-up is within 1.25×, **and a trip cluster that would otherwise qualify**, _Then_ `[]` **and + `getMemoryAssetsForLocation` was never called** — proving the ambiguity guard fired, not the probe. +- **Mid-stay rejection.** _Given_ a qualifying probe and home, and a trip cluster whose `firstDate` is + the day **before** the anniversary, _Then_ `[]` and no asset fetch. +- Boundary pairs: `dayCount` 1 vs 2; `assetCount` 6 vs 7; probe `ratio` just below vs at + `MIN_PROBE_DOMINANCE`; probe `items.length` 2 vs 3. +- **Leap year.** _Given_ `target = 2024-02-29` and a qualifying 2023 trip, _Then_ the 2023 year is + **skipped** (Luxon would clamp to Feb 28); _and_ `target = 2024-02-29` with a qualifying **2020** + (leap) trip still fires. +- Skips the current year and future-dated assets. +- Caps candidates at `MAX_CANDIDATES`; caps assets at `ASSET_CAP` (reachable — §4.1). +- Evaluates at most `MAX_PROBE_YEARS` years (assert cluster-query call count). +- `subtitle` pluralization: `1 year ago` vs `3 years ago`. +- City `null` → title falls back to country only. + +### 6.4 `themed.rule.spec.ts` (fake `ThemeSearchPort`) + +- **Trigger day:** returns `[]` on days 1, 8, 15, 21, 23; fires on 22. +- **Fires:** pinned `title` (`Sunsets from 2023`), `subtitle`, exact `score`, `dedupeKey`, + `visibleForDays: 5`. +- **Rotation:** `themeForMonth` pinned for all 12 months; month 1 and month 7 give the same theme; + the same month in different years gives the same theme (stable across year and leap boundaries). +- **Disabled path:** `resolveEmbedding` → `null` ⇒ `[]` **and `searchByEmbedding` never called**. +- `resolveEmbedding` rejects ⇒ `[]`, no throw. +- **Year filter (§3.4.1):** _Given_ the port returns assets whose `localDateTime` falls in `Y-1` and + `Y+1` as well as `Y`, _Then_ only the `Y` assets are counted, `count` reflects the filtered set, and + `MIN_ASSETS` is applied **after** filtering (a year with 8 raw but 7 in-year assets does **not** + fire). +- Boundary pair: `MIN_ASSETS - 1` in-year ⇒ `[]`; exactly `MIN_ASSETS` ⇒ fires. +- Searches exactly `MAX_YEARS_BACK` years, never the current year. +- **Multi-year:** two qualifying years ⇒ **both** candidates returned (sorted desc), capped at + `MAX_CANDIDATES` — the review-#4 regression guard. +- **Non-tautological ordering:** _Given_ the port returns assets in **similarity** (non-chronological) + order, _Then_ `assetIds` equals a **pinned** id array that is chronological and capped at + `ASSET_CAP` — proving `sampleAssetsByTime` reordered them. +- Passes `size: FETCH_SIZE` and `Date` (not `DateTime`) bounds to the port. + +### 6.5 `theme-search.adapter.spec.ts` + +- `resolveEmbedding` → `null` when `isSmartSearchEnabled` is false; `encodeText` **not** called. +- `encodeText` called **once** for two identical `(modelName, language, themeKey)` requests. +- called **again** when `clip.modelName` changes; **and again** when `language` changes (cache key + includes both). +- `encodeText` rejects ⇒ `null`, logged, no throw. +- `searchByEmbedding` forwards `userIds: [ownerId]`, `type: Image`, `visibility: Timeline`, `size`, + `maxDistance` from config, and **2-day-widened** bounds (assert the exact widened Dates); maps rows + to `{ id, localDateTime }`. + +### 6.6 `video-moments.rule.spec.ts` + +- Returns `[]` on days 1, 7, 9, 15, 22; fires on 8. +- Fires: pinned `title`, `subtitle`, `dedupeKey`, `visibleForDays`, and the **worked score 94** + from §4.3. +- Duration band: `2_999` excluded, `3_000` included, `180_000` included, `180_001` excluded, `null` + excluded. +- Boundary pair: `MIN_ASSETS - 1` survivors ⇒ `[]`; exactly `MIN_ASSETS` ⇒ fires. +- **Selection (given/when/then).** _Given_ 4 favourites and 10 non-favourites in band, _When_ + evaluated, _Then_ `assetIds` equals a **pinned** array containing all 4 favourites plus 4 + evenly-spaced others, in chronological order. +- **Favourites exceed the cap:** _Given_ 12 favourites, _Then_ exactly `ASSET_CAP` ids, evenly spaced. +- `favoriteCount` is capped in the score: 20 favourites and 10 favourites yield the **same** score. +- `count`/`favoriteCount` are pre-selection (a year with 12 in-band videos reports `12 videos` in the + subtitle despite 8 `assetIds`). +- Pluralization: exercise the plural branch at `3` and `6` videos. **The singular branch is + unreachable through the public API** — `MIN_ASSETS = 3` means a year with one survivor never fires, + and `count` is that same pre-selection survivor set, so a fired candidate can never report + `count === 1`. Keep the singular-safe ternary in the candidate for consistency with the other + rules, but do not assert an unreachable `1 video` subtitle. +- Skips current/future years; caps at `MAX_YEARS`. +- Passes `type: AssetType.Video` to the repository (assert the call argument). + +### 6.7 Registry, metadata & shared lists + +- `memory-type.metadata.spec.ts`: new key exists, `kind: 'rule'`, `defaultEnabled: true`, + `adminConfigurable: true`; in `buildDefaultMemoryTypeMap()`; + `getMemoryTypeKeyForMemory(MemoryType.Rule, { ruleId: key })` round-trips. +- `memory-type.registry.spec.ts`: the key builds a rule whose `id` equals it; **update the + completeness-guard count** per §3.1. +- `preferences.spec.ts`: default `memories.types` gains the key. +- `server.service.spec.ts`: **both** `availableMemoryTypes` assertions (default ~line 196 and the + admin-disabled case ~line 220) per §3.1's expected arrays. +- `MemoriesSettings.spec.ts`: switch count **and** the full `types` object literal in the save-payload + test (~lines 92-107). +- `e2e/src/specs/server/api/server.e2e-spec.ts`: the `availableMemoryTypes` fixture. + +### 6.8 Medium tests (real DB) + +**`asset.repository.spec.ts`** — extend `describe('getMemoryAssetsForPeriod')`: + +- rows carry `type` and `duration`. +- `type: AssetType.Video` returns only videos; omitting `type` returns both (proves the filter is + opt-in and existing callers are unaffected). +- a video with `duration: null` is still returned (the band filter is the rule's job). +- an asset **exactly on** `takenBefore` is included (SQL uses `<=`). + +**`memory.service.spec.ts`** — one positive + one negative per rule. +`seedRuleAsset` (`memory.service.spec.ts:41-62`) currently accepts only +`{ ownerId, localDateTime, city, country, isFavorite }`; **extend it with +`type?: AssetType, duration?: number`** (verify `ctx.newAsset` supports both; if not, set them via a +follow-up update in the helper). + +- `video_moments`: videos in the target month of a past year, evaluated on **day 8** ⇒ memory row with + expected `data.ruleId`/`title`/assets. **Negative:** identical data on day 7 ⇒ no memory. +- `trip_anniversary`: away-from-home multi-day cluster plus home baseline ⇒ memory. + **Negative:** same cluster with `dayCount: 1` ⇒ no memory. +- `themed`: **injection seam** — `MemoryService` exposes the port via a + `protected createThemeSearchPort(): ThemeSearchPort` factory method (review #2). The medium test + subclasses `MemoryService` to return a stub, so **no live ML service is required**. Positive: stub + returns an embedding + assets ⇒ memory. Negative: stub returns `null` embedding ⇒ no memory. +- **Slot budget:** on a day when two multi-day memories are already visible, `createRuleMemories` + inserts nothing (guards §3.6). + +### 6.9 Mobile + +- `NativeVideoViewer` defaults `forceAutoPlay` to `false`. +- `DriftMemoryCard` constructs `NativeVideoViewer` with `forceAutoPlay: true` for a video asset. + +> **Honest constraint:** `NativeVideoViewer` initialises a platform video controller on mount, so a +> full pump may be flaky in CI. If unstable, downgrade to a **construction-only** assertion (build the +> tree without pumping frames) and record manual verification in the PR. Do **not** paper over a flake +> with a retry — per fork policy, flakes are fixed at the root or the test is scoped down deliberately. + +### 6.10 Edge-case catalog + +| # | Edge case | Handling | Test | +| --- | ------------------------------------------------- | ---------------------------------------------------- | ------------- | +| 1 | No geotagged on-this-day assets | probe short-circuits, zero cluster queries | 6.3 | +| 2 | Ambiguous home | `inferHome` → `null`; asserted via no asset fetch | 6.2, 6.3 | +| 3 | Mid-stay (arrived before the anniversary) | `GAP_DAYS` pre-window ⇒ `firstDate` guard | 6.2, 6.3 | +| 4 | **Leap year — Feb 29 anniversary** | explicit clamp guard skips invalid `(Y, Feb 29)` | 6.3 | +| 5 | `firstDate` late in the UTC day (23:30Z) | UTC-day `hasSame` comparison | 6.2 | +| 6 | Trip longer than `TRIP_WINDOW_DAYS` | span truncates; still fires | 6.3 | +| 7 | trip vs `on_this_day_place` same day | shared key; proven score precedence | 6.3 | +| 8 | Smart search disabled / ML down | `resolveEmbedding` → `null` ⇒ `[]`, no search | 6.4, 6.5 | +| 9 | CLIP model **or language** changed | cache key includes both | 6.5 | +| 10 | **`fileCreatedAt` vs `localDateTime` skew** | widened search + in-rule year filter | 6.4, 6.5 | +| 11 | **Themed year-boundary assets (Dec 31/Jan 1)** | in-rule `localDateTime` year filter | 6.4 | +| 12 | **Themed newest year already generated** | emit up to 3 years so older years stay reachable | 6.4 | +| 13 | Video `null` duration | excluded by band; query still returns it | 6.6, 6.8 | +| 14 | 1-second clip / 10-minute recording | duration band boundaries | 6.6 | +| 15 | **Favourites exceed `ASSET_CAP`** | evenly-spaced truncation | 6.6 | +| 16 | **Uncapped favourite bonus inverting precedence** | `MAX_FAVORITE_BONUS` cap | 6.6 | +| 17 | **Slot starvation by multi-day rules** | trigger days + 5-day windows (§3.6) | 6.8 | +| 18 | **Two new rules colliding (day 8 / 22)** | distinct trigger days; trip may still coincide | 6.8 | +| 19 | **User with zero assets** | every rule returns `[]`, no throw | 6.3, 6.4, 6.6 | +| 20 | **Empty candidate set → `medianTime([])`** | guarded by `MIN_ASSETS` before any `medianTime` call | 6.4, 6.6 | +| 21 | **Asset exactly on `takenBefore`** | SQL `<=` inclusive | 6.8 | +| 22 | Existing rules see new required fields | 4 fixture factories updated in Slice 1 | 6.8, gates | +| 23 | Duplicate ids within a candidate | `curateTripAssets` / `sampleAssetsByTime` dedupe | 6.2 | +| 24 | Future-dated assets | `takenBefore: target.endOf('day')` on every query | 6.3, 6.6 | + +## 7. Verification gates + +```bash +cd server && pnpm test -- --run src/services/memory-rules/ +cd server && pnpm test -- --run src/utils/preferences.spec.ts src/services/server.service.spec.ts +cd server && pnpm test:medium -- --run test/medium/specs/repositories/asset.repository.spec.ts +cd server && pnpm test:medium -- --run test/medium/specs/services/memory.service.spec.ts +make check-server +make lint-server +cd server && npx prettier --check "src/services/memory-rules/**" "src/repositories/asset.repository.ts" +cd web && pnpm test -- --run src/routes/admin/system-settings/MemoriesSettings.spec.ts +make check-web +cd mobile && dart analyze --fatal-infos lib test && dart format --set-exit-if-changed . +npx prettier --check "docs/**/*.md" "i18n/en.json" +``` + +Plus: `make sql` **against a running dev DB only**; feature branches trigger **no** CI on push, so +dispatch explicitly with `gh workflow run test.yml --ref feat/memory-types-tier3` and check +**job-level** status (a run-level "success" can hide a failed job). + +## 8. Implementation slices (for `/impl-loop`) + +**Slices 2 → 5 → 7 are strictly ordered** (they mutate the same shared lists — §3.1). Slices 1, 3, 4, +6 are free to move within their chains. + +### Slice 1 — `getMemoryAssetsForPeriod` returns `type` + `duration` + +**Files:** `asset.repository.ts` (interfaces + query); **the 4 fixture factories** in +`month-recap.rule.spec.ts`, `favorites-throwback.rule.spec.ts`, `on-this-day-place.rule.spec.ts`, +`season-recap.rule.spec.ts`; medium spec. +**Red:** `cd server && pnpm test:medium -- --run test/medium/specs/repositories/asset.repository.spec.ts` — §6.8. +**Green:** add columns + destructure `type` + `$if` predicate; update the 4 factories. +**Verify:** medium green; **all tier-1 rule specs green**; `make check-server`; `make sql` on a live DB. +**Commit:** `feat(memories): return asset type and duration from getMemoryAssetsForPeriod` + +### Slice 2 — `video_moments` (end-to-end; pattern-setter for the 16 sites) + +**Red:** `cd server && pnpm test -- --run src/services/memory-rules/video-moments.rule.spec.ts` — §6.6. +**Green:** the rule + all 16 registration sites; expected arrays per §3.1 (**9** keys). +**Verify:** §7 server + web gates; e2e fixture updated. +**Commit:** `feat(memories): add video_moments memory type` + +### Slice 3 — Mobile force-autoplay + +**Red:** §6.9. **Green:** `forceAutoPlay` + pass `true`. +**Verify:** `dart analyze --fatal-infos lib test`; `dart format --set-exit-if-changed .`. +**Commit:** `fix(mobile): force autoplay for videos in the memory viewer` + +### Slice 4 — `trip.util.ts` + `recent_trip` refactor + +**Files:** `trip.util.ts` + spec; refactor `recent-trip.rule.ts` onto it (deleting its private +`curateTripAssets`/`collapseBurstAssets`/`groupAssetsByDay`/`pickDayCoverage`/`pickEvenlySpaced`), +calling `curateTripAssets(assets, 10)`. +**Red:** `cd server && pnpm test -- --run src/services/memory-rules/trip.util.spec.ts` — §6.2. +**Green:** implement, then rewire `recent-trip.rule.ts`. +**Verify:** `recent-trip.rule.spec.ts` passes **unchanged** — the regression guard. +**Commit:** `refactor(memories): extract shared trip detection and curation helpers` + +### Slice 5 — `trip_anniversary` + shared place-day dedupe + +**Files:** `trip-anniversary.rule.ts` + spec; `on-this-day-place.rule.ts` (**three** changes: shared +`placeKeyOf`, `place_day:` dedupe namespace, `Math.min(count, 30)` score cap; export constants) + its +spec; the 16 sites (**10** keys). +**Red:** §6.3. +**Verify:** the shared-key **and** scoring-invariant tests pass; `on-this-day-place.rule.spec.ts` +updated for the new key format and capped score **only**. +**Commit:** `feat(memories): add trip_anniversary memory type` + +### Slice 6 — Theme catalog + port + adapter + +**Files:** `theme.catalog.ts` (+ rotation spec), `theme-search.port.ts`, `theme-search.adapter.ts` + +spec; `config.ts` + `system-config.dto.ts` for `memories.themeMaxDistance`; SDK regeneration. +**Red:** §6.5. +**Verify:** `make check-server`; regenerate the SDK (`cd server && pnpm build && pnpm sync:open-api`, +then `make open-api-typescript`) and commit the generated output. +**Commit:** `feat(memories): add theme catalog and smart-search port for themed memories` + +### Slice 7 — `themed` (end-to-end) + +**Files:** `themed.rule.ts` + spec; `memory.service.ts` (the `protected createThemeSearchPort()` +factory + memoized field, passed into `getMemoryRules` deps); `memory-type.registry.ts` +(`MemoryRuleDeps.themeSearchPort`); the 16 sites (**11** keys). +**Red:** §6.4. +**Verify:** existing `memory.service.spec.ts` spies on `getMemoryRules`/`createRuleMemories` are +arg-agnostic — **confirm they stay green unchanged**; full server suite. +**Commit:** `feat(memories): add themed memory type backed by smart search` + +### Slice 8 — Medium tests, calibration, docs + +**Files:** medium `memory.service.spec.ts` (§6.8, incl. the `seedRuleAsset` extension and the slot +test), `docs/docs/features/memories.md`, `docs/docs/install/config-file.md`, roadmap Status. +**Calibration (gates merge):** deploy an RC to the personal instance and tune +`memories.themeMaxDistance`: + +1. Run each of the 6 themes at `0.22 / 0.26 / 0.30 / 0.34` against a real library. +2. Record per-theme result counts and eyeball precision on the top 16. +3. Pick the highest threshold at which **no theme shows obvious false positives** in its top 16; + record the choice and counts in the PR. +4. If a theme cannot be made precise at any threshold, **drop it from the catalog** rather than + loosening the global default. + +**Verify:** every gate in §7. +**Commit:** `docs(memories): document tier 3 memory types and calibrate theme threshold` + +### Dependency graph + +``` +Slice 1 ──▶ Slice 2 ──────────────┐ +Slice 3 (independent) │ +Slice 4 ──▶ Slice 5 ──────────────┤──▶ Slice 8 +Slice 6 ──▶ Slice 7 ──────────────┘ + +Shared-list ordering constraint: Slice 2 ──▶ Slice 5 ──▶ Slice 7 +``` + +## 9. Follow-ups (out of scope) + +| Item | Why deferred | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Mobile memory viewer has **no auto-advance timer** | Pre-existing, affects every memory type. `DriftMemoryCard.onVideoEnded` is plumbed but unused by `drift_memory.page.dart` | +| `RULE_DAILY_LIMIT = 2` contention on days 20–26 | Raising it is an engine change; §3.6 keeps 13–14 and 27+ always free | +| Web: cap the progress timer for very long videos | `MemoryViewer.svelte:120-125` uses full `asset.duration` | +| Per-theme thresholds instead of one global `themeMaxDistance` | Revisit if calibration shows themes diverging widely | +| Widening `themed` beyond `MAX_YEARS_BACK = 3` | Cheap to raise later; keeps nightly cost bounded | +| Roadmap #8, #9, #12–14 | Not this batch; #9 needs a sensitivity frame | +| `statistics()` counts memories of disabled types | Pre-existing documented limitation | diff --git a/docs/plans/2026-07-22-memory-person-throwback-spec.md b/docs/plans/2026-07-22-memory-person-throwback-spec.md new file mode 100644 index 0000000000000..cf13c3b089eed --- /dev/null +++ b/docs/plans/2026-07-22-memory-person-throwback-spec.md @@ -0,0 +1,647 @@ +# Person Throwback Memory — Design & Test Spec + +> Implements roadmap item **#9 "Someone you haven't seen"** from the +> [memory types roadmap](./2026-07-15-memory-types-roadmap.md), reframed (§2 D1). +> Stacked on **PR #812** (`feat/memory-types-tier3`). +> Branch: `feat/memory-person-throwback`. +> Approach: **test-driven, behavior-driven, full edge-case coverage** — see §4.0 for what that +> requires of the implementer. +> Created 2026-07-22. +> Status: **spec — not yet implemented.** + +## 1. Goal & non-goals + +**Goal:** add one `MemoryRule` that resurfaces a warm chapter with a person who has not +appeared in the user's photos for an admin-configurable dormancy window (default 6 months). + +| Key | Memory | Trigger day | Window | +| ------------------ | --------------------------------------------- | ----------- | ------ | +| `person_throwback` | "Times with Anna" · "23 photos · August 2019" | **13** | 7 d | + +**Non-goals (this batch):** + +- **No per-person "exclude from memories" control.** That is a deliberate follow-up PR (§8) that + spans every person-based rule, not just this one. +- No engine change to `memory.service.ts` scheduling, `RULE_DAILY_LIMIT`, the multi-day slot cap, + or cleanup. The rule is a pure function of `(ownerId, target, injected repositories)`. +- No localization of memory _content_ (titles/subtitles stay English, matching every existing rule). +- No `MemoryType` enum or `memory` table schema change. +- No change to the shipped `birthday` rule, including the sampling issue noted in §7.3. + +## 2. Design decisions + +| # | Decision | Rationale | +| --- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | **The dormancy gap is never shown to the user** | The gap is a _selection heuristic only_. Titling it ("You haven't seen Anna in 2 years") makes the app assert something about a relationship — which lands badly when the person has died or the friendship ended. As a silent selector it reads like any other memory. | +| D2 | Key is **`person_throwback`**, not `someone_missed` | The name must not smuggle back the emotional claim D1 removed. Rhymes with the shipped `favorites_throwback`. | +| D3 | Dormancy threshold **admin-configurable, default 6 months, no upper bound** | Photo-absence ≠ real absence, so a shorter threshold admits many people the user still sees — harmless false positives under D1, but they **dilute** how much the rule concentrates on people who are genuinely gone. Originally fixed at 12 months; lowered to a **configurable 6** (2026-07-26) because 12 made the rule effectively unfireable on real libraries — a 65k-asset library with 12 named people had a most-dormant gap of only 9 months, putting the earliest natural fire ~16 months after install. Admins who want the stricter original behaviour set `memories.personThrowbackDormancyMonths` back to `12`. No upper bound because `month_recap` / `favorites_throwback` already surface arbitrarily old photos. | +| D4 | Show the person's **densest chapter**, not their last or a career-spanning spread | Densest cluster ≈ a real event (trip, wedding, summer) ≈ the best photos of them, and it is visually coherent. "Last chapter" is the heaviest possible cut if the person died; an all-years spread reads as an in-memoriam reel. | +| D5 | **Gap length is not scored** | Ranking by dormancy would put the most-likely-deceased person first. Rank by chapter richness so the best-documented relationships win. | +| D6 | `recencyBonus` **is** applied to the chapter year | Conventional (every rule uses it) _and_ it mildly favours recently-dormant people, further diluting the concentration in D3. One lever, two jobs. | +| D7 | **Pets excluded** (`person.type = 'person'`) | A pet dormant for the whole window has overwhelmingly died — it lacks the "maybe I just don't photograph them" ambiguity that makes the human case safe. Roadmap #10 owns pets and can frame them deliberately. | +| D8 | Rule returns **up to 5 candidates**, not 1 | `hasRuleMemory` dedup happens in the engine _after_ the rule returns. A 1-candidate rule whose key already fired contributes nothing — permanently. This is the exact trap Tier 3 hit. Multiple candidates let the engine skip fired keys and reach a fresh person. | +| D9 | Chapter density computed from **daily counts**, not fetched assets | Lets the rule find the true densest window without fetching a heavy subject's whole history (an ex-partner may have thousands of photos). Also makes the core algorithm a pure function over small integers. | +| D10 | `defaultEnabled: true` | Matches every other type and how Apple/Google actually behave. Escape hatches until the §8 follow-up: the per-user type toggle and `person.isHidden`. | +| D11 | Rule **exports its constants** | Private statics make thresholds untestable — the Tier 3 D8 lesson. | + +## 3. Architecture + +### 3.1 Every site a new memory type touches + +Traced from `themed` (added in PR #812). **All 16 sites.** Because D10 makes the key +admin-available by default, rows 8, 10 and 7 **do** change (they would not have under an opt-in +default) — so this branch serialises against any other branch adding a memory type. + +| # | File | What changes | +| --- | ---------------------------------------------------------------- | ---------------------------------------------------------------------- | +| 1 | `server/src/services/memory-rules/memory-type.metadata.ts` | `MEMORY_TYPE_METADATA` entry (appended last) | +| 2 | `server/src/services/memory-rules/memory-type.metadata.spec.ts` | assert key, kind, defaults | +| 3 | `server/src/services/memory-rules/memory-type.registry.ts` | `RULE_FACTORIES` entry (no `MemoryRuleDeps` change — §3.2) | +| 4 | `server/src/services/memory-rules/memory-type.registry.spec.ts` | factory builds the right rule + completeness guard **10 → 11** | +| 5 | `server/src/services/memory-rules/person-throwback.rule.ts` | the rule | +| 6 | `server/src/services/memory-rules/person-throwback.rule.spec.ts` | unit/BDD spec | +| 7 | `server/src/utils/preferences.spec.ts` | default per-user type map gains the key | +| 8 | `server/src/services/server.service.spec.ts` | **TWO** `availableMemoryTypes` assertions | +| 9 | `server/test/medium/specs/services/memory.service.spec.ts` | end-to-end generation medium test | +| 10 | `e2e/src/specs/server/api/server.e2e-spec.ts` | `availableMemoryTypes` fixture — **the server unit suite misses this** | +| 11 | `web/src/routes/admin/system-settings/MemoriesSettings.svelte` | hardcoded `memoryTypeKeys` array | +| 12 | `web/src/routes/admin/system-settings/MemoriesSettings.spec.ts` | the full `types` object literal in the save-payload test | +| 13 | `i18n/en.json` | 4 keys (§3.6) | +| 14 | `docs/docs/features/memories.md` | user-facing type list | +| 15 | `docs/docs/install/config-file.md` | `memories.types` config keys | +| 16 | `docs/plans/2026-07-15-memory-types-roadmap.md` | #9 Status → **Shipped**; also correct #12's status (§8) | + +Expected `availableMemoryTypes` (registry order) after this branch — **12** entries: + +``` +on_this_day, birthday, recent_trip, month_recap, favorites_throwback, on_this_day_place, +season_recap, people_together, video_moments, trip_anniversary, themed, person_throwback +``` + +The registry completeness guard asserts one rule per `kind: 'rule'` entry: **10 → 11** +(`on_this_day` is not rule-kind). + +### 3.2 New / changed source files, with exact signatures + +| File | Change | +| ---------------------------------------------------- | ---------------------------------------------------------------------- | +| `src/repositories/person.repository.ts` | **New** `getDormantPeople` | +| `src/repositories/asset.repository.ts` | **New** `getMemoryPersonDailyCounts`, `getMemoryAssetsForPersonWindow` | +| `src/services/memory-rules/chapter.util.ts` | **New**, pure | +| `src/services/memory-rules/person-throwback.rule.ts` | **New** rule | +| `src/services/memory-rules/memory-type.registry.ts` | one factory entry | +| `src/services/memory-rules/memory-type.metadata.ts` | one metadata entry | + +`MemoryRuleDeps` already carries `personRepository` and `assetRepository`, so the registry's deps +interface is **unchanged**: + +```ts +person_throwback: (deps) => new PersonThrowbackMemoryRule(deps.personRepository, deps.assetRepository), +``` + +### 3.3 Repository queries + +Three queries, all `@GenerateSql`-decorated (so `make sql` must be re-run — §6.2). + +```ts +// person.repository.ts +// `id` and `name` only — the rule consumes nothing else. Ranking happens SQL-side (ORDER BY, which +// needs no SELECT), and scoring uses `chapter.count`, not the person's lifetime total. Returning +// `lastSeenAt` / `assetCount` would be dead weight, and `lastSeenAt` is exactly the dormancy figure +// D1 keeps out of user-facing data. +export interface DormantPerson { + id: string; + name: string; +} + +getDormantPeople( + ownerId: string, + { lastSeenBefore, minAssets, limit }: { lastSeenBefore: Date; minAssets: number; limit: number }, +): Promise; +``` + +`FROM person JOIN asset_face JOIN asset`, filtered by: + +| Side | Predicates | +| ------ | --------------------------------------------------------------------------------------- | +| person | `ownerId = :ownerId`, `type = 'person'` (D7), `name != ''`, `isHidden = false` | +| face | `deletedAt IS NULL`, `isVisible = true` | +| asset | `ownerId = :ownerId`, `visibility = Timeline`, `deletedAt IS NULL`, preview file EXISTS | + +then `GROUP BY person.id` `HAVING max(asset."localDateTime") < :lastSeenBefore AND count(DISTINCT asset.id) >= :minAssets`, +`ORDER BY count(DISTINCT asset.id) DESC, person.id ASC` (deterministic tie-break), `LIMIT :limit`. + +The asset-side predicates **must match `getMemoryFacesForPeriod` exactly**, otherwise a person can +look dormant merely because their recent photos are archived or lack a preview. + +```ts +// asset.repository.ts +export interface MemoryPersonDayCount { + personId: string; + day: Date; // date-truncated localDateTime, UTC + count: number; +} + +getMemoryPersonDailyCounts( + ownerId: string, + personIds: string[], + { takenBefore }: { takenBefore: Date }, +): Promise; // ORDER BY personId, day ASC + +getMemoryAssetsForPersonWindow( + ownerId: string, + personId: string, + { from, to }: { from: Date; to: Date }, +): Promise; // ORDER BY localDateTime ASC +``` + +`getMemoryPersonDailyCounts` returns one row per (person, calendar day) — small even for a heavy +subject — and is the input to the pure density algorithm (D9). `getMemoryAssetsForPersonWindow` is +bounded by a ≤14-day window, so it needs no `LIMIT` guess. + +**Why not reuse `getMemoryAssetsForPerson`:** it is +`DISTINCT ON (asset.id) ORDER BY asset.id … LIMIT 60`, which returns the 60 **lowest UUIDs** — an +arbitrary sample, not the 60 most recent. Unusable for density. (See §7.3.) + +### 3.4 `chapter.util.ts` — exact exports + +```ts +export const CHAPTER_MAX_SPAN_DAYS = 14; + +export interface DayCount { + day: Date; + count: number; +} + +export interface Chapter { + from: Date; // first day of the winning window + to: Date; // last day of the winning window + count: number; // assets inside it, summed from the daily counts +} + +/** + * Widest-count window of at most `maxSpanDays` consecutive calendar days. + * Sorts `days` ascending defensively — the query already orders them, but the + * two-pointer sweep silently returns garbage on unsorted input rather than + * failing, so the contract is enforced here rather than assumed. + * Ties resolve to the MOST RECENT window. Returns null for empty input. + */ +export const densestChapter = (days: DayCount[], maxSpanDays: number): Chapter | null; +``` + +Two-pointer sweep: for each right index, advance `left` while +`day[right] - day[left] > maxSpanDays - 1`; track the running sum. Update the best window on +`sum >= best` (`>=`, not `>`, so the last — most recent — maximal window wins, per D4's tie rule). + +### 3.5 The rule + +```ts +export const TRIGGER_DAY = 13; +/** Fallback for `memories.personThrowbackDormancyMonths` (admin-configurable). */ +export const DEFAULT_DORMANCY_MONTHS = 6; +export const MIN_TOTAL_ASSETS = 10; +export const MIN_CHAPTER_ASSETS = 6; +export const CANDIDATE_POOL = 10; +export const MAX_CANDIDATES = 5; +export const ASSET_CAP = 8; +export const VISIBLE_FOR_DAYS = 7; +export const SCORE_BASE = 110; +export const MAX_COUNT_BONUS = 30; +``` + +**Trigger day 13 — chosen by window occupancy, not by free trigger slots (§5.1).** It is also +`≤ 28`, so it never hits the Luxon month-length clamp that bit Tier 3. + +Flow: + +1. Return `[]` unless `target.day === TRIGGER_DAY`. No repository call before this check. +2. `lastSeenBefore = target.startOf('day').minus({ months: dormancyMonths })`, where `dormancyMonths` + comes from `memories.personThrowbackDormancyMonths` (default 6). Dormant means + `lastSeenAt < lastSeenBefore`, **strictly** — a person last seen exactly at the cutoff is not yet + dormant. (Subtracting whole months from day 13 always lands on day 13; no clamping.) +3. `getDormantPeople(ownerId, { lastSeenBefore, minAssets: MIN_TOTAL_ASSETS, limit: CANDIDATE_POOL })`. + Pool of 10 > the 5 returned, so chapter-density ranking has room to reorder the SQL's + total-count ordering. +4. **If the pool is empty, return `[]` immediately.** Calling step 5 with an empty id list would + emit `IN ()`, which is not valid SQL. This short-circuit is load-bearing, not an optimisation. +5. `getMemoryPersonDailyCounts(ownerId, ids, { takenBefore: lastSeenBefore })`. +6. Per person: `densestChapter(days, CHAPTER_MAX_SPAN_DAYS)`; drop if `null` or + `count < MIN_CHAPTER_ASSETS`. No distinct-day minimum — a wedding is one day and is a fine memory. +7. Score, sort desc (tie-break `personId` asc), take `MAX_CANDIDATES`. +8. For each survivor, `getMemoryAssetsForPersonWindow`, then **re-check + `assets.length >= MIN_CHAPTER_ASSETS` and drop the candidate if it fails.** `chapter.count` came + from step 5 and the assets arrive in a later, separate query; anything deleted or archived in + between would otherwise produce a memory with too few assets — or none at all. +9. `assetIds = sampleAssetsByTime(assets, ASSET_CAP)` and + `memoryAt = DateTime.fromJSDate(medianTime(assets), { zone: 'utc' })` — both over the **full** + window set, not the sampled 8 (matching `people_together`). Note the `fromJSDate` wrap: + `medianTime` returns a `Date`, but `MemoryRuleCandidate.memoryAt` is a Luxon `DateTime`. + +Steps 7–8 can shrink the result below `MAX_CANDIDATES` when read skew drops a survivor; the rule +does **not** backfill from the pool. Read skew is rare and D8 only needs _some_ depth, not exactly 5. + +``` +score = SCORE_BASE + min(chapter.count, MAX_COUNT_BONUS) * 3 + recencyBonus(chapterYear, target.year) +``` + +`chapter.count` here is the **full** chapter total from step 6, not `assetIds.length`. +`chapterYear` is the year of **`chapter.to`** — the window's most recent day. It cannot be `memoryAt`'s +year: scoring happens in step 7, before the assets (and therefore `memoryAt`) exist. Using the +window's last day is also the better recency signal. The two differ only for a chapter that straddles +a year boundary. + +Score bands compared by **achievable range**, not base — comparing bases is misleading, because the +count multipliers differ by 3× between rules: + +| Rule | Formula | Typical (15 assets, 2 yrs back) | Max | +| ------------------- | ---------------------------------- | ------------------------------- | --------- | +| `birthday` | `300 + years*10 + n` | ~330 | ~370 | +| `trip_anniversary` | `260 + days*4 + min(n,20) + bonus` | ~300 | ~318 | +| `person_throwback` | `110 + min(n,30)*3 + bonus` | **163** | **210** | +| `people_together` | `100 + n*3 + bonus` | 153 | unbounded | +| `on_this_day_place` | `100 + min(n,30)*3 + bonus` | 153 | 200 | +| `season_recap` | `90 + min(n,40) + bonus` | 113 | 140 | +| `month_recap` | `80 + min(n,30) + bonus` | 103 | 120 | +| `themed` | `70 + min(n,25) + bonus` | 93 | 105 | +| `video_moments` | `60 + …` | ~80 | ~95 | + +Deliberately mirrors `people_together`'s shape — same ×3 multiplier, +10 base for being rarer (once +ever per person), but **capped** at 30 where `people_together` is uncapped. So it edges out the +other person-centric rule in the typical case without ever running away. It stays well below the +date-anchored `birthday` / `trip_anniversary`, which must fire on their day or wait a year. + +Candidate shape: + +```ts +{ + ruleId: 'person_throwback', + dedupeKey: `person_throwback:${person.id}`, // once ever, per D8's pool + title: `Times with ${person.name}`, + // `chapter.count` — the full chapter total (e.g. "23 photos"), NOT assetIds.length (≤ 8). + // The memory shows 8 of them; the subtitle describes the chapter. Matches `people_together`. + subtitle: `${chapter.count} photos · ${monthName(memoryAt.month)} ${memoryAt.year}`, + score, + assetIds, + memoryAt, + visibleForDays: VISIBLE_FOR_DAYS, + context: { personId, chapterFrom, chapterTo, count: chapter.count }, +} +``` + +### 3.6 i18n keys (`i18n/en.json`, EN only) + +| Key | Value | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `admin.memory_type_person_throwback_setting` | Person throwback | +| `admin.memory_type_person_throwback_setting_description` | Resurface a warm chapter with someone who has not appeared in photos for a while. | +| `memory_type_person_throwback` | Times with someone | +| `memory_type_person_throwback_description` | Occasionally resurface photos of a person you have not photographed in a long while. | + +Note the shape, verified against `en.json`: the **admin** pair is `memory_type__setting` / +`_setting_description` nested under the `admin` object (~line 330), while the **user** pair is +`memory_type_` / `_description` at top level (~line 2013). The admin keys are _not_ +`admin.memory_type_`. Web and mobile share one `i18n/` directory; only `en.json` needs the new +keys. + +## 4. Behaviour spec (tests) + +### 4.0 How these tests get written + +Every row in §4.1–§4.4 is written **before** the code that satisfies it, and is **observed +failing** before that code exists. A row that has never been seen red has not been shown to test +anything — most of the Tier-3 defects were found exactly this way. + +Two rules specific to this spec: + +- **Assert the failure mode, not just the return value.** Row 4.2 #2 is the worked example: `[]` is + the right answer both with and without the step-4 short-circuit, so the row asserts the _absent + second query_ instead. Several rows here are like that — read the Expect column literally. +- **Never assert a SQL-side filter against a mocked repository.** It only tests the mock. Arguments + go in §4.2 row 7, behaviour goes in §4.4. + +Each row is one `it(...)`, phrased as behaviour ("returns nothing when the person is a pet"), not as +implementation ("calls getDormantPeople with type filter"). + +### 4.1 `chapter.util.spec.ts` — pure + +| # | Given | Expect | +| --- | -------------------------------------------- | --------------------------------------------------------------------- | +| 1 | empty input | `null` | +| 2 | one day, 3 assets | window of that day, `count 3` | +| 3 | all days inside the span | whole set | +| 4 | two clusters, second denser | the second | +| 5 | two clusters, **equally dense** | the **more recent** one (D4 tie rule) | +| 6 | days exactly `maxSpanDays - 1` apart | both included — the window covers exactly `maxSpanDays` calendar days | +| 7 | days exactly `maxSpanDays` apart | split into separate windows | +| 8 | dense window at the very start of the series | found (no off-by-one at `left = 0`) | +| 9 | input in **descending** order | same result as ascending — the defensive sort holds the contract | + +### 4.2 `person-throwback.rule.spec.ts` + +| # | Given | Expect | +| --- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `target.day !== 13` | `[]`, **no repository call** | +| 2 | no dormant people | `[]`, and `getMemoryPersonDailyCounts` is **never called** — the step-4 short-circuit. Asserting only `[]` would pass without the guard and let `IN ()` reach the DB | +| 3 | dormant "Anna", 23-asset chapter in Aug **2023**, `target` = 2026-08-13 | one candidate, everything pinned: `title` `'Times with Anna'`, `subtitle` `'23 photos · August 2023'`, `dedupeKey` `'person_throwback:'`, and **`score === 186`** — `110 + min(23,30)*3 + max(0, 10-(2026-2023))` = `110+69+7`. Pin the number, not the formula | +| 4 | last seen **exactly** at the cutoff | not dormant → excluded (strict `<`) | +| 5 | last seen one day before the cutoff | dormant → included | +| 6 | chapter has 5 assets (`< MIN_CHAPTER_ASSETS`) | excluded | +| 7 | any run reaching step 3 | `getDormantPeople` receives `minAssets: 10`, `limit: 10`, and `lastSeenBefore` exactly `personThrowbackDormancyMonths` (default 6) before `target.startOf('day')` — the four SQL-side filters are asserted **at the query argument** here, and their behaviour in §4.4 | +| 8 | 7 qualifying people | exactly `MAX_CANDIDATES` (5) returned, score desc | +| 9 | two people with identical scores | ordered by `personId` asc (deterministic) | +| 10 | chapter spans a month boundary | subtitle uses the **median** asset's month/year | +| 11 | single-day chapter of 8 assets | included (no distinct-day minimum) | +| 12 | chapter year is 4 years back | `recencyBonus` = 6 in the score (D6) | +| 13 | equal chapters, one dated 2 yrs back, one 8 yrs back | the **2-years-back** chapter scores higher (D6). Note the bonus keys off the _chapter year_, not the dormancy gap — a recently-dormant person can still have an ancient chapter | +| 14 | assets exceed `ASSET_CAP` | 8 ids, evenly spaced by time | +| 15 | every candidate | `visibleForDays === 7`, `dedupeKey` has **no** year | +| 16 | every pooled candidate fails the chapter bar | `[]` — not a crash, and no window query issued | +| 17 | window query returns fewer assets than `chapter.count` but still `>= MIN_CHAPTER_ASSETS` | candidate kept; `subtitle` still reports `chapter.count` | +| 18 | window query returns **4** assets (read skew, `< MIN_CHAPTER_ASSETS`) | candidate **dropped** — step 8's re-check. Without it the memory is created with 4 assets | +| 19 | window query returns **zero** assets | candidate dropped, no zero-asset memory created | +| 20 | one candidate's window query rejects | that candidate is dropped; the others still return (one bad person must not void the whole rule) | + +Rows 18–19 cover the read skew between the step-5 daily-counts query and the step-8 asset query. +They are the only defence against a memory with too few — or zero — assets, since `chapter.count` +and `assetIds` come from two different reads. + +Pet, hidden-person and unnamed-person exclusion (D7) live in SQL. Their **arguments** are asserted +in row 7; their **behaviour** is asserted in the medium test (§4.4). Neither is asserted against a +mocked repository here, which would only be testing the mock. + +### 4.3 Registry / metadata / preferences specs + +Mechanical, per §3.1 rows 2, 4, 7, 8: key present, `kind: 'rule'`, `defaultEnabled: true`, +`adminConfigurable: true`; factory returns a `PersonThrowbackMemoryRule`; completeness guard +10 → 11; default preference map gains `person_throwback: true`; both `availableMemoryTypes` +assertions gain the key in registry order. + +### 4.4 Medium test (real DB) + +| # | Scenario | Expect | +| --- | ------------------------------------------------------- | --------------------------------------------------------------------- | +| 1 | dormant named person with a dense chapter, type enabled | memory created, correct assets | +| 2 | same person, but `person.type = 'pet'` | **no** memory (D7) | +| 3 | same person, but `isHidden = true` | no memory | +| 4 | same person, but `name = ''` | no memory | +| 5 | recent photos exist but are `Archived` | **still** dormant → memory (predicate parity, §3.3) | +| 5b | chapter assets have no `Preview` asset_file | excluded from both the dormancy count and the chapter (parity, §3.3) | +| 5c | a face on a chapter asset is soft-deleted or invisible | that asset does not count toward the chapter | +| 6 | user has the type toggled off | no memory | +| 7 | rule already fired for that person | no second memory; a **different** dormant person is used instead (D8) | + +Scenario 7 is the Tier-3 regression guard and is the single most important row in this table. + +## 5. Risks + +| Risk | Mitigation | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Resurfacing a deceased or estranged person | D1 (no gap claim), D3 (dilution), D5/D6 (never rank by dormancy), D7 (no pets); per-user toggle; §8 follow-up | +| Rule goes permanently dry after firing once per person | D8 multi-candidate pool; medium test §4.4 #7 | +| Heavy subject (thousands of photos) makes the density query expensive | D9 — density runs on daily counts; asset fetch is window-bounded | +| Rule never evaluates because lingering memories hold both slots | Trigger day chosen by occupancy analysis — §5.1 | +| `defaultEnabled: true` ships it to every user on upgrade with no per-person opt-out | Accepted (D10). §8 follow-up is the durable answer. | + +### 5.1 Trigger-day occupancy — why 13, not 26 + +`RULE_DAILY_LIMIT` is 2, and `createRuleMemories` returns **early** when `remainingSlots === 0` +(`memory.service.ts:130`) — before any rule evaluates. The slot count comes from +`memoryRepository.search(ownerId, { type: Rule, for: target })`, which matches +`showAt <= target AND hideAt >= target` (`memory.repository.ts:42-50`) — every memory **still +visible** that day, not just ones created that day. + +So a trigger day must be free of _windows_, not merely free of _triggers_. Monthly occupancy of the +multi-day rules (`on_this_day_place`, `recent_trip`, `birthday` are 1-day and don't linger): + +``` +day: 1 5 10 15 20 25 31 + |----|----|----|----|----|----| +month_recap [1–7] +season_recap [1–10] (Mar/Jun/Sep/Dec only) +video_moments [8–12] +favorites_throwback [15–21] +people_together [20–26] +themed [22–26] +person_throwback [13–19] ← proposed +``` + +**Day 26 — the original choice — is the worst day of the month:** it is the exact intersection of +`people_together` (20–26) and `themed` (22–26). Whenever both fired, `remainingSlots === 0` and the +rule would never have evaluated at all. Days free of all scheduled windows are **13, 14, 27–31**. + +Day 13 with a 7-day window (13–19) also leaves every later trigger with a free slot — verified per +day: day 15 `favorites_throwback` sees 1 free (ours only), day 20 `people_together` sees 1 free +(ours ended on 19), day 22 `themed` sees 1 free. Day 27 is worse: its window spills to the 1st–2nd +of the next month and competes with `month_recap` and `season_recap` on day 1. + +Sporadic date-anchored rules (`trip_anniversary`, 3–7 days, any day) can still occupy one slot on +day 13. With two slots that leaves one, which is the same exposure every other rule carries. + +## 6. Verification + +### 6.1 Gates + +`pnpm test` (server), `pnpm test:medium`, `tsc --noEmit`, `eslint --max-warnings 0`, +`prettier --check .` over the **whole** server package, web `check:typescript` + `check:svelte` + +`pnpm lint`, and `prettier` over `docs/`. + +### 6.2 Codegen + +The three new repository methods are `@GenerateSql`-decorated, so `make sql` must be re-run — +**with a running DB, and after a build**; running it without one deletes every query file. No DTO +or endpoint changes, so **no** OpenAPI regeneration and **no** Dart client regeneration. + +## 7. Notes for the implementer + +1. `person.type` is a plain `character varying` defaulting to `'person'`; pets are `'pet'`. There is + no enum — filter on the string literal, as `person.repository.ts` already does elsewhere. +2. `getMemoryFacesForPeriod` does **not** filter `person.type`, so the shipped `people_together` + rule can already pair a human with a pet ("Anna & Rex"). That is charming and out of scope here. +3. **Adjacent, not in scope:** `getMemoryAssetsForPerson`'s `DISTINCT ON (asset.id) ORDER BY +asset.id … LIMIT 60` means the shipped `birthday` rule samples an arbitrary 60 assets by UUID for + anyone with more than 60 photos, skewing its per-year distribution. Worth its own issue. +4. **Known cost:** step 8 runs a window query for all 5 candidates, but the engine's multi-day + one-slot cap inserts at most one of them — so 4 of the 5 are wasted work. That is the price of + D8: the engine only skips already-fired keys _after_ the rule returns, so the alternatives are + returning fewer candidates (and going permanently dry) or teaching the rule to pre-filter with + its own `hasRuleMemory` calls (5 queries saved, 5 queries added, plus engine coupling). Once a + month, per user, on bounded windows — accepted deliberately. +5. Total query cost on the trigger day: `1 + 1 + 5 = 7`, all bounded. On every other day of the + month: **0** — step 1 returns before touching a repository. + +## 8. Follow-up PR (not this branch) + +Add `person.excludeFromMemories` (migration in `server/src/schema/migrations-gallery/`), honoured by +**every** person-based rule — `person_throwback`, `people_together`, and especially `birthday`, +which currently wishes a deceased person a happy birthday with no opt-out short of `isHidden`. +Needs: migration, DTO, web person UI, mobile person UI. + +Also correct the roadmap while there: **#12 Semantic themes (CLIP)** is listed as an unshipped 🔴 +north star, but PR #812's `themed` rule already rides smart-search CLIP embeddings — the remaining +work is vocabulary breadth and the `themeMaxDistance` calibration, not new infrastructure. + +## 9. Implementation slices (for `/impl-loop`) + +Every slice is **red → green → refactor**: + +1. Write the slice's tests. Run them. **Capture the failure output** — a test never seen red proves + nothing (§4.0). +2. Implement the minimum that makes them green. +3. Re-run; confirm green. Run the slice's gate. +4. Commit with the slice's message. + +> ⚠️ Use `pnpm test --run ` — **not** `pnpm test -- --run `. This pnpm version forwards +> the literal `--` to vitest, which silently drops the path filter and runs the whole suite. + +### Dependency graph + +``` +Slice 1 (pure util) ───┐ + ├──▶ Slice 3 (rule) ──▶ Slice 4 (server reg.) ──▶ Slice 5 (web + i18n) +Slice 2 (queries) ─────┘ │ + └──▶ Slice 6 (medium + docs) +``` + +Slices 1 and 2 are independent and may run in either order. Slices 3 → 4 → 5 are strictly ordered. +Slice 6 needs 4 (registration) but not 5. + +--- + +### Slice 1 — `chapter.util.ts` + +**Files:** `server/src/services/memory-rules/chapter.util.ts` + `.spec.ts` (both new). + +Pure, zero dependencies. Implements §3.4; tests are §4.1 rows 1–9 verbatim, one `it()` each. + +The whole slice is the two-pointer boundary. Write rows 6 and 7 first — they are the pair that +pins "at most `maxSpanDays` **calendar days**", i.e. a maximum day-index difference of +`maxSpanDays - 1`. Row 5 pins the `>=` tie-break toward the most recent window; row 9 pins the +defensive sort. + +**Gate:** `cd server && pnpm test --run src/services/memory-rules/chapter.util.spec.ts` +**Commit:** `feat(memories): add densestChapter window helper` + +--- + +### Slice 2 — repository queries + +**Files:** `server/src/repositories/person.repository.ts`, +`server/src/repositories/asset.repository.ts`, `server/src/queries/*` (generated). + +Three queries per §3.3: `getDormantPeople`, `getMemoryPersonDailyCounts`, +`getMemoryAssetsForPersonWindow`. All `@GenerateSql`-decorated. + +Copy the asset-side predicate block from `getMemoryFacesForPeriod` (`asset.repository.ts:1001`) +rather than retyping it — §3.3's parity requirement is the whole point, and a missing +`preview EXISTS` makes people look dormant who aren't. + +`getDormantPeople` returns `{ id, name }` only. `ORDER BY count(DISTINCT asset.id) DESC, +person.id ASC` needs no matching `SELECT`. + +Then regenerate: `make sql`. **Requires a running DB and a prior build** — without a DB it deletes +every file in `server/src/queries/`. Build first, then migrate, then `make sql`. + +**Gate:** `cd server && pnpm check`, then confirm `git diff --stat server/src/queries` shows exactly +two **modified** files — `person.repository.sql` (+1 query) and `asset.repository.sql` (+2). Both +already exist; new files or deletions mean `make sql` ran without a DB. +**Commit:** `feat(memories): add dormant-person and chapter-window queries` + +--- + +### Slice 3 — the rule + +**Files:** `server/src/services/memory-rules/person-throwback.rule.ts` + `.spec.ts` (both new). +**Depends on:** Slices 1 and 2 (imports `densestChapter` and the repository method types). + +Implements §3.5. Tests are §4.2 rows 1–20, one `it()` each. Model the spec file on +`people-together.rule.spec.ts` — same fixture and mock style, and the same rule shape. + +Export every constant per D11 (module-level `export const`, not private statics). + +Three rows carry the defects this spec was revised for — write them first: + +- **Row 2** — asserts `getMemoryPersonDailyCounts` is _never called_ on an empty pool. Asserting + `[]` alone passes without the guard. +- **Rows 18–19** — read skew: the window query returning 4 assets, then 0, must drop the candidate. +- **Row 3** — the pinned `score === 186`. + +Constructor takes narrowed repository types, matching every existing rule: + +```ts +constructor( + private personRepository: Pick, + private assetRepository: Pick, +) {} +``` + +**Gate:** `cd server && pnpm test --run src/services/memory-rules/person-throwback.rule.spec.ts` +**Commit:** `feat(memories): add person_throwback memory rule` + +--- + +### Slice 4 — server registration + +**Files:** §3.1 rows 1, 2, 3, 4, 7, 8, 10. +**Depends on:** Slice 3. + +Append the `MEMORY_TYPE_METADATA` entry **last** (registry order is the `availableMemoryTypes` +order), add the `RULE_FACTORIES` entry, then fix the four shared lists that now fail: + +- `memory-type.registry.spec.ts` — completeness guard **10 → 11** +- `preferences.spec.ts` — default map gains `person_throwback: true` +- `server.service.spec.ts` — **both** `availableMemoryTypes` assertions → 12 entries +- `e2e/src/specs/server/api/server.e2e-spec.ts` — the same fixture. **The server unit suite does not + catch this one**; it is the most-missed site in the whole list. + +No `MemoryRuleDeps` change — `personRepository` and `assetRepository` are already there. + +**Gate:** `cd server && pnpm test --run src/services src/utils` then the full `pnpm test` +**Commit:** `feat(memories): register person_throwback memory type` + +--- + +### Slice 5 — web admin settings + i18n + +**Files:** §3.1 rows 11, 12, 13. +**Depends on:** Slice 4. + +Add the key to the hardcoded `memoryTypeKeys` array in `MemoriesSettings.svelte`, then fix +`MemoriesSettings.spec.ts` — the full `types` object literal +in the save-payload assertion. Add the four `i18n/en.json` keys from §3.6 (EN only; web and mobile +share the directory). + +**Gate:** `cd web && pnpm test --run src/routes/admin/system-settings/MemoriesSettings.spec.ts`, +then `pnpm check:typescript && pnpm check:svelte && pnpm lint` +**Commit:** `feat(web): expose person_throwback in memory settings` + +--- + +### Slice 6 — medium tests + docs + +**Files:** `server/test/medium/specs/services/memory.service.spec.ts`, `docs/docs/features/memories.md`, +`docs/docs/install/config-file.md`, `docs/plans/2026-07-15-memory-types-roadmap.md`. +**Depends on:** Slice 4. + +Medium tests are §4.4 rows 1–7 (including 5b and 5c). **Row 7 is the one that must not be cut** — +it proves that when the rule has already fired for a person, a _different_ dormant person is used +instead. That is the Tier-3 regression guard and the only test covering D8. + +Rows 2, 3, 4, 5, 5b, 5c are the SQL-side filters; they exist here precisely because a mocked +repository cannot test them (§4.0). + +Docs: add the type to the user-facing list and the `memories.types` config keys, and flip roadmap +#9 to **Shipped**. Run `npx prettier --write` over any touched markdown — CI Docs Build is strict. + +**Gate:** `cd server && pnpm test:medium --run test/medium/specs/services/memory.service.spec.ts`, +then the full §6.1 gate set +**Commit:** `test(memories): end-to-end coverage for person_throwback + docs` + +--- + +### Not in any slice + +- Per-person `excludeFromMemories` — §8, its own PR. +- The `birthday` UUID-sampling issue — §7.3, its own issue. +- Calibration/RC deploy — the dormancy window is admin-tunable + (`memories.personThrowbackDormancyMonths`, default 6, see D3), so a wrong default is a settings + change rather than a redeploy. No pre-merge calibration sweep is required. diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-1.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-1.md new file mode 100644 index 0000000000000..01f8089e3ad23 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-1.md @@ -0,0 +1,113 @@ +# Slice 1 — `getMemoryAssetsForPeriod` returns `type` + `duration` + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.5, §6.8, Slice 1. +Foundation for `video_moments` (Slice 2). No new memory type registered here. + +## Goal + +Make the shared period query return each asset's `type` and `duration`, and accept an optional +`type` filter. Additive in behavior, **not** source-compatible (the new fields are required), so the +four existing fixture factories must be updated in this same slice or `tsc` breaks. + +## TDD order + +### Step 1 — RED: medium tests + +File: `server/test/medium/specs/repositories/asset.repository.spec.ts`, inside the existing +`describe('getMemoryAssetsForPeriod')` (starts line 911). + +First extend the local `seedPeriodAsset` helper (line 31) with two optional params, passed straight +through to `ctx.newAsset` (`assetInsert` spreads overrides over its defaults, so both are accepted): + +```ts +type = AssetType.Image, +duration = null, +... +type?: AssetType; +duration?: number | null; +``` + +Then add these four cases: + +1. **returns `type` and `duration` on each row** — seed one image (`duration: null`) and one video + (`duration: 5000`); assert both rows expose the correct `type` and `duration`. +2. **`type: AssetType.Video` returns only videos** — seed 1 image + 2 videos in the same month; + assert length 2 and every `row.type === AssetType.Video`. +3. **omitting `type` returns both** — same fixture, no `type` option; assert length 3. (Proves the + filter is opt-in and existing callers are unaffected.) +4. **a video with `duration: null` is still returned** — the band filter is the rule's job, not the + query's. +5. **an asset exactly on `takenBefore` is included** — seed `localDateTime` exactly equal to + `takenBefore`; assert it is returned (SQL uses `<=`). + +Run: `cd server && pnpm test:medium -- --run test/medium/specs/repositories/asset.repository.spec.ts` +**Expected red:** cases 1/2/4 fail because `type`/`duration` are `undefined` on the returned rows and +the `type` option is not honored. Capture the failure summary. + +### Step 2 — GREEN: repository + +File: `server/src/repositories/asset.repository.ts`. + +Interfaces (~line 165 and ~line 182): + +```ts +export interface MemoryPeriodAsset { + id: string; + localDateTime: Date; + year: number; + country: string | null; + city: string | null; + isFavorite: boolean; + type: AssetType; // NEW + duration: number | null; // NEW — milliseconds +} + +export interface MemoryPeriodOptions { + months: number[]; + day?: number; + favoritesOnly?: boolean; + type?: AssetType; // NEW + takenBefore: Date; +} +``` + +Query (`getMemoryAssetsForPeriod`, line 890) — three edits: + +1. Add `type` to the destructured params: `{ months, day, favoritesOnly, type, takenBefore }`. +2. Add `'asset.type'` and `'asset.duration'` to the `.select([...])` list. +3. Add, next to the existing `favoritesOnly` guard: + `.$if(type !== undefined, (qb) => qb.where('asset.type', '=', type!))` + +Keep every existing filter, the Preview `asset_file` `exists` clause, and the `orderBy` unchanged. + +### Step 3 — GREEN: the four fixture factories + +Adding required fields breaks these object literals. Add `type: AssetType.Image, duration: null` to +each factory (import `AssetType` from `src/enum` where missing): + +- `server/src/services/memory-rules/month-recap.rule.spec.ts:7-16` +- `server/src/services/memory-rules/favorites-throwback.rule.spec.ts:7-16` +- `server/src/services/memory-rules/on-this-day-place.rule.spec.ts:13-23` +- `server/src/services/memory-rules/season-recap.rule.spec.ts:9-19` + +Do **not** change any assertion in those four files — their behavior must be untouched. + +## Verification + +```bash +cd server && pnpm test:medium -- --run test/medium/specs/repositories/asset.repository.spec.ts # green +cd server && pnpm test -- --run src/services/memory-rules/ # all green, unchanged +make check-server +cd server && npx prettier --check src/repositories/asset.repository.ts "src/services/memory-rules/**" +``` + +`make sql` regeneration is handled by the controller against a live dev DB (it **deletes all query +files** if no DB is running). Do not run it. + +## Out of scope + +No new rule, no registry/metadata entry, no i18n, no docs. Those belong to Slice 2. + +## Commit + +`feat(memories): return asset type and duration from getMemoryAssetsForPeriod` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-2.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-2.md new file mode 100644 index 0000000000000..cee15bd7464cf --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-2.md @@ -0,0 +1,140 @@ +# Slice 2 — `video_moments` memory type (end-to-end) + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.1, §4.3, §6.6, §6.7, Slice 2. +Depends on Slice 1 (`type`/`duration` on `MemoryPeriodAsset`, `type` filter) — already committed. + +This is the **pattern-setter** for registering a memory type. Slices 5 and 7 repeat this shape. + +## Part A — the rule (TDD) + +### A1. RED — `server/src/services/memory-rules/video-moments.rule.spec.ts` (new) + +Model the file on the existing `month-recap.rule.spec.ts` (same fixture/mock style). Fixtures build +`MemoryPeriodAsset[]` literals — remember `type` and `duration` are now **required** fields. + +Cases (all from spec §6.6 — every one must be present): + +1. **Trigger day** — returns `[]` on days 1, 7, 9, 15, 22; fires on day 8. +2. **Fires** — pinned `title` `'Video moments from July 2023'`, `subtitle`, `dedupeKey` + `'video_moments:2023-07'`, `visibleForDays: 5`, `ruleId: 'video_moments'`. +3. **Worked score = 94** — 9 in-band videos, 3 favourites, `Y=2023`, `target.year=2026` ⇒ + `60 + min(9,15)*2 + min(3,10)*3 + max(0,10-3)` = `60+18+9+7` = **94**. Pin this exact number. +4. **Duration band** — `2_999` excluded, `3_000` included, `180_000` included, `180_001` excluded, + `null` excluded. +5. **MIN_ASSETS boundary pair** — 2 survivors ⇒ `[]`; exactly 3 ⇒ fires. +6. **Selection (given/when/then)** — _Given_ 4 favourites and 10 non-favourites in band, _Then_ + `assetIds` equals a **pinned array**: all 4 favourite ids plus 4 evenly-spaced non-favourite ids, + sorted chronologically. Compute the expected `pickEvenlySpaced(others, 4)` indices by hand + (`Math.round(i*(n-1)/(count-1))` for n=10,count=4 ⇒ indices 0,3,6,9) and pin those ids. +7. **Favourites exceed cap** — 12 favourites ⇒ exactly 8 ids, evenly spaced. +8. **Favourite bonus capped** — 20 favourites and 10 favourites yield the **same** score. +9. **`count`/`favoriteCount` are pre-selection** — 12 in-band videos ⇒ subtitle says `12 videos` + even though `assetIds.length === 8`. +10. **Pluralization** — `1 video` vs `6 videos`. +11. Skips current and future years; caps candidates at `MAX_YEARS` (3). +12. **Passes `type: AssetType.Video`** to the repository — assert the mock call argument. +13. **Zero assets** — empty repository result ⇒ `[]`, no throw (guards `medianTime([])`). + +Run: `cd server && pnpm test --run src/services/memory-rules/video-moments.rule.spec.ts` +**Expected red:** module not found / all cases fail. Capture the output. + +> ⚠️ Use `pnpm test --run ` — NOT `pnpm test -- --run `. This pnpm version passes the +> literal `--` to vitest, which silently drops the path filter and runs the whole suite. + +### A2. GREEN — `server/src/services/memory-rules/video-moments.rule.ts` (new) + +Follow spec §4.3 exactly. Structure mirrors `month-recap.rule.ts`. + +**Export the constants** (spec D8 — module-level `export const`, not private statics): +`TRIGGER_DAY = 8`, `MIN_DURATION_MS = 3_000`, `MAX_DURATION_MS = 180_000`, `MIN_ASSETS = 3`, +`MAX_YEARS = 3`, `ASSET_CAP = 8`, `VISIBLE_FOR_DAYS = 5`, `MAX_FAVORITE_BONUS = 10`, +`SCORE_BASE = 60`. + +```ts +export class VideoMomentsMemoryRule implements MemoryRule { + readonly id = 'video_moments'; + constructor(private assetRepository: Pick) {} + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { ... } +} +``` + +Algorithm (§4.3): trigger-day guard → `getMemoryAssetsForPeriod({ months: [target.month], +type: AssetType.Video, takenBefore: target.endOf('day').toJSDate() })` → bucket by year, drop +`year >= target.year` → band filter → skip `< MIN_ASSETS` → selection → candidate. + +Selection (exact): + +```ts +const favourites = survivors.filter((a) => a.isFavorite).sort(byTime); +const others = survivors.filter((a) => !a.isFavorite).sort(byTime); +const selected = + favourites.length >= ASSET_CAP + ? pickEvenlySpaced(favourites, ASSET_CAP) + : [...favourites, ...pickEvenlySpaced(others, ASSET_CAP - favourites.length)]; +// then sort `selected` chronologically for assetIds +``` + +`count` = band survivors for the year (pre-selection). `favoriteCount` = favourite survivors +(pre-selection). Reuse `pickEvenlySpaced`, `medianTime`, `monthName`, `recencyBonus` from +`curation.util`. Sort candidates by score desc, `.slice(0, MAX_YEARS)`. + +## Part B — register the type at all 16 sites + +Exactly **one** new key: `video_moments`, appended **last** in registry order. + +| Site | File | Change | +| ---- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `server/src/services/memory-rules/memory-type.metadata.ts` | append `{ key: 'video_moments', kind: 'rule', defaultEnabled: true, adminConfigurable: true }` | +| 2 | `.../memory-type.metadata.spec.ts` | append to **all three** lists: the full metadata array (~line 28), the `MEMORY_TYPE_KEYS` array (~line 48), and `buildDefaultMemoryTypeMap` (~line 63) | +| 3 | `.../memory-type.registry.ts` | `video_moments: (deps) => new VideoMomentsMemoryRule(deps.assetRepository)` | +| 4 | `.../memory-type.registry.spec.ts` | add an id-parity case. The completeness guard uses `ruleKeys.length` (derived) so it needs no manual count change — verify it still passes | +| 7 | `server/src/utils/preferences.spec.ts` | default `memories.types` gains `video_moments: true` | +| 8 | `server/src/services/server.service.spec.ts` | **BOTH** assertions: line ~196 and line ~220 | +| 10 | `e2e/src/specs/server/api/server.e2e-spec.ts` | `availableMemoryTypes` fixture (~line 148) | +| 11 | `web/src/routes/admin/system-settings/MemoriesSettings.svelte` | `memoryTypeKeys` array (line 12) | +| 12 | `.../MemoriesSettings.spec.ts` | switch count **and** the full `types` object literal in the save-payload test (~lines 92-107) | +| 13 | `i18n/en.json` | 4 keys (below) | +| 14 | `docs/docs/features/memories.md` | add to the user-facing type list | +| 15 | `docs/docs/install/config-file.md` | add to the `memories.types` keys | +| 16 | `docs/plans/2026-07-15-memory-types-roadmap.md` | row #11 Status → **Shipped** — `video_moments` | + +Expected `availableMemoryTypes` after this slice (**9** entries, registry order): + +``` +on_this_day, birthday, recent_trip, month_recap, favorites_throwback, +on_this_day_place, season_recap, people_together, video_moments +``` + +i18n keys (`i18n/en.json`, EN only — keep the file's existing alphabetical ordering within its +sections): + +``` +"memory_type_video_moments": "Video moments" +"memory_type_video_moments_description": "Videos you filmed in this month of a past year." +"admin.memory_type_video_moments_setting": "Video moments" +"admin.memory_type_video_moments_setting_description": "Surface videos from this month in a past year." +``` + +Note `admin.*` keys live nested under the `admin` object in `en.json` — match the existing +`memory_type_people_together` entries' placement exactly. + +## Verification + +```bash +cd server && pnpm test --run src/services/memory-rules/ # all green incl. new spec +cd server && pnpm test --run src/utils/preferences.spec.ts src/services/server.service.spec.ts +cd server && pnpm run check # tsc --noEmit +cd server && npx prettier --check "src/services/memory-rules/**" +cd web && pnpm test --run src/routes/admin/system-settings/MemoriesSettings.spec.ts +npx prettier --check "docs/**/*.md" "i18n/en.json" # from worktree root +``` + +`make check-server` does NOT exist in this repo (the Makefile has stubs) — use `cd server && pnpm run check`. + +## Out of scope + +No medium test (Slice 8). No `trip_anniversary`/`themed`. No mobile change (Slice 3). + +## Commit + +`feat(memories): add video_moments memory type` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-3.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-3.md new file mode 100644 index 0000000000000..6286ac3dd90ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-3.md @@ -0,0 +1,69 @@ +# Slice 3 — Mobile: force autoplay for videos in the memory viewer + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §4.4, §6.9, Slice 3. +Independent of every other slice (mobile only, no shared files). + +## Problem + +`NativeVideoViewer` gates playback on the user's global setting +(`video_viewer.widget.dart:221-222`): + +```dart +final autoPlayVideo = ref.read(appConfigProvider).viewer.autoPlayVideo; +if (autoPlayVideo || widget.asset.isMotionPhoto) { + await _notifier.play(); +} +``` + +`DriftMemoryCard` builds it with `showControls: false` +(`memory_card.widget.dart:63-70`), so a user with autoplay **off** sees a frozen first frame **and +no play button**. This is pre-existing (videos can already land in memories today) but becomes +user-visible with `video_moments`. + +## Changes + +1. `mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart` + - Add `final bool forceAutoPlay;` to `NativeVideoViewer` (class fields, ~line 26). + - Add `this.forceAutoPlay = false,` to the constructor (~line 34). Default `false` means **no + existing call site changes behavior**. + - Change the gate (~line 222) to: + `if (widget.forceAutoPlay || autoPlayVideo || widget.asset.isMotionPhoto) {` + +2. `mobile/lib/presentation/widgets/memory/memory_card.widget.dart` + - Pass `forceAutoPlay: true` in the `NativeVideoViewer(...)` construction (~line 63-70). + +## Verification + +```bash +cd mobile && dart analyze --fatal-infos lib test +cd mobile && dart format --set-exit-if-changed . +``` + +Note: CI runs **two** Dart gates — `dart analyze --fatal-infos lib test` (local +`flutter analyze lib` misses test-only lints) and `dart format --set-exit-if-changed`. + +Analyzer/formatter need generated files that are gitignored. Per CLAUDE.md, from `mobile/`: +`flutter pub get`, then `dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart`. + +## Test (§6.9) — with an honest constraint + +Intended: assert `NativeVideoViewer` defaults `forceAutoPlay` to `false`, and that `DriftMemoryCard` +constructs it with `forceAutoPlay: true` for a video asset. + +`NativeVideoViewer` initialises a platform video controller on mount, so a full `pumpWidget` may be +flaky in CI. **If it proves unstable, downgrade to a construction-only assertion** (instantiate the +widget directly and read the field — no pumping, no platform channel) and record manual verification +in the PR. Do **not** paper over a flake with a retry: per fork policy, flakes are fixed at the root +or the test is scoped down deliberately. + +A direct-construction assertion is genuinely meaningful here because the whole change is "is the flag +plumbed through with the right default", which is a pure constructor-wiring property. + +## Out of scope + +No mobile auto-advance timer (pre-existing gap affecting every memory type — spec §9). No muting +changes. + +## Commit + +`fix(mobile): force autoplay for videos in the memory viewer` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-4.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-4.md new file mode 100644 index 0000000000000..90eb8c7308567 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-4.md @@ -0,0 +1,152 @@ +# Slice 4 — `trip.util.ts` (pure helpers) + `recent_trip` refactor + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.2, §6.2, Slice 4. +Prepares Slice 5 (`trip_anniversary`). Registers **no** memory type. + +## Goal + +Extract the trip-detection and curation logic currently private to `recent-trip.rule.ts` into a pure, +unit-tested module that `trip_anniversary` can share, and introduce the **canonical `placeKeyOf`** +that makes the Slice-5 cross-rule dedupe actually collide. + +`recent-trip.rule.spec.ts` must pass **completely unchanged** — it is the regression guard proving +the refactor preserved behavior exactly. + +## Step 1 — RED: `server/src/services/memory-rules/trip.util.spec.ts` (new) + +Write every case from spec §6.2. Build `MemoryLocationCluster` literals +(`{ country, city, assetCount, dayCount, firstDate, lastDate }`) and `MemoryAsset` literals +(`{ id, localDateTime }`). + +**`placeKeyOf`** (the §3.3 collision contract): + +- `('Italy', 'Rome')` → `'italy:rome'`; `('ITALY', 'ROME')` → `'italy:rome'` (case-insensitive). +- `(null, 'Rome')` → `':rome'`; `('Italy', null)` → `'italy:'` — **both null positions**. This is + exactly what the two divergent pre-existing implementations got wrong. + +**`inferHome`:** + +- returns the top cluster when it dominates. +- `null` when the top cluster's `country` is `null`. +- `null` when a **different-country** runner-up has `assetCount >= top.assetCount / 1.25`. +- returns the top cluster when the runner-up is **same-country** (not ambiguous). +- `null` for `[]`. + +**`isAwayFromHome`:** + +- different country → `true`. +- same country, different non-null city → `true`. +- same country, same city → `false`. +- same country, **home** city `null` → `false`. +- same country, **candidate** city `null` → `false`. + +**`findTripStartingOn`:** + +- picks a cluster whose `firstDate` is on the anniversary day meeting both thresholds. +- **rejects `firstDate` one day before** the anniversary (mid-stay). +- **rejects `firstDate` one day after**. +- **UTC boundary:** a `firstDate` of `23:30Z` **on** the anniversary day **qualifies** — proves the + comparison is UTC-calendar-day based, not instant based. Use + `new Date('2023-06-14T23:30:00Z')` against an anniversary of `2023-06-14`. +- boundary pairs: rejects `assetCount = minAssets - 1`, accepts `= minAssets`; rejects + `dayCount = 1`, accepts `= 2`. +- rejects a cluster meeting the thresholds that is **not** away from home. +- two qualifying clusters same day → higher `assetCount` wins; equal counts → lower `placeKeyOf` + (deterministic). +- `null` for `[]`. + +**`curateTripAssets(assets, cap)`:** + +- collapses assets within the 2-minute burst window to one representative. +- returns all when `<= SMALL_TRIP_MAX` (6) after collapsing. +- covers distinct days before topping up. +- **never exceeds `cap`**, including a `cap` **below** the internal ladder (e.g. `cap: 4` on a large + multi-day set returns exactly 4) — this is the case that makes Slice 5's `ASSET_CAP` assertion + non-tautological. +- output is chronologically sorted and has no duplicate ids. + +Run: `cd server && pnpm test --run src/services/memory-rules/trip.util.spec.ts` +**Expected red:** module not found. Capture the output. + +> ⚠️ `pnpm test --run ` — NOT `pnpm test -- --run ` (this pnpm passes the literal `--` +> through and drops the path filter). + +## Step 2 — GREEN: `server/src/services/memory-rules/trip.util.ts` (new) + +Exports exactly as spec §3.2: + +```ts +export const BURST_WINDOW_MS = 2 * 60 * 1000; +export const SMALL_TRIP_MAX = 6; +export const HOME_DOMINANCE_RATIO = 1.25; + +export interface TripThresholds { + minAssets: number; + minDays: number; +} + +export const placeKeyOf = (country: string | null, city: string | null): string => + `${country ?? ''}:${city ?? ''}`.toLowerCase(); + +export const inferHome = (clusters: MemoryLocationCluster[]): MemoryLocationCluster | null; +export const isAwayFromHome = (item: MemoryLocationCluster, home: MemoryLocationCluster): boolean; +export const findTripStartingOn = ( + clusters: MemoryLocationCluster[], + anniversary: DateTime, + home: MemoryLocationCluster, + thresholds: TripThresholds, +): MemoryLocationCluster | null; +export const curateTripAssets = (assets: MemoryAsset[], cap: number): string[]; +``` + +**Port the bodies verbatim** from `recent-trip.rule.ts`: + +- `inferHome` ← the `const [home, runnerUp] = baseline` + `isAmbiguousHome` logic (lines 39-50). +- `isAwayFromHome` ← the country/city half of `isTripCandidate` (lines 116-120) — **thresholds are + NOT part of this helper**, `findTripStartingOn` applies them. +- `curateTripAssets` ← `curateTripAssets` + `collapseBurstAssets` + `groupAssetsByDay` + + `getTripTargetSize` + `pickDayCoverage` (lines 123-193), with the ladder result additionally + clamped: `Math.min(cap, ladderSize)`. Use `pickEvenlySpaced` from `curation.util` — do **not** + copy the rule's private duplicate. + +`findTripStartingOn` is new logic (no equivalent exists): filter clusters by UTC-day equality on +`firstDate` via `DateTime.fromJSDate(c.firstDate, { zone: 'utc' }).hasSame(anniversary, 'day')`, +then by `isAwayFromHome` and both thresholds, then sort by `assetCount` desc, `placeKeyOf` asc, and +return the first or `null`. + +## Step 3 — GREEN: refactor `recent-trip.rule.ts` + +- Import `inferHome`, `isAwayFromHome`, `curateTripAssets`, `placeKeyOf`, `HOME_DOMINANCE_RATIO`. +- Replace the inline home/ambiguity block with `inferHome(baseline)`; `null` ⇒ `return []`. +- `isTripCandidate` keeps its **threshold** checks (`assetCount < 7 || dayCount < 2`) but delegates + the country/city comparison to `isAwayFromHome`. +- Replace the `placeKey` template with `placeKeyOf(candidate.country, candidate.city)`. + ⚠️ This **changes the key's null handling** (`` `${country}:${city ?? ''}` `` → both-null-safe). + For `recent_trip` the value is identical whenever `country` is non-null, which the rule already + guarantees by returning early on `!candidate.country` — so behavior is preserved. Confirm + `recent-trip.rule.spec.ts` still passes unchanged. +- Replace the private curation with `curateTripAssets(locationAssets, 10)` — `10` preserves today's + ceiling exactly. +- **Delete** the now-unused private methods: `curateTripAssets`, `collapseBurstAssets`, + `groupAssetsByDay`, `getTripTargetSize`, `pickDayCoverage`, `pickEvenlySpaced`, and the + `BURST_WINDOW_MS` / `SMALL_TRIP_MAX` / `HOME_DOMINANCE_RATIO` private statics. + +## Verification + +```bash +cd server && pnpm test --run src/services/memory-rules/ # ALL green; recent-trip spec UNCHANGED +cd server && pnpm run check +cd server && npx eslint src/services/memory-rules/ --max-warnings 0 +cd server && npx prettier --check "src/services/memory-rules/**" +``` + +`git diff --stat server/src/services/memory-rules/recent-trip.rule.spec.ts` must be **empty**. + +## Out of scope + +No `trip_anniversary` rule, no registry/metadata entry, no change to `on-this-day-place.rule.ts` +(that is Slice 5). + +## Commit + +`refactor(memories): extract shared trip detection and curation helpers` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-5.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-5.md new file mode 100644 index 0000000000000..29b042e4243d1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-5.md @@ -0,0 +1,142 @@ +# Slice 5 — `trip_anniversary` + shared place-day dedupe + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.3, §4.1, §6.3, Slice 5. +Depends on Slice 4 (`trip.util.ts`). Registers the **10th** memory type key. + +## Part A — change `on_this_day_place` (do this FIRST, it is the dedupe contract) + +File: `server/src/services/memory-rules/on-this-day-place.rule.ts`. **Three** changes: + +1. **Use the canonical place key.** Delete the local + `const placeKeyOf = (asset) => \`${asset.country ?? ''}:${asset.city}\`.toLowerCase()`(line 5) and +use`placeKeyOf(asset.country, asset.city)`from`src/services/memory-rules/trip.util`. +2. **Shared dedupe namespace.** `dedupeKey` becomes + `` `place_day:${year}-${mm}-${dd}:${dominant.key}` `` (was `on_this_day_place:...`). +3. **Cap the count term.** `score: 100 + Math.min(count, 30) * 3 + recencyBonus(year, target.year)`. + Without the cap the score is unbounded and a heavily-photographed day beats `trip_anniversary` + exactly when the trip was well documented — inverting §3.3's precedence. + +Also **export the constants** (spec D8) so the invariant test can import them: +`MIN_ASSETS = 4`, `MIN_DOMINANCE = 0.6`, `MAX_YEARS = 3`, `ASSET_CAP = 8`, `SCORE_BASE = 100`, +`MAX_COUNT_BONUS = 30`. + +Update `on-this-day-place.rule.spec.ts` for the new key format and capped score **only** — no other +assertion changes. Add one case: a year with `count = 40` scores the same as `count = 30` (proves the cap). + +## Part B — the rule (TDD) + +### B1. RED — `server/src/services/memory-rules/trip-anniversary.rule.spec.ts` (new) + +Deps to mock: `Pick`. + +Every case is **given/when/then** because each needs three fixtures across two repository methods +with different date windows. `getMemoryLocationClusters` is called twice per year with _different_ +windows (home baseline, then trip window) — drive it with `mockResolvedValueOnce` chains or a +window-aware `mockImplementation`, and be explicit which call is which. + +Cases (spec §6.3): + +1. **Fires.** _Given_ a probe with one dominant city in 2023 (≥3 assets, ratio ≥0.6), a home baseline + whose top cluster is a different country, and a trip cluster with `firstDate` on the anniversary, + 8 assets over 3 days, _When_ evaluated on that anniversary, _Then_ exactly one candidate with + pinned `title` (`'Your trip to Rome, Italy'`), `subtitle` (`'3 years ago · 8 photos over 3 days'`), + exact `score`, `memoryAt` equal to the cluster's `firstDate`, `visibleForDays: 3`, and a + `place_day:` `dedupeKey`. +2. **Shared-key contract.** _Given_ the same probe fixture fed to BOTH rules, _Then_ + `trip_anniversary`'s `dedupeKey` **equals** `on_this_day_place`'s. Assert against the other rule's + **real output** (instantiate `OnThisDayPlaceMemoryRule` in the test) — never a hand-written string. +3. **Scoring invariant.** _Given_ `trip_anniversary` at its minimum (`MIN_TRIP_DAYS` days, + `MIN_TRIP_ASSETS` assets, oldest year → `recencyBonus` 0) and `on_this_day_place` at its maximum + (`count ≥ 30`, most recent past year → `recencyBonus` 9), _Then_ the trip score is strictly + greater. Compute both from the **exported constants** of both rules, not hardcoded numbers. +4. **Probe short-circuit.** _Given_ no past year with a dominant city, _Then_ `[]` **and + `getMemoryLocationClusters` was never called**. +5. **Ambiguous home.** _Given_ a probe that DOES qualify, a baseline whose different-country + runner-up is within 1.25×, **and a trip cluster that would otherwise qualify**, _Then_ `[]` **and + `getMemoryAssetsForLocation` was never called** — proving the ambiguity guard fired, not the probe. +6. **Mid-stay rejection.** _Given_ a qualifying probe and home, and a trip cluster whose `firstDate` + is the day BEFORE the anniversary, _Then_ `[]` and no asset fetch. +7. **Boundary pairs:** `dayCount` 1 vs 2; `assetCount` 6 vs 7; probe ratio just below vs at 0.6; + probe `items.length` 2 vs 3. +8. **Leap year.** _Given_ `target = DateTime.utc(2024, 2, 29)` and a qualifying **2023** trip, _Then_ + 2023 is **skipped** (Luxon clamps `.set({year: 2023})` to Feb 28, so the day guard rejects it). + _And_ given a qualifying **2020** (leap) trip, it still fires. +9. Skips the current year and future-dated assets. +10. Caps candidates at `MAX_CANDIDATES` (2); caps assets at `ASSET_CAP` (10). +11. Evaluates at most `MAX_PROBE_YEARS` (4) years — assert the cluster-query call count. +12. `subtitle` pluralization: `1 year ago` vs `3 years ago`. +13. City `null` → title is `'Your trip to Italy'` (country only). +14. **Zero assets** — empty probe ⇒ `[]`, no throw. + +Run: `cd server && pnpm test --run src/services/memory-rules/trip-anniversary.rule.spec.ts` +**Expected red:** module not found. + +### B2. GREEN — `server/src/services/memory-rules/trip-anniversary.rule.ts` (new) + +Follow spec §4.1 exactly. **Export the constants**: `MIN_PROBE_ASSETS = 3`, +`MIN_PROBE_DOMINANCE = 0.6`, `MAX_PROBE_YEARS = 4`, `GAP_DAYS = 5`, `TRIP_WINDOW_DAYS = 21`, +`MIN_TRIP_ASSETS = 7`, `MIN_TRIP_DAYS = 2`, `HOME_BASELINE_DAYS = 90`, `ASSET_CAP = 10`, +`MAX_CANDIDATES = 2`, `SCORE_BASE = 260`. + +Algorithm: + +1. **Probe:** `getMemoryAssetsForPeriod({ months: [target.month], day: target.day, takenBefore: target.endOf('day').toJSDate() })`. + Bucket by year; skip `year >= target.year` and blank cities. Per year + `dominantBy(assets, (a) => placeKeyOf(a.country, a.city))`; keep when + `items.length >= MIN_PROBE_ASSETS && ratio >= MIN_PROBE_DOMINANCE`. Empty ⇒ `return []` **before + any cluster query**. Sort years desc, take `MAX_PROBE_YEARS`. +2. **Leap guard:** `const anniversary = target.set({ year: Y }).startOf('day')`; skip the year when + `anniversary.day !== target.day || anniversary.month !== target.month`. +3. **Home:** `getMemoryLocationClusters({ takenAfter: anniversary.minus({ days: HOME_BASELINE_DAYS }).toJSDate(), takenBefore: anniversary.minus({ days: GAP_DAYS + 1 }).endOf('day').toJSDate() })` + → `inferHome(...)`; `null` ⇒ skip year. +4. **Trip window:** `getMemoryLocationClusters({ takenAfter: anniversary.minus({ days: GAP_DAYS }).toJSDate(), takenBefore: anniversary.plus({ days: TRIP_WINDOW_DAYS }).endOf('day').toJSDate() })` + → `findTripStartingOn(clusters, anniversary, home, { minAssets: MIN_TRIP_ASSETS, minDays: MIN_TRIP_DAYS })`. +5. **Build:** `getMemoryAssetsForLocation({ country, city, takenAfter: cluster.firstDate, takenBefore: cluster.lastDate })` + → `curateTripAssets(assets, ASSET_CAP)`. + +Candidate fields exactly per spec §4.1's table. `yearsAgo = target.year - Y`. + +## Part C — register the type at all 16 sites + +One new key `trip_anniversary`, appended **last** (after `video_moments`). +Expected `availableMemoryTypes` (**10**): + +``` +on_this_day, birthday, recent_trip, month_recap, favorites_throwback, +on_this_day_place, season_recap, people_together, video_moments, trip_anniversary +``` + +Same 16 sites as Slice 2 — see `docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-2.md` +Part B for the exact file/line map. Note `memory-type.metadata.spec.ts` has **5** hardcoded list +assertions and `preferences.spec.ts` has **2**. + +i18n (`i18n/en.json`): + +``` +"memory_type_trip_anniversary": "Trip anniversaries" +"memory_type_trip_anniversary_description": "Past trips resurfaced on the anniversary of the day they began." +"admin.memory_type_trip_anniversary_setting": "Trip anniversaries" +"admin.memory_type_trip_anniversary_setting_description": "Resurface a past trip on the anniversary of its first day." +``` + +Roadmap row **#6** Status → **Shipped** — `trip_anniversary`. + +## Verification + +```bash +cd server && pnpm test --run src/services/memory-rules/ +cd server && pnpm test --run src/utils/preferences.spec.ts src/services/server.service.spec.ts +cd server && pnpm run check +cd server && npx eslint src/services/memory-rules/ --max-warnings 0 +cd server && npx prettier --check "src/services/memory-rules/**" +cd web && pnpm test --run src/routes/admin/system-settings/MemoriesSettings.spec.ts +npx prettier --check "docs/**/*.md" "i18n/en.json" +``` + +## Out of scope + +No medium test (Slice 8). No `themed`. + +## Commit + +`feat(memories): add trip_anniversary memory type` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-6.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-6.md new file mode 100644 index 0000000000000..4db61ea240b5e --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-6.md @@ -0,0 +1,164 @@ +# Slice 6 — Theme catalog + `ThemeSearchPort` + adapter + config + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.4, §3.4.1, §4.2 (catalog/adapter halves), +§6.5, Slice 6. Prepares Slice 7 (`themed`). Registers **no** memory type. + +## Part A — `theme.catalog.ts` (pure) + +### A1. RED — `server/src/services/memory-rules/theme.catalog.spec.ts` (new) + +- `THEMES` has 6 entries; every `key` is unique; every entry has a non-empty `query` and `label`. +- `themeForMonth` pinned for **all 12 months** (write out the expected key per month). +- Month 1 and month 7 give the **same** theme (6 themes, 12 months). +- The same month in **different years** gives the same theme — i.e. `themeForMonth` depends only on + the month, so rotation is stable across year and leap boundaries. (Spec §4.2: this is exactly why + rotation is month-based rather than day-of-year — `365 % 6 !== 0` would break stability.) + +Run: `cd server && pnpm test --run src/services/memory-rules/theme.catalog.spec.ts` + +### A2. GREEN — `server/src/services/memory-rules/theme.catalog.ts` (new) + +```ts +export interface Theme { + key: string; + /** CLIP text prompt */ + query: string; + /** human label used in the memory title */ + label: string; +} + +export const THEMES: Theme[] = [ + { key: 'sunset', query: 'a beautiful sunset', label: 'Sunsets' }, + { key: 'beach', query: 'a beach with sand and ocean', label: 'Beach days' }, + { key: 'food', query: 'a plate of food at a meal', label: 'Food' }, + { key: 'mountains', query: 'mountains and hiking trails', label: 'Mountains' }, + { key: 'snow', query: 'a snowy winter landscape', label: 'Snow days' }, + { key: 'city_night', query: 'a city skyline at night', label: 'City lights' }, +]; + +/** Deterministic for a given calendar month, forever. */ +export const themeForMonth = (month: number): Theme => THEMES[(month - 1) % THEMES.length]!; +``` + +## Part B — `theme-search.port.ts` (interface only, no test) + +Exactly spec §3.4: + +```ts +export interface ThemeSearchAsset { + id: string; + localDateTime: Date; +} + +export interface ThemeSearchPort { + /** null when smart search is disabled or the embedding cannot be produced. Never throws. */ + resolveEmbedding(themeKey: string, query: string): Promise; + /** Assets ordered by similarity, best first. */ + searchByEmbedding(params: { + ownerId: string; + embedding: string; + takenAfter: Date; + takenBefore: Date; + size: number; + }): Promise; +} +``` + +## Part C — the adapter (TDD) + +### C1. RED — `server/src/services/memory-rules/theme-search.adapter.spec.ts` (new) + +Construct `MemoryThemeSearchAdapter` with fakes for its three collaborators (see C2). Cases from +spec §6.5: + +1. `resolveEmbedding` returns `null` when `isSmartSearchEnabled(config.machineLearning)` is false + (e.g. `machineLearning.enabled: false`, or `clip.enabled: false`) — and `encodeText` is **not called**. +2. `encodeText` is called **once** for two identical `(modelName, language, themeKey)` requests — + the second is a cache hit returning the same string. +3. `encodeText` is called **again** when `clip.modelName` changes between calls. +4. `encodeText` is called **again** when `language` changes between calls. + (3 and 4 prove the cache key is `${modelName}:${language ?? 'default'}:${themeKey}` — spec §4.2.) +5. `encodeText` rejects ⇒ `resolveEmbedding` returns `null`, does **not** throw. +6. `searchByEmbedding` forwards to `searchSmart` with: `userIds: [ownerId]`, + `type: AssetType.Image`, `visibility: AssetVisibility.Timeline`, the given `size`, + `maxDistance` from `config.memories.themeMaxDistance`, and **2-day-widened** date bounds. + Assert the **exact** widened Dates (`takenAfter - 2d`, `takenBefore + 2d`) — spec §3.4.1. +7. `searchByEmbedding` maps `searchSmart`'s rows to `{ id, localDateTime }` only. + +### C2. GREEN — `server/src/services/memory-rules/theme-search.adapter.ts` (new) + +```ts +export const SEARCH_WINDOW_MARGIN_DAYS = 2; + +export class MemoryThemeSearchAdapter implements ThemeSearchPort { + private readonly cache = new Map(); + + constructor( + private machineLearningRepository: Pick, + private searchRepository: Pick, + private getConfig: () => Promise, + private logger: LoggingRepository, // or a minimal { warn } shape + ) {} + ... +} +``` + +`resolveEmbedding(themeKey, query)`: + +- `const config = await this.getConfig();` +- `if (!isSmartSearchEnabled(config.machineLearning)) return null;` (import from `src/utils/misc`) +- `const { modelName } = config.machineLearning.clip;` — `language` is `undefined` in this batch, but + **include it in the cache key** so a future non-English deployment cannot serve a stale English + embedding: `` const cacheKey = `${modelName}:${language ?? 'default'}:${themeKey}` `` +- return cached if present; else `await encodeText(query, { modelName, language })`, cache, return. +- wrap the `encodeText` call in try/catch → log and `return null`. + +`searchByEmbedding({ ownerId, embedding, takenAfter, takenBefore, size })`: + +- `const config = await this.getConfig();` +- widen: `takenAfter - SEARCH_WINDOW_MARGIN_DAYS days`, `takenBefore + SEARCH_WINDOW_MARGIN_DAYS days` + (use Luxon or plain ms arithmetic — be consistent and testable). +- `const { items } = await searchSmart({ page: 1, size }, { embedding, userIds: [ownerId], takenAfter: widenedAfter, takenBefore: widenedBefore, type: AssetType.Image, visibility: AssetVisibility.Timeline, maxDistance: config.memories.themeMaxDistance })` +- `return items.map(({ id, localDateTime }) => ({ id, localDateTime }))` + +> **Why widened:** `searchAssetBuilder` maps `takenAfter`/`takenBefore` to **`asset.fileCreatedAt`** +> (`server/src/utils/database.ts:725-726`), not `localDateTime`. The margin ensures no in-year asset +> is missed by that skew; the **rule** (Slice 7) then filters precisely by `localDateTime` year. + +## Part D — config field + +- `server/src/config.ts`: add `themeMaxDistance: number;` to the `memories` type (~line 179) and + `themeMaxDistance: 0.3,` to the `memories` defaults (~line 443). +- `server/src/dtos/system-config.dto.ts`: `SystemConfigMemoriesSchema` (~line 267) adds + `themeMaxDistance: z.coerce.number().min(0).max(2).default(0.3).describe('Max CLIP cosine distance for themed memories')`. + Note `0 < maxDistance < 2` is the active range (`isActiveDistanceThreshold`); `0` disables the + threshold entirely, which for themed means "no quality gate" — allowed but not the default. +- `server/src/services/system-config.service.spec.ts`: extend the defaults assertion if it pins the + whole `memories` object. + +## Part E — SDK regeneration + +```bash +cd server && pnpm build && pnpm sync:open-api +cd .. && make open-api-typescript +``` + +Commit the regenerated `open-api/typescript-sdk/` output. Do **not** hand-edit generated files. + +## Verification + +```bash +cd server && pnpm test --run src/services/memory-rules/ +cd server && pnpm test --run src/services/system-config.service.spec.ts +cd server && pnpm run check +cd server && npx eslint src/services/memory-rules/ src/config.ts src/dtos/system-config.dto.ts --max-warnings 0 +cd server && npx prettier --check "src/services/memory-rules/**" src/config.ts src/dtos/system-config.dto.ts +``` + +## Out of scope + +No `themed` rule, no registry/metadata entry, no wiring into `MemoryService` — all Slice 7. + +## Commit + +`feat(memories): add theme catalog and smart-search port for themed memories` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-7.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-7.md new file mode 100644 index 0000000000000..e6b2024f3e17c --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-7.md @@ -0,0 +1,169 @@ +# Slice 7 — `themed` memory type (end-to-end) + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §3.4, §3.4.1, §4.2, §6.4, Slice 7. +Depends on Slice 6 (catalog, port, adapter, `themeMaxDistance`). Registers the **11th** key. + +## Part A — the rule (TDD) + +### A1. RED — `server/src/services/memory-rules/themed.rule.spec.ts` (new) + +Dependency is the **port**, so the fake is trivial: + +```ts +const port = { + resolveEmbedding: vi.fn().mockResolvedValue('embedding-string'), + searchByEmbedding: vi.fn().mockResolvedValue([...]), +}; +``` + +Cases (spec §6.4 — all of them): + +1. **Trigger day** — `[]` on days 1, 8, 15, 21, 23; fires on **22**. +2. **Fires** — pinned `title` `'Sunsets from 2023'`, `subtitle` `'18 photos'`, exact `score`, + `dedupeKey` `'themed:sunset:2023'`, `visibleForDays: 5`, `ruleId: 'themed'`. + Pick a `target` month whose `themeForMonth` is `sunset` (month 1 or 7 — verify against the + catalog) so the title is deterministic. +3. **Disabled path** — `resolveEmbedding` → `null` ⇒ `[]` **and `searchByEmbedding` never called**. +4. **Rejection is swallowed** — `resolveEmbedding` rejects ⇒ `[]`, no throw. +5. **Year filter (§3.4.1)** — _Given_ the port returns assets whose `localDateTime` is in `Y-1`, + `Y`, and `Y+1`, _Then_ only the `Y` ones are counted, `count`/subtitle reflect the filtered set, + and `MIN_ASSETS` is applied **after** filtering: a year returning 8 raw assets of which only 7 + are in-year does **NOT** fire. +6. **MIN_ASSETS boundary pair** — 7 in-year ⇒ `[]`; exactly 8 ⇒ fires. +7. Searches exactly `MAX_YEARS_BACK` (3) years, never the current year — assert the + `searchByEmbedding` call count and the year bounds passed. +8. **Multi-year** — two qualifying years ⇒ **BOTH** candidates returned, sorted by score desc, + capped at `MAX_CANDIDATES` (3). This is the review-#4 regression guard: a 1-candidate rule would + make older years permanently unreachable once the newest year's memory exists, because + `hasRuleMemory` filtering happens in the engine **after** the rule returns. +9. **Non-tautological ordering** — _Given_ the port returns assets in **similarity** (deliberately + non-chronological) order, _Then_ `assetIds` equals a **pinned** array that is chronological and + capped at `ASSET_CAP` (16). Proves `sampleAssetsByTime` reordered them. +10. Passes `size: FETCH_SIZE` (40) and **`Date`** (not Luxon `DateTime`) bounds to the port. +11. **Zero assets** — port returns `[]` ⇒ `[]`, no throw (guards `medianTime([])`). + +Run: `cd server && pnpm test --run src/services/memory-rules/themed.rule.spec.ts` +**Expected red:** module not found. + +### A2. GREEN — `server/src/services/memory-rules/themed.rule.ts` (new) + +Follow spec §4.2. **Export the constants**: `TRIGGER_DAY = 22`, `MAX_YEARS_BACK = 3`, +`FETCH_SIZE = 40`, `MIN_ASSETS = 8`, `ASSET_CAP = 16`, `VISIBLE_FOR_DAYS = 5`, +`MAX_CANDIDATES = 3`, `SCORE_BASE = 70`. + +```ts +export class ThemedMemoryRule implements MemoryRule { + readonly id = 'themed'; + constructor(private themeSearchPort: ThemeSearchPort) {} +} +``` + +Algorithm: + +1. `if (target.day !== TRIGGER_DAY) return []` +2. `const theme = themeForMonth(target.month)` +3. `const embedding = await this.themeSearchPort.resolveEmbedding(theme.key, theme.query)`; + `if (embedding === null) return []` +4. For `Y` from `target.year - 1` down to `target.year - MAX_YEARS_BACK`: + - `takenAfter = DateTime.utc(Y, 1, 1).startOf('day')`, + `takenBefore = DateTime.utc(Y, 12, 31).endOf('day')` — **no `min(..., target)` clamp**, the + year range already excludes the current year (dead code, spec review #11). + - `const assets = await port.searchByEmbedding({ ownerId, embedding, takenAfter: takenAfter.toJSDate(), takenBefore: takenBefore.toJSDate(), size: FETCH_SIZE })` + - **filter by `localDateTime` year**: + `assets.filter((a) => DateTime.fromJSDate(a.localDateTime, { zone: 'utc' }).year === Y)` + - `if (filtered.length < MIN_ASSETS) continue` +5. Candidate per spec §4.2's table; `count = filtered.length` (pre-`ASSET_CAP`). + Sort by score desc, `.slice(0, MAX_CANDIDATES)`. + +The rule never sees `maxDistance` — the adapter owns it. + +## Part B — wire the port through `MemoryService` + +`server/src/services/memory-rules/memory-type.registry.ts`: + +- `MemoryRuleDeps` gains `themeSearchPort: ThemeSearchPort`. +- `themed: (deps) => new ThemedMemoryRule(deps.themeSearchPort)`. +- ⚠️ `memory-type.registry.spec.ts` builds a `deps` object — it must now include a + `themeSearchPort` stub, or every registry test fails to compile. + +`server/src/services/memory.service.ts`: + +- Add a memoized field + an **overridable factory** so the Slice-8 medium test can inject a stub + without a live ML service (spec §6.8): + +```ts +private themeSearchPort?: ThemeSearchPort; + +/** Overridable seam: the medium test subclasses MemoryService to inject a stub. */ +protected createThemeSearchPort(): ThemeSearchPort { + return new MemoryThemeSearchAdapter( + this.machineLearningRepository, + this.searchRepository, + () => this.getConfig({ withCache: true }), + this.logger, + ); +} + +private getThemeSearchPort(): ThemeSearchPort { + this.themeSearchPort ??= this.createThemeSearchPort(); + return this.themeSearchPort; +} +``` + +- In `getMemoryRules` (line ~115), add `themeSearchPort: this.getThemeSearchPort()` to the deps + object passed to `createMemoryRules`. + +**Memoization matters:** the field is per-service-instance and the adapter holds the embedding +cache, so a theme is encoded once per process rather than once per user per night. + +`this.machineLearningRepository` (`base.service.ts:187`) and `this.searchRepository` (`:200`) are +already injected on `BaseService`; no constructor plumbing needed. + +⚠️ **Regression guard:** existing `memory.service.spec.ts` tests `vi.spyOn` the private +`getMemoryRules` / `createRuleMemories` with **arg-agnostic** mocks — they must stay green +**unchanged**. Confirm with `git diff --stat` on that file being empty. + +## Part C — register the type at all 16 sites + +One new key `themed`, appended **last**. Expected `availableMemoryTypes` (**11**): + +``` +on_this_day, birthday, recent_trip, month_recap, favorites_throwback, on_this_day_place, +season_recap, people_together, video_moments, trip_anniversary, themed +``` + +Same 16 sites as Slice 2 (see that plan's Part B map). `memory-type.metadata.spec.ts` has **5** +hardcoded list assertions; `preferences.spec.ts` has **2**; `server.service.spec.ts` has **2**. + +i18n (`i18n/en.json`): + +``` +"memory_type_themed": "Themes" +"memory_type_themed_description": "Photo themes like sunsets, food, and beach days, found automatically." +"admin.memory_type_themed_setting": "Themes" +"admin.memory_type_themed_setting_description": "Group photos by visual theme using smart search. Requires smart search to be enabled." +``` + +Roadmap row **#7** Status → **Shipped** — `themed`. + +## Verification + +```bash +cd server && pnpm test --run src/services/memory-rules/ src/services/memory.service.spec.ts +cd server && pnpm test --run src/utils/preferences.spec.ts src/services/server.service.spec.ts +cd server && pnpm run check +cd server && npx eslint src/services/ --max-warnings 0 +cd server && npx prettier --check "src/services/memory-rules/**" src/services/memory.service.ts +cd web && pnpm test --run src/routes/admin/system-settings/MemoriesSettings.spec.ts +npx prettier --check "docs/**/*.md" "i18n/en.json" +``` + +Then the **full** server suite: `cd server && pnpm test --run`. + +## Out of scope + +No medium test (Slice 8). No calibration (Slice 8). + +## Commit + +`feat(memories): add themed memory type backed by smart search` diff --git a/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-8.md b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-8.md new file mode 100644 index 0000000000000..f6519b034c751 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-memory-types-tier3-slice-8.md @@ -0,0 +1,134 @@ +# Slice 8 — Medium tests, docs, calibration gate + +Spec: `docs/plans/2026-07-19-memory-types-tier3-spec.md` §6.8, §7, Slice 8. +Final slice. Depends on Slices 1–7 (all committed). + +## Part A — extend `seedRuleAsset` + +`server/test/medium/specs/services/memory.service.spec.ts` (helper at line 41) currently accepts +only `{ ownerId, localDateTime, city, country, isFavorite }`. Add: + +```ts +type = AssetType.Image, +duration = null, +... +type?: AssetType; +duration?: number | null; +``` + +and pass both through to `ctx.newAsset({ ownerId, localDateTime, isFavorite, type, duration })` +(`assetInsert` in `test/medium.factory.ts` spreads overrides over its defaults, so both are accepted). + +## Part B — medium tests (real DB, end-to-end generation) + +Add a `describe('onMemoriesCreate — Tier 3 rules (end-to-end generation)')` block modelled on the +existing Tier 1 block (line 649). Each rule gets a **positive and a negative** case; the negative +must fail for a _different_ reason than the positive passes. + +### B1. `video_moments` + +- **Positive:** seed ≥3 videos (`type: AssetType.Video`, `duration` in the 3s–180s band) in the + target month of a **past** year; run generation with `target.day === 8`; assert a memory row + exists with `data.ruleId === 'video_moments'`, the expected `title`, and the expected asset set. +- **Negative (trigger day):** identical data, generation run for **day 7** ⇒ no `video_moments` + memory. Proves the day gate, not the band. +- **Negative (duration band):** ≥3 videos all with `duration: 1000` (below band) on day 8 ⇒ no + memory. Proves the band, not the day gate. + +### B2. `trip_anniversary` + +- **Positive:** seed a home baseline (≥90 days before the anniversary, one dominant country/city) + **plus** an away-from-home cluster of ≥7 assets across ≥2 distinct days whose first day is exactly + the anniversary date, **plus** ≥3 geotagged assets on the anniversary day itself in that city (so + the cheap probe passes). Assert a memory with `data.ruleId === 'trip_anniversary'`. +- **Negative:** same fixture but the away cluster spans a **single** day (`dayCount = 1`) ⇒ no + memory. + +> Getting this fixture right is the fiddly part: the probe reads +> `getMemoryAssetsForPeriod(months=[m], day=d)` while the confirm step reads +> `getMemoryLocationClusters` over two different windows. Seed real assets that satisfy all three. + +### B3. `themed` + +**No live ML service.** Use the Slice-7 seam: subclass `MemoryService` and override the `protected +createThemeSearchPort()` to return a stub. + +```ts +class StubbedMemoryService extends MemoryService { + constructor( + private stub: ThemeSearchPort, + ...args: ConstructorParameters + ) { + super(...args); + } + protected override createThemeSearchPort(): ThemeSearchPort { + return this.stub; + } +} +``` + +If constructing the subclass through the medium `setup()`/`ctx` harness is awkward, the simpler +equivalent is to build the real service and override the method on the instance +(`(sut as unknown as { createThemeSearchPort: () => ThemeSearchPort }).createThemeSearchPort = () => stub`) +**before** the first generation call — the port is memoized lazily, so an override applied before +first use takes effect. Either is acceptable; state which you used. + +- **Positive:** stub returns an embedding and ≥8 assets whose `localDateTime` is in the target year; + run generation on **day 22**; assert a `themed` memory with the expected `title`. +- **Negative:** stub's `resolveEmbedding` returns `null` (smart search disabled) ⇒ no `themed` + memory, and `searchByEmbedding` was never called. + +### B4. Slot budget (spec §6.10 row 17) + +Guards §3.6: with `RULE_DAILY_LIMIT = 2` already satisfied by two visible multi-day memories, +`createRuleMemories` returns early and inserts nothing. Seed two rule memories visible on the target +day, then run generation and assert no third rule memory is inserted for that day. + +## Part C — docs + +Slices 2/5/7 already updated `docs/docs/features/memories.md`, +`docs/docs/install/config-file.md`, and the roadmap rows per type. In this slice: + +1. Verify all three roadmap rows (#6, #7, #11) read **Shipped** with the right key. +2. Add `memories.themeMaxDistance` to `docs/docs/install/config-file.md` (Slice 6 deliberately + deferred it) — document the `0 < x < 2` active range, the `0.3` default, and that `0` disables + the quality gate entirely. +3. In `docs/docs/features/memories.md`, note that **Themes requires smart search to be enabled**. + +## Part D — calibration gate (NOT executable here) + +Spec §4.2 gates merge on empirically tuning `memories.themeMaxDistance` against a real library. That +requires deploying an RC to the personal instance, which is an explicit human decision (fork policy: +always confirm before triggering release/deploy workflows). **Do not run any deploy or release +workflow.** + +Instead, record the procedure as an explicit pre-merge checklist item in the PR description: + +1. Deploy an RC to the personal instance. +2. For each of the 6 themes, run the themed search at `0.22 / 0.26 / 0.30 / 0.34`. +3. Record per-theme result counts; eyeball precision of the top 16. +4. Choose the highest threshold at which **no theme shows obvious false positives** in its top 16. +5. If a theme cannot be made precise at any threshold, **drop it from the catalog** rather than + loosening the global default. + +Until that runs, `0.3` is an **unvalidated placeholder**, and this must be stated plainly in the PR. + +## Verification + +```bash +cd server && pnpm test:medium --run test/medium/specs/services/memory.service.spec.ts +cd server && pnpm test:medium --run test/medium/specs/repositories/asset.repository.spec.ts +cd server && pnpm test --run +cd server && pnpm run check +cd server && npx eslint src/ test/ --max-warnings 0 +npx prettier --check "docs/**/*.md" "i18n/en.json" +``` + +> `pnpm test:medium --run ` — NOT `pnpm test:medium -- --run ` (the literal `--` drops +> the path filter and runs the whole medium suite, surfacing unrelated pre-existing failures). + +Medium tests need Docker; it is running. + +## Commit + +`test(memories): end-to-end generation coverage for tier 3 memory types` diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-1.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-1.md new file mode 100644 index 0000000000000..2271dae6a3a02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-1.md @@ -0,0 +1,111 @@ +# Slice 1 — `chapter.util.ts` + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §3.4, §4.0, §4.1, §9 Slice 1. +No dependencies. Pure module, zero imports beyond types. + +## Goal + +A pure helper that finds the densest window of at most `maxSpanDays` consecutive calendar days in a +series of per-day counts. + +## Part A — RED + +Create `server/src/services/memory-rules/chapter.util.spec.ts`. + +Model the file style on `server/src/services/memory-rules/season.util.spec.ts` (plain `describe`/`it`, +no mocks, a small local date helper). Vitest globals are configured — do **not** import `describe`/ +`it`/`expect`. + +Local helper: + +```ts +const day = (iso: string, count: number): DayCount => ({ day: new Date(`${iso}T00:00:00.000Z`), count }); +``` + +All nine cases from spec §4.1, one `it()` each, phrased as behaviour: + +1. **empty input** → `densestChapter([], 14)` returns `null`. +2. **one day, 3 assets** → `from`/`to` both that day, `count === 3`. +3. **all days inside the span** → window covers the whole set; `count` is the total. +4. **two clusters, second denser** → returns the second cluster's bounds and count. +5. **two clusters equally dense** → returns the **more recent** one. Build two 3-day clusters of + identical total count, far apart, and assert `from` equals the later cluster's first day. +6. **days exactly `maxSpanDays - 1` apart** → both included. With `maxSpanDays = 14`, use + `2020-01-01` and `2020-01-14` (13 days apart) → one window, `count` is the sum. +7. **days exactly `maxSpanDays` apart** → split. `2020-01-01` and `2020-01-15` (14 days apart) → + the window contains only one of them. +8. **dense window at the very start** → a heavy cluster at index 0 followed by sparse days is still + found (guards an off-by-one at `left = 0`). +9. **input in descending order** → same result as the ascending equivalent (defensive sort). + +Cases 6 and 7 are the pair that pins the boundary: "at most `maxSpanDays` **calendar days**" means a +maximum day-index difference of `maxSpanDays - 1`. Write them first. + +Run: + +``` +cd server && pnpm test --run src/services/memory-rules/chapter.util.spec.ts +``` + +⚠️ `pnpm test --run `, NOT `pnpm test -- --run ` — this pnpm forwards the literal `--` +to vitest, which silently drops the path filter and runs the whole suite. + +**Expected red:** `Failed to resolve import "src/services/memory-rules/chapter.util"` — the module +does not exist. Capture the output. + +## Part B — GREEN + +Create `server/src/services/memory-rules/chapter.util.ts` exactly per spec §3.4: + +```ts +export const CHAPTER_MAX_SPAN_DAYS = 14; + +export interface DayCount { + day: Date; + count: number; +} + +export interface Chapter { + from: Date; + to: Date; + count: number; +} + +export const densestChapter = (days: DayCount[], maxSpanDays: number): Chapter | null => { ... }; +``` + +Implementation notes: + +- Sort a **copy** ascending by `day.getTime()` — defensive, per the spec docstring. Never mutate the + caller's array. +- Two-pointer sweep with a running sum. For each `right`, advance `left` while the day difference + exceeds `maxSpanDays - 1`, subtracting as it moves. +- Day difference in whole days: `(a.getTime() - b.getTime()) / 86_400_000`. Inputs are UTC + midnight (`date_trunc` output), so this is exact — no DST drift. +- Update the best window on `sum >= best` (**`>=`**, not `>`), so the last — most recent — maximal + window wins. This is the §4.1 case 5 tie rule; `>` silently fails it. +- Return `null` only for empty input. +- Add the file's doc comment from the spec, including why the sort is defensive. + +Follow repo style: 120-char lines, single quotes, arrow-function exports, `src/` import paths (no +relative imports). + +## Part C — VERIFY + +``` +cd server && pnpm test --run src/services/memory-rules/chapter.util.spec.ts # 9 passing +cd server && pnpm check # tsc --noEmit +cd server && npx eslint src/services/memory-rules/chapter.util.ts src/services/memory-rules/chapter.util.spec.ts --max-warnings 0 +cd server && npx prettier --check src/services/memory-rules/chapter.util.ts src/services/memory-rules/chapter.util.spec.ts +``` + +All four must pass. Report the red output and the green output. + +## Commit + +``` +feat(memories): add densestChapter window helper +``` + +Only the two new files. Do not touch anything else — registration, the rule, and the repository +queries belong to later slices. diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-2.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-2.md new file mode 100644 index 0000000000000..148e6b68edd70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-2.md @@ -0,0 +1,161 @@ +# Slice 2 — repository queries + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §3.3, §9 Slice 2. +Independent of Slice 1. No rule code in this slice. + +## Goal + +Three `@GenerateSql`-decorated queries that feed the rule, plus their regenerated `.sql` docs. + +## Part A — `getDormantPeople` + +In `server/src/repositories/person.repository.ts`, next to `getBirthdaysForDay` (~line 297). + +```ts +export interface DormantPerson { + id: string; + name: string; +} +``` + +`id` and `name` only — the rule consumes nothing else, and `lastSeenAt` is exactly the dormancy +figure the spec keeps out of stored data (§2 D1). + +```ts +@GenerateSql({ + params: [DummyValue.UUID, { lastSeenBefore: DummyValue.DATE, minAssets: 10, limit: 10 }], +}) +getDormantPeople( + ownerId: string, + { lastSeenBefore, minAssets, limit }: { lastSeenBefore: Date; minAssets: number; limit: number }, +): Promise +``` + +Query shape — `person` → `asset_face` → `asset`: + +| Side | Predicates | +| ------ | ----------------------------------------------------------------------------------------------- | +| person | `ownerId = :ownerId`, `type = 'person'`, `name != ''`, `isHidden = false` | +| face | `deletedAt is null`, `isVisible = true` | +| asset | `ownerId = :ownerId`, `visibility = Timeline`, `deletedAt is null`, Preview `asset_file` EXISTS | + +Then: + +``` +GROUP BY person.id +HAVING max(asset."localDateTime") < :lastSeenBefore + AND count(DISTINCT asset.id) >= :minAssets +ORDER BY count(DISTINCT asset.id) DESC, person.id ASC +LIMIT :limit +``` + +`ORDER BY` on an aggregate needs no matching `SELECT` — do not add one. + +**Copy the asset-side predicate block from `getMemoryFacesForPeriod`** (`asset.repository.ts`, +~line 1001) rather than retyping it. Predicate parity is the point: a missing `Preview EXISTS` makes +people look dormant who are not. That includes the `eb.exists(...)` subquery on `asset_file` with +`type = AssetFileType.Preview`. + +`HAVING` with Kysely: use `.having(...)` with `sql` fragments or `eb.fn.max`/`eb.fn.count` as the +surrounding code does. `person.type = 'person'` is a plain string literal — there is no enum +(`person.repository.ts` already filters `'pet'` this way elsewhere). + +## Part B — the two asset queries + +In `server/src/repositories/asset.repository.ts`, next to `getMemoryFacesForPeriod`. + +```ts +export interface MemoryPersonDayCount { + personId: string; + day: Date; + count: number; +} + +@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], { takenBefore: DummyValue.DATE }] }) +getMemoryPersonDailyCounts( + ownerId: string, + personIds: string[], + { takenBefore }: { takenBefore: Date }, +): Promise + +@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] }) +getMemoryAssetsForPersonWindow( + ownerId: string, + personId: string, + { from, to }: { from: Date; to: Date }, +): Promise +``` + +`getMemoryPersonDailyCounts`: + +- Same person/face/asset predicates as Part A, plus `asset_face.personId in personIds` and + `asset.localDateTime <= takenBefore`. +- Select `asset_face.personId`, the UTC-truncated day, and a distinct asset count: + `date_trunc('day', asset."localDateTime" at time zone 'UTC')` as `day`, + `count(DISTINCT asset.id)::int` as `count`. +- `GROUP BY asset_face."personId", day`, `ORDER BY personId, day ASC`. +- `count(DISTINCT asset.id)`, not `count(*)` — two faces of the same person on one asset must not + double-count. + +`getMemoryAssetsForPersonWindow`: + +- Returns the existing `MemoryAsset` type (`id`, `localDateTime`) — already exported. +- Same predicates, plus `asset_face.personId = personId` and + `asset.localDateTime >= from AND asset.localDateTime <= to` (inclusive both ends — `from`/`to` + are the chapter's first and last day at UTC midnight, so `to` must be taken as end-of-day: + filter `< to + 1 day` rather than `<= to`, otherwise assets later on the final day are dropped). +- `distinctOn(['asset.id'])` to collapse multiple faces, then `ORDER BY asset."localDateTime" ASC`. + Note Postgres requires `DISTINCT ON` expressions to lead `ORDER BY` — mirror how + `getMemoryAssetsForPerson` handles this, but **do not copy its `ORDER BY asset.id … LIMIT 60`**; + that is the arbitrary-UUID-sample bug the spec calls out (§7.3). No `LIMIT` here — the window is + already bounded. + +## Part C — regenerate the SQL docs + +**`make sql` no longer exists** (the Makefile target prints a removal notice). The command is +`mise sql`, which runs `server/dist/bin/sync-sql.js` — so it needs a **build** and a **live DB**. + +A Postgres matching CI is already running on `localhost:5432` +(`ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, user/pass `postgres`, db `immich`). + +From the repo root, in order: + +``` +mise //:plugins +mise //server:build +pnpm --filter immich migrations:run +DB_URL=postgres://postgres:postgres@localhost:5432/immich mise sql +``` + +Then confirm: + +``` +git diff --stat server/src/queries +``` + +Expect exactly two **modified** files: `person.repository.sql` (+1 query) and +`asset.repository.sql` (+2). New files, deletions, or a wholesale rewrite mean the generator ran +without a DB — `git checkout server/src/queries` and retry after fixing the connection. + +CI verifies this directory is in sync (`test.yml` `sql-schema-up-to-date`), so it is not optional. + +## Part D — VERIFY + +``` +cd server && pnpm check +cd server && npx eslint src/repositories/person.repository.ts src/repositories/asset.repository.ts --max-warnings 0 +cd server && npx prettier --check src/repositories/person.repository.ts src/repositories/asset.repository.ts +git diff --stat server/src/queries +``` + +There are no unit tests in this slice — these queries are exercised by the rule's unit tests +(Slice 3, via mocks) and their SQL behaviour by the medium tests (Slice 6). Do not add a +repository spec. + +## Commit + +``` +feat(memories): add dormant-person and chapter-window queries +``` + +Files: the two repository files plus the two regenerated `.sql` files. Nothing else. diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-3.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-3.md new file mode 100644 index 0000000000000..ab9521f2af08a --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-3.md @@ -0,0 +1,149 @@ +# Slice 3 — `person_throwback` rule + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §3.5, §4.0, §4.2, §9 Slice 3. +Depends on Slice 1 (`densestChapter`) and Slice 2 (repository method types) — both committed. + +## Goal + +The rule itself. Pure function of `(ownerId, target)` plus two injected repositories. + +## Part A — RED + +Create `server/src/services/memory-rules/person-throwback.rule.spec.ts`. + +Model on `people-together.rule.spec.ts`: a module-level `target`, small typed fixture builders, and a +`ruleWith(...)` helper returning both the rule and its mocks so call arguments can be asserted. +Vitest globals are configured — do **not** import `describe`/`it`/`expect`/`vi`. + +```ts +const target = DateTime.fromISO('2026-08-13', { zone: 'utc' }); + +const ruleWith = (people: DormantPerson[], counts: MemoryPersonDayCount[], assets: MemoryAsset[]) => { + const personRepository = { getDormantPeople: vi.fn().mockResolvedValue(people) }; + const assetRepository = { + getMemoryPersonDailyCounts: vi.fn().mockResolvedValue(counts), + getMemoryAssetsForPersonWindow: vi.fn().mockResolvedValue(assets), + }; + return { + rule: new PersonThrowbackMemoryRule(personRepository as never, assetRepository as never), + personRepository, + assetRepository, + }; +}; +``` + +All 20 cases from spec §4.2, one `it()` each, phrased as behaviour. **Write rows 2, 18, 19 and 3 +first** — they are the four the spec was revised to cover. + +| Row | Test | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `target.day !== 13` (use the 12th and the 14th) → `[]`, and **`getDormantPeople` not called** | +| 2 | empty pool → `[]`, and **`getMemoryPersonDailyCounts` NOT called**. Asserting only `[]` passes without the guard — assert the absent call | +| 3 | "Anna", 23-asset chapter across Aug 2019, target 2026-08-13 → one candidate; pin `title` `'Times with Anna'`, `subtitle` `'23 photos · August 2019'`, `dedupeKey` `'person_throwback:p1'`, `ruleId`, and **`score === 186`** (`110 + min(23,30)*3 + max(0,10-7)` = `110+69+7`) | +| 4 | `lastSeenAt` exactly at the cutoff → excluded. Assert via the **query argument**: `lastSeenBefore` equals `target.startOf('day').minus({months:12})` | +| 5 | last seen one day before the cutoff → included | +| 6 | densest chapter has 5 assets → excluded (`MIN_CHAPTER_ASSETS`) | +| 7 | any run reaching step 3 → `getDormantPeople` called with `minAssets: 10`, `limit: 10`, and the exact `lastSeenBefore` | +| 8 | 7 qualifying people → exactly 5 candidates, score descending | +| 9 | two identical scores → ordered by `personId` ascending | +| 10 | chapter spanning a month boundary (e.g. 2019-07-29 → 2019-08-04, heavier in August) → subtitle month/year come from `medianTime`, not from `chapter.from` | +| 11 | single-day chapter of 8 assets → included (no distinct-day minimum) | +| 12 | chapter dated 4 years back → `recencyBonus` contributes 6 | +| 13 | equal chapters, one 2 years back and one 8 years back → the 2-years-back one scores higher | +| 14 | window returns 20 assets → `assetIds.length === 8`, evenly spaced by time, chronological | +| 15 | every candidate → `visibleForDays === 7`, and `dedupeKey` contains **no** year | +| 16 | every pooled candidate fails the chapter bar → `[]`, and `getMemoryAssetsForPersonWindow` never called | +| 17 | window returns 20 assets but `chapter.count` is 23 → candidate kept; subtitle still says `23 photos` | +| 18 | window returns **4** assets (< `MIN_CHAPTER_ASSETS`) → candidate **dropped** | +| 19 | window returns **zero** assets → candidate dropped, no zero-asset memory | +| 20 | one candidate's window query rejects → that candidate dropped, the others still returned | + +For rows 17–20 the window mock must vary per candidate — use +`vi.fn().mockResolvedValueOnce(...).mockResolvedValueOnce(...)` or a `mockImplementation` keyed on +`personId`. + +Run: + +``` +cd server && pnpm test --run src/services/memory-rules/person-throwback.rule.spec.ts +``` + +⚠️ `pnpm test --run `, NOT `pnpm test -- --run `. + +**Expected red:** module not found. Capture the output. + +## Part B — GREEN + +Create `server/src/services/memory-rules/person-throwback.rule.ts` per spec §3.5. + +Exported constants (module-level `export const`, not private statics — spec D11): + +```ts +export const TRIGGER_DAY = 13; +export const DORMANCY_MONTHS = 12; +export const MIN_TOTAL_ASSETS = 10; +export const MIN_CHAPTER_ASSETS = 6; +export const CANDIDATE_POOL = 10; +export const MAX_CANDIDATES = 5; +export const ASSET_CAP = 8; +export const VISIBLE_FOR_DAYS = 7; +export const SCORE_BASE = 110; +export const MAX_COUNT_BONUS = 30; +``` + +```ts +export class PersonThrowbackMemoryRule implements MemoryRule { + readonly id = 'person_throwback'; + + constructor( + private personRepository: Pick, + private assetRepository: Pick< + AssetRepository, + 'getMemoryPersonDailyCounts' | 'getMemoryAssetsForPersonWindow' + >, + ) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { ... } +} +``` + +Follow the nine numbered flow steps in spec §3.5 exactly. The five that are easy to get wrong: + +- **Step 4** — return `[]` when the pool is empty, **before** calling + `getMemoryPersonDailyCounts`. An empty `personIds` array would emit `IN ()`. Load-bearing. +- **Step 8** — after `getMemoryAssetsForPersonWindow`, re-check + `assets.length >= MIN_CHAPTER_ASSETS` and drop the candidate if it fails. `chapter.count` came + from a different, earlier query. +- **Step 9** — `memoryAt` must be + `DateTime.fromJSDate(medianTime(assets), { zone: 'utc' })`. `medianTime` returns a `Date`; + `MemoryRuleCandidate.memoryAt` is a Luxon `DateTime`. `sampleAssetsByTime` and `medianTime` both + run over the **full** window set, not the sampled 8. +- **Subtitle/score** use `chapter.count` (the full chapter total), never `assetIds.length`. +- **Row 20** — a rejecting window query must not sink the whole rule. Wrap the per-candidate fetch + so one failure drops only that candidate. + +Reuse from `curation.util.ts`: `sampleAssetsByTime`, `medianTime`, `recencyBonus`, `monthName`. +Reuse `densestChapter` and `CHAPTER_MAX_SPAN_DAYS` from `chapter.util.ts` (Slice 1). + +Candidate shape is pinned in spec §3.5 — copy it exactly, including +`context: { personId, chapterFrom, chapterTo, count: chapter.count }`. + +Do **not** register the rule anywhere. No metadata entry, no registry entry, no i18n. Slice 4. + +## Part C — VERIFY + +``` +cd server && pnpm test --run src/services/memory-rules/person-throwback.rule.spec.ts # 20 passing +cd server && pnpm test --run src/services/memory-rules # whole dir still green +cd server && pnpm check +cd server && npx eslint src/services/memory-rules/person-throwback.rule.ts src/services/memory-rules/person-throwback.rule.spec.ts --max-warnings 0 +cd server && npx prettier --check src/services/memory-rules/person-throwback.rule.ts src/services/memory-rules/person-throwback.rule.spec.ts +``` + +## Commit + +``` +feat(memories): add person_throwback memory rule +``` + +Two new files only. diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-4.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-4.md new file mode 100644 index 0000000000000..88d4cd54f4acd --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-4.md @@ -0,0 +1,93 @@ +# Slice 4 — server registration + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §3.1 rows 1–4, 7, 8, 10; §9 Slice 4. +Depends on Slice 3 (the rule class). + +## Goal + +Register `person_throwback` so the engine, the config, and the API all know about it. This slice is +mechanical but touches **shared lists** — the risk is missing one, not getting one wrong. + +## Part A — registration (2 files) + +**`server/src/services/memory-rules/memory-type.metadata.ts`** — append **last** in +`MEMORY_TYPE_METADATA` (registry order is the `availableMemoryTypes` order): + +```ts +{ key: 'person_throwback', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, +``` + +**`server/src/services/memory-rules/memory-type.registry.ts`** — import the rule and add: + +```ts +person_throwback: (deps) => new PersonThrowbackMemoryRule(deps.personRepository, deps.assetRepository), +``` + +`MemoryRuleDeps` needs **no** change — `personRepository` and `assetRepository` are already there. +Keep imports alphabetically ordered (prettier-plugin-organize-imports enforces this). + +## Part B — the shared lists (4 spec files, 7 sites) + +These fail as soon as Part A lands. Fix each; do not skip one because another already passed. + +**`memory-type.metadata.spec.ts`** — four separate lists, all verified by grep before editing: + +- ~line 31 — the full `MEMORY_TYPE_METADATA` literal: add the new entry last +- ~line 54 — key list +- ~line 72 — the `types` object literal: `person_throwback: true` +- ~lines 121, 167 — two more key lists + +Run `grep -n "themed" server/src/services/memory-rules/memory-type.metadata.spec.ts` first and add +`person_throwback` after **every** `themed` occurrence. There are five. + +**`memory-type.registry.spec.ts`** + +- Add an `it('instantiates person_throwback by key')` mirroring the `themed` test at ~line 33. +- The completeness guard at ~line 57 is `expect(rules).toHaveLength(ruleKeys.length)` — it derives + its own count, so it needs no edit, but it **will** fail if the factory is missing. That is the + point of it. + +**`server/src/utils/preferences.spec.ts`** — two sites (~lines 29 and 187): add +`person_throwback: true` to the default memory-types map. + +**`server/src/services/server.service.spec.ts`** — **two** `availableMemoryTypes` assertions +(~lines 196 and 223). Both become 12 entries, `person_throwback` last. + +**`e2e/src/specs/server/api/server.e2e-spec.ts`** — the same fixture at ~line 148. **The server unit +suite does not cover this file.** It is the single most-missed site when adding a memory type; if +you skip it, CI fails and the server suite stays green, which is confusing. Do it. + +Expected `availableMemoryTypes`, in order (12): + +``` +on_this_day, birthday, recent_trip, month_recap, favorites_throwback, on_this_day_place, +season_recap, people_together, video_moments, trip_anniversary, themed, person_throwback +``` + +## Part C — VERIFY + +``` +cd server && pnpm test --run src/services/memory-rules src/utils/preferences.spec.ts src/services/server.service.spec.ts +cd server && pnpm test # FULL suite — shared lists mean a miss can surface anywhere +cd server && pnpm check +cd server && pnpm lint +cd server && pnpm format +``` + +The full server suite is not optional here. The whole risk of this slice is a shared list you did +not know about; only the full run proves you found them all. + +E2E is not runnable locally in this slice (it needs the docker stack) — the fixture edit is verified +by inspection against the 12-entry list above, and by CI. + +## Commit + +``` +feat(memories): register person_throwback memory type +``` + +## Do not + +- Touch `MemoriesSettings.svelte` or `i18n/en.json` — Slice 5. +- Touch the medium tests or docs — Slice 6. +- Change `RULE_DAILY_LIMIT`, the multi-day slot cap, or anything else in `memory.service.ts`. diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-5.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-5.md new file mode 100644 index 0000000000000..021e1255d11eb --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-5.md @@ -0,0 +1,85 @@ +# Slice 5 — web admin settings + i18n + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §3.1 rows 11–13, §3.6, §9 Slice 5. +Depends on Slice 4 (server registration). + +## Goal + +Make the new type visible and toggleable in admin system settings, with its four English strings. + +## Part A — i18n (`i18n/en.json`) + +**The key shapes differ between admin and user, and are not what you might guess.** Verified +against the file: + +- **Admin** pair lives nested inside the `admin` object (~line 330), named + `memory_type__setting` and `memory_type__setting_description`. It is **not** + `admin.memory_type_`. +- **User** pair lives at top level (~line 2013), named `memory_type_` and + `memory_type__description`. + +Add, placed alphabetically among their neighbours (the file is sorted; +`prettier-plugin-sort-json` enforces it): + +| Key | Value | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `admin` › `memory_type_person_throwback_setting` | `Person throwback` | +| `admin` › `memory_type_person_throwback_setting_description` | `Resurface a warm chapter with someone who has not appeared in photos for a year or more.` | +| `memory_type_person_throwback` | `Times with someone` | +| `memory_type_person_throwback_description` | `Occasionally resurface photos of a person you have not photographed in a long while.` | + +Only `en.json`. Do not touch other locales — they are translated downstream. Note `i18n/` is shared +by web **and** mobile; adding keys is safe, renaming is not. + +## Part B — the settings component + +**`web/src/routes/admin/system-settings/MemoriesSettings.svelte`** — append `'person_throwback'` +last in the `memoryTypeKeys` array (~line 12, currently ending with `'themed'`). Order here drives +the render order; keep it matching the server's registry order. + +Nothing else in this component needs changing — the switch, label, and description are all derived +from the key. + +## Part C — the component spec + +**`web/src/routes/admin/system-settings/MemoriesSettings.spec.ts`** — **one** site: the `types` +object literal in the save-payload assertion (~lines 97–109, currently ending with `themed: true`). +Add `person_throwback: true` last. + +There is **no** switch-count assertion in this file — it asserts specific switches by name +(`admin.memory_type_on_this_day_setting` etc.), so no count needs bumping. Do not invent one. + +## Part D — VERIFY + +``` +cd web && pnpm test --run src/routes/admin/system-settings/MemoriesSettings.spec.ts +cd web && pnpm test # full web suite +cd web && pnpm check:typescript +cd web && pnpm check:svelte +cd web && pnpm lint +``` + +`pnpm lint` in `web/` has been seen to abort locally on a tscompat crash unrelated to any change. +If it crashes rather than reporting lint errors, note that in your report and continue — CI is the +authoritative lint gate. If it reports actual errors in the files you touched, fix them. + +Also confirm the JSON stayed sorted and valid: + +``` +npx prettier --check i18n/en.json +node -e "JSON.parse(require('fs').readFileSync('i18n/en.json','utf8')); console.log('en.json parses')" +``` + +## Commit + +``` +feat(web): expose person_throwback in memory settings +``` + +Three files: `i18n/en.json`, the Svelte component, its spec. + +## Do not + +- Touch any locale other than `en.json`. +- Touch server files — Slice 4 owns those and is already committed. +- Touch docs — Slice 6. diff --git a/docs/superpowers/plans/2026-07-22-person-throwback-slice-6.md b/docs/superpowers/plans/2026-07-22-person-throwback-slice-6.md new file mode 100644 index 0000000000000..98aa7ccf6e53d --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-person-throwback-slice-6.md @@ -0,0 +1,115 @@ +# Slice 6 — medium tests + docs + +Spec: `docs/plans/2026-07-22-memory-person-throwback-spec.md` §4.4, §3.1 rows 9, 14–16, §9 Slice 6. +Depends on Slice 4 (registration). Independent of Slice 5. + +## Goal + +Prove the SQL-side behaviour against a real database, and document the type. + +## Part A — medium tests (RED first) + +**`server/test/medium/specs/services/memory.service.spec.ts`** + +Model on the existing `themed` block (~line 1037) and `video_moments` block (~line 855) — same +factory helpers, same assertion style (`memories.some((m) => (m.data as { ruleId?: string }).ruleId === ...)`). + +The rule fires on **day 13**, so build targets as day-13 dates. Dormancy cutoff is 12 months before +the target, so a "dormant" person's assets must be older than that. + +All of spec §4.4, one `it()` each: + +| # | Scenario | Expect | +| --- | ------------------------------------------------------ | ---------------------------------------------------------------- | +| 1 | dormant named person, dense chapter, type enabled | memory created with `ruleId: 'person_throwback'`, correct assets | +| 2 | same but `person.type = 'pet'` | **no** memory | +| 3 | same but `isHidden = true` | no memory | +| 4 | same but `name = ''` | no memory | +| 5 | recent photos exist but are `Archived` | **still** dormant → memory created | +| 5b | chapter assets have no `Preview` asset_file | excluded from dormancy count and chapter | +| 5c | a face on a chapter asset is soft-deleted or invisible | that asset does not count toward the chapter | +| 6 | user has the type toggled off | no memory | +| 7 | rule already fired for that person | **no second memory; a different dormant person is used instead** | + +**Row 7 is the most important test in this slice.** It is the only coverage of D8 (the rule returns +up to 5 candidates so the engine can skip an already-fired `dedupeKey` and reach a fresh person). +Build it as: two qualifying dormant people, pre-insert a `person_throwback` memory whose +`dedupeKey` is `person_throwback:`, run generation, assert a memory exists for **person +B** and that person A did not gain a second one. If this test is hard to build, that is a reason to +spend more time on it, not to drop it. + +Rows 2–5c exist here precisely because they are SQL-side filters that a mocked repository cannot +test (spec §4.0). + +Run: + +``` +cd server && pnpm test:medium --run test/medium/specs/services/memory.service.spec.ts +``` + +⚠️ `pnpm test:medium --run `, NOT `pnpm test:medium -- --run ` — the `--` form silently +drops the path filter and runs every medium test. + +Medium tests need Docker; a Postgres matching CI is already running on `localhost:5432` (container +`pt-pg`). The medium harness normally manages its own testcontainer — if it does, let it; do not +repoint it at `pt-pg`. + +**Expected red:** the new cases fail (no memory created / wrong assertions) because... actually +Slice 4 already registered the rule, so rows 1 and 5 may pass immediately. That is expected and +fine — they are integration confirmations, not new behaviour. The rows that must genuinely start +red are any that expose a filter bug. If **every** new row passes on the first run, re-read each +one and confirm it is actually exercising the filter it claims (e.g. row 2 must create a real +`type='pet'` person with a qualifying chapter, not a person that fails for some other reason). + +## Part B — docs + +**`docs/docs/features/memories.md`** (~line 88) — add a table row after `themed`: + +``` +| `person_throwback` | Times with someone | A warm chapter with someone who has not appeared in your photos for a year or more | +``` + +Do **not** describe it as "people you haven't seen" or mention the gap in user-facing copy — spec +D1. The gap is a selection heuristic, not a claim shown to users. + +**`docs/docs/install/config-file.md`** (~line 331) — add to the `memories.types` key list: + +``` +- `person_throwback` — a warm chapter with someone who has not appeared in your photos for a year or more +``` + +**`docs/plans/2026-07-15-memory-types-roadmap.md`** — flip #9's row to shipped: + +``` +| 9 | Someone you haven't seen | ... | 🟠 | High but risky | **Shipped** — `person_throwback` (reframed: gap is a silent selector, never shown) | +``` + +While there, correct #12's status per spec §8: `themed` (PR #812) already rides smart-search CLIP +embeddings, so "Semantic themes (CLIP)" is no longer an unshipped north star — the remaining work +is vocabulary breadth and `themeMaxDistance` calibration. Add that as a Notes-column clarification; +do not mark #12 shipped outright. + +Then: + +``` +npx prettier --write docs/docs/features/memories.md docs/docs/install/config-file.md docs/plans/2026-07-15-memory-types-roadmap.md +``` + +CI Docs Build is strict about markdown formatting. + +## Part C — VERIFY + +``` +cd server && pnpm test:medium --run test/medium/specs/services/memory.service.spec.ts +cd server && pnpm test # full unit suite still green +cd server && pnpm check +cd server && pnpm lint +cd server && pnpm format +npx prettier --check docs/docs/features/memories.md docs/docs/install/config-file.md docs/plans/2026-07-15-memory-types-roadmap.md +``` + +## Commit + +``` +test(memories): end-to-end coverage for person_throwback + docs +``` diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 169dac6041e4f..b21263c363294 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -154,6 +154,10 @@ describe('/server', () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ], }); }); diff --git a/i18n/en.json b/i18n/en.json index ba2ef9f371560..da618c54da373 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -309,8 +309,12 @@ "memories_settings_description": "Manage generated memory history", "memory_cleanup_job": "Memory cleanup", "memory_generate_job": "Memory generation", + "memory_person_throwback_dormancy_setting": "Person throwback dormancy", + "memory_person_throwback_dormancy_setting_description": "How many months someone must be absent from your photos before the Person throwback memory can resurface them. Lower values surface more people, including some you still see regularly.", "memory_retention_setting": "Memory retention", "memory_retention_setting_description": "Number of days to keep generated memories. Set to 0 to keep memories forever.", + "memory_theme_max_distance_setting": "Theme match threshold", + "memory_theme_max_distance_setting_description": "Maximum CLIP cosine distance for the Themes memory type. Text-to-image distances sit much higher than face or duplicate thresholds — 0.75 is a reasonable starting point. Lower values are stricter and can produce no themed memories at all. Set to 0 to accept every smart-search result.", "memory_type_birthday_setting": "Birthday memories", "memory_type_birthday_setting_description": "Generate memories on the birthdays of named people.", "memory_type_favorites_throwback_setting": "Favorite moments memories", @@ -323,10 +327,18 @@ "memory_type_on_this_day_setting_description": "Generate memories from photos taken on this date in previous years.", "memory_type_people_together_setting": "People together memories", "memory_type_people_together_setting_description": "Generate memories of two people or pets often photographed together in a past year.", + "memory_type_person_throwback_setting": "Person throwback", + "memory_type_person_throwback_setting_description": "Resurface a warm chapter with someone who has not appeared in photos for a while.", "memory_type_recent_trip_setting": "Recent trip memories", "memory_type_recent_trip_setting_description": "Generate memories from places recently visited away from home.", "memory_type_season_recap_setting": "Season recap memories", "memory_type_season_recap_setting_description": "Generate memories recapping a past meteorological season when the new season begins.", + "memory_type_themed_setting": "Themes", + "memory_type_themed_setting_description": "Group photos by visual theme using smart search. Requires smart search to be enabled.", + "memory_type_trip_anniversary_setting": "Trip anniversaries", + "memory_type_trip_anniversary_setting_description": "Resurface a past trip on the anniversary of its first day.", + "memory_type_video_moments_setting": "Video moments", + "memory_type_video_moments_setting_description": "Surface videos from this month in a past year.", "metadata_extraction_job": "Extract metadata", "metadata_extraction_job_description": "Extract metadata information from each asset, such as GPS, faces and resolution", "metadata_faces_import_setting": "Enable face import", @@ -2000,10 +2012,18 @@ "memory_type_on_this_day_place_description": "A past year's photos from this date, when they cluster in one place.", "memory_type_people_together": "People together", "memory_type_people_together_description": "Two people or pets often photographed together in a past year.", + "memory_type_person_throwback": "Times with someone", + "memory_type_person_throwback_description": "Occasionally resurface photos of a person you have not photographed in a long while.", "memory_type_recent_trip": "Recent trips", "memory_type_recent_trip_description": "Memories from places you recently visited away from home.", "memory_type_season_recap": "Season recap", "memory_type_season_recap_description": "A look back at a past season when the new one begins.", + "memory_type_themed": "Themes", + "memory_type_themed_description": "Photo themes like sunsets, food, and beach days, found automatically.", + "memory_type_trip_anniversary": "Trip anniversaries", + "memory_type_trip_anniversary_description": "Past trips resurfaced on the anniversary of the day they began.", + "memory_type_video_moments": "Video moments", + "memory_type_video_moments_description": "Videos you filmed in this month of a past year.", "menu": "Menu", "merge": "Merge", "merge_error_conflict": "This merge ran into a concurrent change to the same people. Please try again.", diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 87f97d5754ecc..1d155be897a0b 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -25,6 +25,12 @@ class NativeVideoViewer extends ConsumerStatefulWidget { final bool showControls; final Widget image; + /// Play regardless of the user's global `viewer.autoPlayVideo` setting. + /// + /// The memory viewer builds this widget with [showControls] `false`, so without an override a + /// user who has autoplay disabled gets a frozen first frame and no way to start playback. + final bool forceAutoPlay; + const NativeVideoViewer({ super.key, required this.asset, @@ -32,6 +38,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget { required this.image, this.isCurrent = false, this.showControls = true, + this.forceAutoPlay = false, }); @override @@ -219,7 +226,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg } final autoPlayVideo = ref.read(appConfigProvider).viewer.autoPlayVideo; - if (autoPlayVideo || widget.asset.isMotionPhoto) { + if (widget.forceAutoPlay || autoPlayVideo || widget.asset.isMotionPhoto) { await _notifier.play(); } } diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index 2f7a616632ec7..74d1a098bf0cf 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -66,6 +66,7 @@ class DriftMemoryCard extends StatelessWidget { asset: asset, isCurrent: isCurrent, showControls: false, + forceAutoPlay: true, image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain), ), ), diff --git a/mobile/openapi/lib/model/system_config_memories_dto.dart b/mobile/openapi/lib/model/system_config_memories_dto.dart index c96a452cb7901..ae87a0deac4ee 100644 --- a/mobile/openapi/lib/model/system_config_memories_dto.dart +++ b/mobile/openapi/lib/model/system_config_memories_dto.dart @@ -14,14 +14,22 @@ class SystemConfigMemoriesDto { /// Returns a new [SystemConfigMemoriesDto] instance. SystemConfigMemoriesDto({ required this.birthday, + this.personThrowbackDormancyMonths = const Optional.present(6), required this.recentTrips, required this.retentionDays, + this.themeMaxDistance = const Optional.present(0.75), this.types = const Optional.present(const {}), }); /// Birthday memories bool birthday; + /// Months a person must be absent from photos before person_throwback resurfaces them + /// + /// Minimum value: 1 + /// Maximum value: 120 + Optional personThrowbackDormancyMonths; + /// Recent trip memories bool recentTrips; @@ -31,32 +39,50 @@ class SystemConfigMemoriesDto { /// Maximum value: 9007199254740991 int retentionDays; + /// Max CLIP cosine distance for themed memories + /// + /// Minimum value: 0 + /// Maximum value: 2 + Optional themeMaxDistance; + /// Per-type memory availability overrides Optional?> types; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigMemoriesDto && other.birthday == birthday && + other.personThrowbackDormancyMonths == personThrowbackDormancyMonths && other.recentTrips == recentTrips && other.retentionDays == retentionDays && + other.themeMaxDistance == themeMaxDistance && _deepEquality.equals(other.types, types); @override int get hashCode => // ignore: unnecessary_parenthesis (birthday.hashCode) + + (personThrowbackDormancyMonths.hashCode) + (recentTrips.hashCode) + (retentionDays.hashCode) + + (themeMaxDistance.hashCode) + (types.hashCode); @override - String toString() => 'SystemConfigMemoriesDto[birthday=$birthday, recentTrips=$recentTrips, retentionDays=$retentionDays, types=$types]'; + String toString() => 'SystemConfigMemoriesDto[birthday=$birthday, personThrowbackDormancyMonths=$personThrowbackDormancyMonths, recentTrips=$recentTrips, retentionDays=$retentionDays, themeMaxDistance=$themeMaxDistance, types=$types]'; Map toJson() { final json = {}; json[r'birthday'] = this.birthday; + if (this.personThrowbackDormancyMonths.isPresent) { + final value = this.personThrowbackDormancyMonths.value; + json[r'personThrowbackDormancyMonths'] = value; + } json[r'recentTrips'] = this.recentTrips; json[r'retentionDays'] = this.retentionDays; + if (this.themeMaxDistance.isPresent) { + final value = this.themeMaxDistance.value; + json[r'themeMaxDistance'] = value; + } if (this.types.isPresent) { final value = this.types.value; json[r'types'] = value; @@ -74,8 +100,10 @@ class SystemConfigMemoriesDto { return SystemConfigMemoriesDto( birthday: mapValueOfType(json, r'birthday')!, + personThrowbackDormancyMonths: json.containsKey(r'personThrowbackDormancyMonths') ? Optional.present(json[r'personThrowbackDormancyMonths'] == null ? null : int.parse('${json[r'personThrowbackDormancyMonths']}')) : const Optional.absent(), recentTrips: mapValueOfType(json, r'recentTrips')!, retentionDays: mapValueOfType(json, r'retentionDays')!, + themeMaxDistance: json.containsKey(r'themeMaxDistance') ? Optional.present(json[r'themeMaxDistance'] == null ? null : num.parse('${json[r'themeMaxDistance']}')) : const Optional.absent(), types: json.containsKey(r'types') ? Optional.present(mapCastOfType(json, r'types')) : const Optional.absent(), ); } diff --git a/mobile/test/widgets/memory/memory_card_widget_test.dart b/mobile/test/widgets/memory/memory_card_widget_test.dart new file mode 100644 index 0000000000000..eab2c6167ebfa --- /dev/null +++ b/mobile/test/widgets/memory/memory_card_widget_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; + +import '../../utils.dart'; + +RemoteAsset _videoAsset() { + final id = TestUtils.uuid(null); + + return RemoteAsset( + id: id, + name: 'remote_$id.mp4', + ownerId: TestUtils.uuid(null), + checksum: 'checksum-$id', + type: .video, + createdAt: TestUtils.yesterday(), + updatedAt: TestUtils.now(), + isEdited: false, + width: 1920, + height: 1080, + durationMs: 12_000, + ); +} + +// These are construction-only assertions rather than `pumpWidget` tests. +// +// Mounting `DriftMemoryCard` under `flutter test` throws two unavoidable exceptions: its remote +// image provider cannot resolve (it does not route through `dart:io`, so `MockHttpOverrides` does +// not intercept it), and `NativeVideoViewer` opens a platform video controller in `initState`. +// Both are environment limitations unrelated to this wiring, and suppressing them would make the +// test assert almost nothing while still being flaky. +// +// What actually carries risk here is the parameter contract: that `forceAutoPlay` exists and +// defaults to `false`, so the ~40 other `NativeVideoViewer` call sites keep honouring the user's +// global autoplay preference. That is what these cover. The single `forceAutoPlay: true` literal in +// `memory_card.widget.dart` is a compile-checked named argument (`dart analyze --fatal-infos`) and +// was verified by hand in the memory viewer. +void main() { + group('NativeVideoViewer.forceAutoPlay', () { + test('defaults to false so existing call sites keep the global autoplay behaviour', () { + final viewer = NativeVideoViewer(asset: _videoAsset(), image: const SizedBox.shrink()); + + expect(viewer.forceAutoPlay, isFalse); + }); + + test('can be opted into explicitly, as the memory card does', () { + final viewer = NativeVideoViewer(asset: _videoAsset(), image: const SizedBox.shrink(), forceAutoPlay: true); + + expect(viewer.forceAutoPlay, isTrue); + }); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e0f472b3e74aa..498d491f90a67 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -34658,6 +34658,13 @@ "description": "Birthday memories", "type": "boolean" }, + "personThrowbackDormancyMonths": { + "default": 6, + "description": "Months a person must be absent from photos before person_throwback resurfaces them", + "maximum": 120, + "minimum": 1, + "type": "integer" + }, "recentTrips": { "description": "Recent trip memories", "type": "boolean" @@ -34668,6 +34675,13 @@ "minimum": 0, "type": "integer" }, + "themeMaxDistance": { + "default": 0.75, + "description": "Max CLIP cosine distance for themed memories", + "maximum": 2, + "minimum": 0, + "type": "number" + }, "types": { "additionalProperties": { "type": "boolean" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 956a69dcfebb4..50bece90eea50 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -3226,10 +3226,14 @@ export type SystemConfigMapDto = { export type SystemConfigMemoriesDto = { /** Birthday memories */ birthday: boolean; + /** Months a person must be absent from photos before person_throwback resurfaces them */ + personThrowbackDormancyMonths?: number; /** Recent trip memories */ recentTrips: boolean; /** Retention days */ retentionDays: number; + /** Max CLIP cosine distance for themed memories */ + themeMaxDistance?: number; /** Per-type memory availability overrides */ types?: { [key: string]: boolean; diff --git a/server/src/config.ts b/server/src/config.ts index 15478132f2f27..63d668e00dd02 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -184,6 +184,8 @@ export type SystemConfig = { recentTrips: boolean; /** sparse admin availability overrides, memory-type key -> enabled */ types: Record; + themeMaxDistance: number; + personThrowbackDormancyMonths: number; }; trash: { enabled: boolean; @@ -445,6 +447,12 @@ export const defaults = Object.freeze({ birthday: true, recentTrips: true, types: {}, + // CLIP text->image distances sit far higher than the image->image thresholds used elsewhere + // (duplicateDetection 0.01, facialRecognition 0.5) because of the modality gap: even a perfect + // textual match rarely drops below ~0.6. Matches the 0.75 the admin UI recommends for + // `machineLearning.clip.maxDistance`, the same metric over the same embeddings. + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, }, trash: { enabled: true, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index f4f5d352c6c7f..b795d33447344 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -270,6 +270,19 @@ const SystemConfigMemoriesSchema = z birthday: configBool.describe('Birthday memories'), recentTrips: configBool.describe('Recent trip memories'), types: z.record(z.string(), z.boolean()).default({}).describe('Per-type memory availability overrides'), + themeMaxDistance: z.coerce + .number() + .min(0) + .max(2) + .default(0.75) + .describe('Max CLIP cosine distance for themed memories'), + personThrowbackDormancyMonths: z.coerce + .number() + .int() + .min(1) + .max(120) + .default(6) + .describe('Months a person must be absent from photos before person_throwback resurfaces them'), }) .meta({ id: 'SystemConfigMemoriesDto' }); diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index ba36d8412ceaa..c96877c6f38fd 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -263,6 +263,8 @@ select "asset"."id", "asset"."localDateTime", "asset"."isFavorite", + "asset"."type", + "asset"."duration", "asset_exif"."country" as "country", "asset_exif"."city" as "city", extract( @@ -337,6 +339,67 @@ where order by "asset"."localDateTime" asc +-- AssetRepository.getMemoryPersonDailyCounts +select + "asset_face"."personId", + date_trunc('day', asset."localDateTime" at time zone 'UTC') as "day", + count(distinct ("asset"."id")) as "count" +from + "asset" + inner join "asset_face" on "asset_face"."assetId" = "asset"."id" +where + "asset"."ownerId" = $1 + and "asset"."visibility" = $2 + and "asset"."deletedAt" is null + and "asset"."localDateTime" <= $3 + and "asset_face"."personId" in ($4) + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $5 + and exists ( + select + "asset_file"."assetId" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $6 + ) +group by + "asset_face"."personId", + "day" +order by + "asset_face"."personId", + "day" asc + +-- AssetRepository.getMemoryAssetsForPersonWindow +select distinct + on ("asset"."id") "asset"."id", + "asset"."localDateTime" +from + "asset" + inner join "asset_face" on "asset_face"."assetId" = "asset"."id" +where + "asset"."ownerId" = $1 + and "asset_face"."personId" = $2 + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $3 + and "asset"."visibility" = $4 + and "asset"."deletedAt" is null + and "asset"."localDateTime" >= $5 + and "asset"."localDateTime" < $6 + and exists ( + select + "asset_file"."assetId" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $7 + ) +order by + "asset"."id", + "asset"."localDateTime" asc + -- AssetRepository.getOwnedManifestAssets select "asset"."id", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 6eba93f38f674..dcf2851639c79 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -36,6 +36,44 @@ where "birthDate" ) = $6 +-- PersonRepository.getDormantPeople +select + "person"."id", + "person"."name" +from + "person" + inner join "asset_face" on "asset_face"."personId" = "person"."id" + inner join "asset" on "asset"."id" = "asset_face"."assetId" +where + "person"."ownerId" = $1 + and "person"."type" = $2 + and "person"."name" != $3 + and "person"."isHidden" = $4 + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $5 + and "asset"."ownerId" = $6 + and "asset"."visibility" = $7 + and "asset"."deletedAt" is null + and exists ( + select + "asset_file"."assetId" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $8 + ) +group by + "person"."id" +having + max("asset"."localDateTime") < $9 + and count(distinct ("asset"."id")) >= $10 +order by + count(distinct ("asset"."id")) desc, + "person"."id" asc +limit + $11 + -- PersonRepository.getFileSamples select "id", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index abb06c1808c3c..ee5dfcb5775be 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -161,6 +161,12 @@ export interface MemoryAsset { localDateTime: Date; } +export interface MemoryPersonDayCount { + personId: string; + day: Date; + count: number; +} + export interface MemoryLocationCluster { country: string | null; city: string | null; @@ -177,6 +183,8 @@ export interface MemoryPeriodAsset { country: string | null; city: string | null; isFavorite: boolean; + type: AssetType; + duration: number | null; } export interface MemoryPeriodFace { @@ -194,6 +202,8 @@ export interface MemoryPeriodOptions { day?: number; /** when true, only favorited assets are returned */ favoritesOnly?: boolean; + /** when set, only assets of this type are returned */ + type?: AssetType; /** exclude assets taken after this instant (defensive guard against future-dated assets) */ takenBefore: Date; } @@ -955,7 +965,7 @@ export class AssetRepository { @GenerateSql({ params: [DummyValue.UUID, { months: [7], takenBefore: DummyValue.DATE }] }) getMemoryAssetsForPeriod( ownerId: string, - { months, day, favoritesOnly, takenBefore }: MemoryPeriodOptions, + { months, day, favoritesOnly, type, takenBefore }: MemoryPeriodOptions, ): Promise { return this.db .selectFrom('asset') @@ -964,6 +974,8 @@ export class AssetRepository { 'asset.id', 'asset.localDateTime', 'asset.isFavorite', + 'asset.type', + 'asset.duration', 'asset_exif.country as country', 'asset_exif.city as city', ]) @@ -977,6 +989,7 @@ export class AssetRepository { qb.where(sql`extract(day from (asset."localDateTime" at time zone 'UTC'))::int`, '=', day!), ) .$if(favoritesOnly === true, (qb) => qb.where('asset.isFavorite', '=', true)) + .$if(type !== undefined, (qb) => qb.where('asset.type', '=', type!)) .where((eb) => eb.exists( eb @@ -1024,6 +1037,94 @@ export class AssetRepository { .execute(); } + /** + * One row per (person, calendar day) among `personIds`, with the distinct asset count that day. + * Predicates mirror `getMemoryFacesForPeriod` — Timeline visibility, not deleted, previewable — + * so a person's density is never skewed by archived or preview-less assets. + */ + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], { takenBefore: DummyValue.DATE }] }) + getMemoryPersonDailyCounts( + ownerId: string, + personIds: string[], + { takenBefore }: { takenBefore: Date }, + ): Promise { + return this.db + .selectFrom('asset') + .innerJoin('asset_face', 'asset_face.assetId', 'asset.id') + .select('asset_face.personId') + .select(sql`date_trunc('day', asset."localDateTime" at time zone 'UTC')`.as('day')) + .select((eb) => + eb.fn + .count(eb.fn('distinct', ['asset.id'])) + .$castTo() + .as('count'), + ) + .$narrowType<{ personId: NotNull }>() + .where('asset.ownerId', '=', ownerId) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where('asset.localDateTime', '<=', takenBefore) + .where('asset_face.personId', 'in', personIds) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .where((eb) => + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.assetId') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Preview), + ), + ) + .groupBy(['asset_face.personId', 'day']) + .orderBy('asset_face.personId') + .orderBy('day', 'asc') + .execute(); + } + + /** + * Assets of one person inside an inclusive `[from, to]` day window (`to` is a calendar day, so + * the upper bound is exclusive of the following day). Bounded by a ≤14-day window — no `LIMIT`. + * Do not copy `getMemoryAssetsForPerson`'s `ORDER BY asset.id … LIMIT 60` — that returns the 60 + * lowest UUIDs, an arbitrary sample, not the most recent assets. + */ + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] }) + getMemoryAssetsForPersonWindow( + ownerId: string, + personId: string, + { from, to }: { from: Date; to: Date }, + ): Promise { + // `to` is the chapter's last day at UTC midnight, so the upper bound must be exclusive of the + // following day — otherwise assets later on the final day (after `to`'s midnight) are dropped. + const toExclusive = new Date(to.getTime() + 24 * 60 * 60 * 1000); + + return this.db + .selectFrom('asset') + .select(['asset.id', 'asset.localDateTime']) + .innerJoin('asset_face', 'asset_face.assetId', 'asset.id') + .where('asset.ownerId', '=', ownerId) + .where('asset_face.personId', '=', personId) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where('asset.localDateTime', '>=', from) + .where('asset.localDateTime', '<', toExclusive) + .where((eb) => + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.assetId') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Preview), + ), + ) + .distinctOn(['asset.id']) + .orderBy('asset.id') + .orderBy('asset.localDateTime', 'asc') + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, 1000, DummyValue.UUID] }) getOwnedManifestAssets(ownerId: string, limit: number, cursor?: string) { return this.db diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 217d672f33fc4..88226443d3c4f 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -31,6 +31,11 @@ export interface PersonNameResponse { name: string; } +export interface DormantPerson { + id: string; + name: string; +} + export interface AssetFaceId { assetId: string; personId: string; @@ -308,6 +313,51 @@ export class PersonRepository { .execute(); } + /** + * Dormant people: named, non-hidden, non-pet persons whose most recent Timeline-visible, + * previewable asset predates `lastSeenBefore`, with at least `minAssets` such assets ever. + * The asset-side predicates mirror `AssetRepository.getMemoryFacesForPeriod` exactly — a + * missing `Preview` file or an archived asset must not make a still-active person look dormant. + */ + @GenerateSql({ + params: [DummyValue.UUID, { lastSeenBefore: DummyValue.DATE, minAssets: 10, limit: 10 }], + }) + getDormantPeople( + ownerId: string, + { lastSeenBefore, minAssets, limit }: { lastSeenBefore: Date; minAssets: number; limit: number }, + ): Promise { + return this.db + .selectFrom('person') + .select(['person.id', 'person.name']) + .innerJoin('asset_face', 'asset_face.personId', 'person.id') + .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .where('person.ownerId', '=', ownerId) + .where('person.type', '=', 'person') + .where('person.name', '!=', '') + .where('person.isHidden', '=', false) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) + .where('asset.ownerId', '=', ownerId) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where((eb) => + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.assetId') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Preview), + ), + ) + .groupBy('person.id') + .having((eb) => eb.fn.max('asset.localDateTime'), '<', lastSeenBefore) + .having((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])), '>=', minAssets) + .orderBy((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])), 'desc') + .orderBy('person.id', 'asc') + .limit(limit) + .execute(); + } + @GenerateSql() getFileSamples() { return this.db diff --git a/server/src/services/memory-rules/chapter.util.spec.ts b/server/src/services/memory-rules/chapter.util.spec.ts new file mode 100644 index 0000000000000..2f891a7b60739 --- /dev/null +++ b/server/src/services/memory-rules/chapter.util.spec.ts @@ -0,0 +1,97 @@ +import { Chapter, CHAPTER_MAX_SPAN_DAYS, DayCount, densestChapter } from 'src/services/memory-rules/chapter.util'; + +const day = (iso: string, count: number): DayCount => ({ day: new Date(`${iso}T00:00:00.000Z`), count }); + +describe('densestChapter', () => { + it('returns null for empty input', () => { + expect(densestChapter([], 14)).toBeNull(); + }); + + it('returns a single-day window when given one day', () => { + const result = densestChapter([day('2024-05-01', 3)], 14); + expect(result).toEqual({ + from: new Date('2024-05-01T00:00:00.000Z'), + to: new Date('2024-05-01T00:00:00.000Z'), + count: 3, + }); + }); + + it('covers the whole set when every day fits inside the span', () => { + const days = [day('2024-05-01', 2), day('2024-05-03', 4), day('2024-05-05', 1)]; + const result = densestChapter(days, 14); + expect(result).toEqual({ + from: new Date('2024-05-01T00:00:00.000Z'), + to: new Date('2024-05-05T00:00:00.000Z'), + count: 7, + }); + }); + + it('picks the denser of two far-apart clusters', () => { + const days = [ + day('2020-01-01', 2), + day('2020-01-02', 2), + // second cluster, denser, far away from the first + day('2020-06-01', 5), + day('2020-06-02', 6), + ]; + const result = densestChapter(days, 14); + expect(result).toEqual({ + from: new Date('2020-06-01T00:00:00.000Z'), + to: new Date('2020-06-02T00:00:00.000Z'), + count: 11, + }); + }); + + it('breaks a tie between two equally dense clusters by picking the more recent one', () => { + const days = [ + // earlier 3-day cluster, total 9 + day('2020-01-01', 3), + day('2020-01-02', 3), + day('2020-01-03', 3), + // later 3-day cluster, also total 9 + day('2020-06-01', 3), + day('2020-06-02', 3), + day('2020-06-03', 3), + ]; + const result = densestChapter(days, 14); + expect(result?.from).toEqual(new Date('2020-06-01T00:00:00.000Z')); + expect(result?.count).toBe(9); + }); + + it('includes both days when they are exactly maxSpanDays - 1 apart', () => { + const days = [day('2020-01-01', 4), day('2020-01-14', 5)]; + const result = densestChapter(days, CHAPTER_MAX_SPAN_DAYS); + expect(result).toEqual({ + from: new Date('2020-01-01T00:00:00.000Z'), + to: new Date('2020-01-14T00:00:00.000Z'), + count: 9, + }); + }); + + it('splits into separate windows when days are exactly maxSpanDays apart', () => { + const days = [day('2020-01-01', 4), day('2020-01-15', 5)]; + const result = densestChapter(days, CHAPTER_MAX_SPAN_DAYS); + expect(result).toEqual({ + from: new Date('2020-01-15T00:00:00.000Z'), + to: new Date('2020-01-15T00:00:00.000Z'), + count: 5, + }); + }); + + it('finds a dense window at the very start of the series (no off-by-one at left = 0)', () => { + const days = [day('2020-01-01', 10), day('2020-01-02', 10), day('2020-03-01', 1), day('2020-05-01', 1)]; + const result = densestChapter(days, 14); + expect(result).toEqual({ + from: new Date('2020-01-01T00:00:00.000Z'), + to: new Date('2020-01-02T00:00:00.000Z'), + count: 20, + }); + }); + + it('returns the same result regardless of input order (defensive sort)', () => { + const ascending = [day('2020-01-01', 2), day('2020-01-02', 2), day('2020-06-01', 5), day('2020-06-02', 6)]; + const descending = ascending.toReversed(); + + expect(densestChapter(descending, 14)).toEqual(densestChapter(ascending, 14)); + }); +}); diff --git a/server/src/services/memory-rules/chapter.util.ts b/server/src/services/memory-rules/chapter.util.ts new file mode 100644 index 0000000000000..07e79f4879637 --- /dev/null +++ b/server/src/services/memory-rules/chapter.util.ts @@ -0,0 +1,48 @@ +export const CHAPTER_MAX_SPAN_DAYS = 14; + +export interface DayCount { + day: Date; + count: number; +} + +export interface Chapter { + from: Date; // first day of the winning window + to: Date; // last day of the winning window + count: number; // assets inside it, summed from the daily counts +} + +const MS_PER_DAY = 86_400_000; + +/** + * Widest-count window of at most `maxSpanDays` consecutive calendar days. + * Sorts `days` ascending defensively — the query already orders them, but the + * two-pointer sweep silently returns garbage on unsorted input rather than + * failing, so the contract is enforced here rather than assumed. + * Ties resolve to the MOST RECENT window. Returns null for empty input. + */ +export const densestChapter = (days: DayCount[], maxSpanDays: number): Chapter | null => { + if (days.length === 0) { + return null; + } + + const sorted = [...days].sort((a, b) => a.day.getTime() - b.day.getTime()); + + let left = 0; + let sum = 0; + let best: Chapter | null = null; + + for (let right = 0; right < sorted.length; right++) { + sum += sorted[right]!.count; + + while ((sorted[right]!.day.getTime() - sorted[left]!.day.getTime()) / MS_PER_DAY > maxSpanDays - 1) { + sum -= sorted[left]!.count; + left++; + } + + if (best === null || sum >= best.count) { + best = { from: sorted[left]!.day, to: sorted[right]!.day, count: sum }; + } + } + + return best; +}; diff --git a/server/src/services/memory-rules/favorites-throwback.rule.spec.ts b/server/src/services/memory-rules/favorites-throwback.rule.spec.ts index fda5f20c4e78e..413bbd508929b 100644 --- a/server/src/services/memory-rules/favorites-throwback.rule.spec.ts +++ b/server/src/services/memory-rules/favorites-throwback.rule.spec.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; import { MemoryPeriodAsset } from 'src/repositories/asset.repository'; import { FavoritesThrowbackMemoryRule } from 'src/services/memory-rules/favorites-throwback.rule'; @@ -12,6 +13,8 @@ const favoritesForYear = (year: number, count: number, month = 7): MemoryPeriodA country: null, city: null, isFavorite: true, + type: AssetType.Image, + duration: null, })); const ruleWith = (assets: MemoryPeriodAsset[]) => { diff --git a/server/src/services/memory-rules/memory-type.metadata.spec.ts b/server/src/services/memory-rules/memory-type.metadata.spec.ts index 0aa5996f9c9ea..7075dfc8ed735 100644 --- a/server/src/services/memory-rules/memory-type.metadata.spec.ts +++ b/server/src/services/memory-rules/memory-type.metadata.spec.ts @@ -26,6 +26,10 @@ describe('memory-type.metadata', () => { { key: 'on_this_day_place', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'season_recap', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'people_together', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'video_moments', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'trip_anniversary', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'themed', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'person_throwback', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, ]); }); @@ -46,6 +50,10 @@ describe('memory-type.metadata', () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ]); }); }); @@ -61,6 +69,10 @@ describe('memory-type.metadata', () => { on_this_day_place: true, season_recap: true, people_together: true, + video_moments: true, + trip_anniversary: true, + themed: true, + person_throwback: true, }); }); }); @@ -107,6 +119,10 @@ describe('memory-type.metadata', () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ]), ); }); @@ -150,6 +166,10 @@ describe('memory-type.metadata', () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ]), ); }); diff --git a/server/src/services/memory-rules/memory-type.metadata.ts b/server/src/services/memory-rules/memory-type.metadata.ts index 1544269e34eac..b77ab692e4e72 100644 --- a/server/src/services/memory-rules/memory-type.metadata.ts +++ b/server/src/services/memory-rules/memory-type.metadata.ts @@ -21,6 +21,10 @@ export const MEMORY_TYPE_METADATA: MemoryTypeMetadata[] = [ { key: 'on_this_day_place', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'season_recap', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, { key: 'people_together', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'video_moments', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'trip_anniversary', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'themed', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, + { key: 'person_throwback', kind: 'rule', defaultEnabled: true, adminConfigurable: true }, ]; export const MEMORY_TYPE_KEYS = MEMORY_TYPE_METADATA.map((m) => m.key); diff --git a/server/src/services/memory-rules/memory-type.registry.spec.ts b/server/src/services/memory-rules/memory-type.registry.spec.ts index 649f52ca151eb..37d4f07406479 100644 --- a/server/src/services/memory-rules/memory-type.registry.spec.ts +++ b/server/src/services/memory-rules/memory-type.registry.spec.ts @@ -1,7 +1,9 @@ import { MEMORY_TYPE_KEYS, MEMORY_TYPE_METADATA } from 'src/services/memory-rules/memory-type.metadata'; import { createMemoryRules, MemoryRuleDeps } from 'src/services/memory-rules/memory-type.registry'; -const deps = {} as MemoryRuleDeps; +const deps = { + themeSearchPort: { resolveEmbedding: vi.fn(), searchByEmbedding: vi.fn() }, +} as unknown as MemoryRuleDeps; const ruleKeys = MEMORY_TYPE_METADATA.filter((m) => m.kind === 'rule').map((m) => m.key); describe('createMemoryRules', () => { @@ -16,6 +18,30 @@ describe('createMemoryRules', () => { expect(rules.map((r) => r.id)).toEqual(['birthday', 'recent_trip']); }); + it('instantiates video_moments by key', () => { + const rules = createMemoryRules(['video_moments'], deps); + expect(rules).toHaveLength(1); + expect(rules[0].id).toBe('video_moments'); + }); + + it('instantiates trip_anniversary by key', () => { + const rules = createMemoryRules(['trip_anniversary'], deps); + expect(rules).toHaveLength(1); + expect(rules[0].id).toBe('trip_anniversary'); + }); + + it('instantiates themed by key', () => { + const rules = createMemoryRules(['themed'], deps); + expect(rules).toHaveLength(1); + expect(rules[0].id).toBe('themed'); + }); + + it('instantiates person_throwback by key', () => { + const rules = createMemoryRules(['person_throwback'], deps); + expect(rules).toHaveLength(1); + expect(rules[0].id).toBe('person_throwback'); + }); + it('returns nothing for a non-rule key', () => { expect(createMemoryRules(['on_this_day'], deps)).toEqual([]); }); diff --git a/server/src/services/memory-rules/memory-type.registry.ts b/server/src/services/memory-rules/memory-type.registry.ts index 348439777bad8..be949703772af 100644 --- a/server/src/services/memory-rules/memory-type.registry.ts +++ b/server/src/services/memory-rules/memory-type.registry.ts @@ -1,3 +1,4 @@ +import { SystemConfig } from 'src/config'; import { AssetRepository } from 'src/repositories/asset.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -8,13 +9,24 @@ import { MEMORY_TYPE_METADATA } from 'src/services/memory-rules/memory-type.meta import { MonthRecapMemoryRule } from 'src/services/memory-rules/month-recap.rule'; import { OnThisDayPlaceMemoryRule } from 'src/services/memory-rules/on-this-day-place.rule'; import { PeopleTogetherMemoryRule } from 'src/services/memory-rules/people-together.rule'; +import { DEFAULT_DORMANCY_MONTHS, PersonThrowbackMemoryRule } from 'src/services/memory-rules/person-throwback.rule'; import { RecentTripMemoryRule } from 'src/services/memory-rules/recent-trip.rule'; import { SeasonRecapMemoryRule } from 'src/services/memory-rules/season-recap.rule'; +import { ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; +import { ThemedMemoryRule } from 'src/services/memory-rules/themed.rule'; +import { TripAnniversaryMemoryRule } from 'src/services/memory-rules/trip-anniversary.rule'; +import { VideoMomentsMemoryRule } from 'src/services/memory-rules/video-moments.rule'; export interface MemoryRuleDeps { personRepository: PersonRepository; assetRepository: AssetRepository; memoryRepository: MemoryRepository; + themeSearchPort: ThemeSearchPort; + /** + * Admin-tunable knobs from `SystemConfig['memories']`. Optional so callers that construct rules + * without config (tests, tooling) fall back to each rule's own default. + */ + memories?: Pick; } /** per rule-kind key, how to construct its MemoryRule */ @@ -26,6 +38,15 @@ const RULE_FACTORIES: Record MemoryRule> = { on_this_day_place: (deps) => new OnThisDayPlaceMemoryRule(deps.assetRepository), season_recap: (deps) => new SeasonRecapMemoryRule(deps.assetRepository), people_together: (deps) => new PeopleTogetherMemoryRule(deps.assetRepository), + video_moments: (deps) => new VideoMomentsMemoryRule(deps.assetRepository), + trip_anniversary: (deps) => new TripAnniversaryMemoryRule(deps.assetRepository), + themed: (deps) => new ThemedMemoryRule(deps.themeSearchPort), + person_throwback: (deps) => + new PersonThrowbackMemoryRule( + deps.personRepository, + deps.assetRepository, + deps.memories?.personThrowbackDormancyMonths ?? DEFAULT_DORMANCY_MONTHS, + ), }; /** instantiate the rule-kind memory rules whose key is in `enabledKeys` (in registry order, deduped) */ diff --git a/server/src/services/memory-rules/month-recap.rule.spec.ts b/server/src/services/memory-rules/month-recap.rule.spec.ts index 148f668714f73..bfed240372f5f 100644 --- a/server/src/services/memory-rules/month-recap.rule.spec.ts +++ b/server/src/services/memory-rules/month-recap.rule.spec.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; import { MemoryPeriodAsset } from 'src/repositories/asset.repository'; import { MonthRecapMemoryRule } from 'src/services/memory-rules/month-recap.rule'; @@ -12,6 +13,8 @@ const assetsForYear = (year: number, count: number, month = 7): MemoryPeriodAsse country: null, city: null, isFavorite: false, + type: AssetType.Image, + duration: null, })); const ruleWith = (assets: MemoryPeriodAsset[]) => { diff --git a/server/src/services/memory-rules/on-this-day-place.rule.spec.ts b/server/src/services/memory-rules/on-this-day-place.rule.spec.ts index 1501b146a4eb1..073c6f3c6617e 100644 --- a/server/src/services/memory-rules/on-this-day-place.rule.spec.ts +++ b/server/src/services/memory-rules/on-this-day-place.rule.spec.ts @@ -1,6 +1,12 @@ import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; import { MemoryPeriodAsset } from 'src/repositories/asset.repository'; -import { OnThisDayPlaceMemoryRule } from 'src/services/memory-rules/on-this-day-place.rule'; +import { recencyBonus } from 'src/services/memory-rules/curation.util'; +import { + MAX_COUNT_BONUS, + OnThisDayPlaceMemoryRule, + SCORE_BASE, +} from 'src/services/memory-rules/on-this-day-place.rule'; const target = DateTime.fromISO('2026-06-10', { zone: 'utc' }); @@ -20,6 +26,8 @@ const cityAssets = ( country, city, isFavorite: false, + type: AssetType.Image, + duration: null, })); const ruleWith = (assets: MemoryPeriodAsset[]) => { @@ -45,7 +53,7 @@ describe(OnThisDayPlaceMemoryRule.name, () => { ruleId: 'on_this_day_place', title: 'On this day in Lisbon', subtitle: '6 photos from 2023', - dedupeKey: 'on_this_day_place:2023-06-10:france:lisbon', + dedupeKey: 'place_day:2023-06-10:france:lisbon', score: 125, // 100 + 6*3 + recencyBonus(2023,2026)=7 context: { year: 2023, city: 'Lisbon', country: 'France', count: 6 }, }); @@ -126,6 +134,18 @@ describe(OnThisDayPlaceMemoryRule.name, () => { // Note: a two-city *tie* can never pass the 60% majority gate (each side is <= 50%), so the // deterministic tie-break lives in and is tested by dominantBy (curation.util.spec). + it('caps the count bonus so 40 photos score the same as 30 (score cap)', async () => { + const { rule: ruleAt40 } = ruleWith(cityAssets(2023, 'Lisbon', 40)); + const { rule: ruleAt30 } = ruleWith(cityAssets(2023, 'Lisbon', 30)); + + const [at40] = await ruleAt40.evaluate({ ownerId: 'user-1', target }); + const [at30] = await ruleAt30.evaluate({ ownerId: 'user-1', target }); + + const expectedScore = SCORE_BASE + MAX_COUNT_BONUS * 3 + recencyBonus(2023, 2026); + expect(at40.score).toBe(expectedScore); + expect(at30.score).toBe(expectedScore); + }); + it('emits nothing when only current-year photos exist', async () => { const { rule } = ruleWith(cityAssets(2026, 'Lisbon', 8)); expect(await rule.evaluate({ ownerId: 'user-1', target })).toEqual([]); diff --git a/server/src/services/memory-rules/on-this-day-place.rule.ts b/server/src/services/memory-rules/on-this-day-place.rule.ts index 82e8b4ee11817..f4d429aa34aaf 100644 --- a/server/src/services/memory-rules/on-this-day-place.rule.ts +++ b/server/src/services/memory-rules/on-this-day-place.rule.ts @@ -1,8 +1,14 @@ import { AssetRepository, MemoryPeriodAsset } from 'src/repositories/asset.repository'; import { dominantBy, recencyBonus, sampleAssetsByTime } from 'src/services/memory-rules/curation.util'; import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; +import { placeKeyOf } from 'src/services/memory-rules/trip.util'; -const placeKeyOf = (asset: MemoryPeriodAsset): string => `${asset.country ?? ''}:${asset.city}`.toLowerCase(); +export const MIN_ASSETS = 4; +export const MIN_DOMINANCE = 0.6; +export const MAX_YEARS = 3; +export const ASSET_CAP = 8; +export const SCORE_BASE = 100; +export const MAX_COUNT_BONUS = 30; /** A usable place needs a non-blank city (EXIF city is usually null when absent, but can be ''). */ const hasCity = (asset: MemoryPeriodAsset): boolean => asset.city !== null && asset.city.trim() !== ''; @@ -10,10 +16,6 @@ const hasCity = (asset: MemoryPeriodAsset): boolean => asset.city !== null && as /** "On this day in Lisbon" — a past year's on-this-day photos dominated by a single city. */ export class OnThisDayPlaceMemoryRule implements MemoryRule { readonly id = 'on_this_day_place'; - private static readonly MIN_ASSETS = 4; - private static readonly MIN_DOMINANCE = 0.6; - private static readonly MAX_YEARS = 3; - private static readonly ASSET_CAP = 8; constructor(private assetRepository: Pick) {} @@ -39,11 +41,8 @@ export class OnThisDayPlaceMemoryRule implements MemoryRule { const candidates: MemoryRuleCandidate[] = []; for (const [year, geotagged] of byYear) { - const dominant = dominantBy(geotagged, placeKeyOf); - if ( - dominant.items.length < OnThisDayPlaceMemoryRule.MIN_ASSETS || - dominant.ratio < OnThisDayPlaceMemoryRule.MIN_DOMINANCE - ) { + const dominant = dominantBy(geotagged, (asset) => placeKeyOf(asset.country, asset.city)); + if (dominant.items.length < MIN_ASSETS || dominant.ratio < MIN_DOMINANCE) { continue; } @@ -51,16 +50,16 @@ export class OnThisDayPlaceMemoryRule implements MemoryRule { const count = dominant.items.length; candidates.push({ ruleId: this.id, - dedupeKey: `on_this_day_place:${year}-${mm}-${dd}:${dominant.key}`, + dedupeKey: `place_day:${year}-${mm}-${dd}:${dominant.key}`, title: `On this day in ${city}`, subtitle: `${count} photos from ${year}`, - score: 100 + count * 3 + recencyBonus(year, target.year), - assetIds: sampleAssetsByTime(dominant.items, OnThisDayPlaceMemoryRule.ASSET_CAP), + score: SCORE_BASE + Math.min(count, MAX_COUNT_BONUS) * 3 + recencyBonus(year, target.year), + assetIds: sampleAssetsByTime(dominant.items, ASSET_CAP), memoryAt: target.set({ year }), context: { year, city, country: dominant.items[0]!.country, count }, }); } - return candidates.toSorted((left, right) => right.score - left.score).slice(0, OnThisDayPlaceMemoryRule.MAX_YEARS); + return candidates.toSorted((left, right) => right.score - left.score).slice(0, MAX_YEARS); } } diff --git a/server/src/services/memory-rules/person-throwback.rule.spec.ts b/server/src/services/memory-rules/person-throwback.rule.spec.ts new file mode 100644 index 0000000000000..c5d9cb225a2d3 --- /dev/null +++ b/server/src/services/memory-rules/person-throwback.rule.spec.ts @@ -0,0 +1,285 @@ +import { DateTime } from 'luxon'; +import { MemoryAsset, MemoryPersonDayCount } from 'src/repositories/asset.repository'; +import { DormantPerson } from 'src/repositories/person.repository'; +import { DEFAULT_DORMANCY_MONTHS, PersonThrowbackMemoryRule } from 'src/services/memory-rules/person-throwback.rule'; + +const target = DateTime.fromISO('2026-08-13', { zone: 'utc' }); + +let seq = 0; + +/** One `MemoryPersonDayCount` row per entry in `counts`, on consecutive UTC calendar days starting `startDate`. */ +const dailyCounts = (personId: string, startDate: string, counts: number[]): MemoryPersonDayCount[] => { + const start = DateTime.fromISO(startDate, { zone: 'utc' }); + return counts.map((count, index) => ({ personId, day: start.plus({ days: index }).toJSDate(), count })); +}; + +/** One asset per unit of `counts[dayIndex]`, spread across the same days as `dailyCounts`, chronological. */ +const buildAssets = (startDate: string, counts: number[]): MemoryAsset[] => { + const start = DateTime.fromISO(startDate, { zone: 'utc' }); + const assets: MemoryAsset[] = []; + for (const [dayIndex, count] of counts.entries()) { + for (let hour = 0; hour < count; hour++) { + assets.push({ id: `asset-${seq++}`, localDateTime: start.plus({ days: dayIndex, hours: hour }).toJSDate() }); + } + } + return assets; +}; + +const person = (id: string, name: string): DormantPerson => ({ id, name }); + +type WindowAssetsFn = (personId: string) => Promise; + +const ruleWith = ( + people: DormantPerson[], + counts: MemoryPersonDayCount[], + assetsOrFn: MemoryAsset[] | WindowAssetsFn, + dormancyMonths?: number, +) => { + const personRepository = { getDormantPeople: vi.fn().mockResolvedValue(people) }; + const resolveAssets: WindowAssetsFn = + typeof assetsOrFn === 'function' ? assetsOrFn : () => Promise.resolve(assetsOrFn); + const assetRepository = { + getMemoryPersonDailyCounts: vi.fn().mockResolvedValue(counts), + getMemoryAssetsForPersonWindow: vi.fn((_ownerId: string, personId: string) => resolveAssets(personId)), + }; + return { + rule: new PersonThrowbackMemoryRule(personRepository as never, assetRepository as never, dormancyMonths), + personRepository, + assetRepository, + }; +}; + +describe(PersonThrowbackMemoryRule.name, () => { + beforeEach(() => { + seq = 0; + }); + + it('given target.day is not 13, then returns [] without calling the repository', async () => { + const { rule, personRepository } = ruleWith([], [], []); + const before = DateTime.fromISO('2026-08-12', { zone: 'utc' }); + const after = DateTime.fromISO('2026-08-14', { zone: 'utc' }); + + await expect(rule.evaluate({ ownerId: 'user-1', target: before })).resolves.toEqual([]); + await expect(rule.evaluate({ ownerId: 'user-1', target: after })).resolves.toEqual([]); + expect(personRepository.getDormantPeople).not.toHaveBeenCalled(); + }); + + it('given an empty dormant-person pool, then returns [] and never queries daily counts', async () => { + const { rule, assetRepository } = ruleWith([], [], []); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + expect(assetRepository.getMemoryPersonDailyCounts).not.toHaveBeenCalled(); + }); + + it('given a dormant person with a rich chapter, then emits one candidate with the pinned title/subtitle/dedupeKey/score', async () => { + const counts = [3, 3, 3, 2, 2, 2, 2, 2, 2, 2]; // sums to 23, 10 days (2023-08-01..2023-08-10) + const days = dailyCounts('p1', '2023-08-01', counts); + const assets = buildAssets('2023-08-01', counts); + const { rule } = ruleWith([person('p1', 'Anna')], days, assets); + + const result = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + ruleId: 'person_throwback', + dedupeKey: 'person_throwback:p1', + title: 'Times with Anna', + subtitle: '23 photos · August 2023', + score: 186, // 110 + min(23,30)*3 + recencyBonus(2023,2026)=7 -> 110+69+7 + }); + }); + + it('given any run reaching the dormant-person query, then lastSeenBefore is exactly the default 6 months before the trigger day', async () => { + const { rule, personRepository } = ruleWith([], [], []); + await rule.evaluate({ ownerId: 'user-1', target }); + const [, options] = personRepository.getDormantPeople.mock.calls[0]!; + expect((options as { lastSeenBefore: Date }).lastSeenBefore).toEqual( + target.startOf('day').minus({ months: DEFAULT_DORMANCY_MONTHS }).toJSDate(), + ); + }); + + it('given a configured dormancy window, then lastSeenBefore honours it instead of the default', async () => { + const { rule, personRepository } = ruleWith([], [], [], 18); + await rule.evaluate({ ownerId: 'user-1', target }); + const [, options] = personRepository.getDormantPeople.mock.calls[0]!; + expect((options as { lastSeenBefore: Date }).lastSeenBefore).toEqual( + target.startOf('day').minus({ months: 18 }).toJSDate(), + ); + }); + + it('given a person included in the dormant pool with a qualifying chapter, then a candidate is produced', async () => { + const counts = [10]; + const days = dailyCounts('p1', '2024-05-01', counts); + const assets = buildAssets('2024-05-01', counts); + const { rule } = ruleWith([person('p1', 'Ben')], days, assets); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + }); + + it('given a chapter with fewer than MIN_CHAPTER_ASSETS assets, then that person is excluded', async () => { + const counts = [5]; + const days = dailyCounts('p1', '2024-05-01', counts); + const { rule } = ruleWith([person('p1', 'Cara')], days, []); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + }); + + it('given any run reaching the dormant-person query, then it is called with minAssets 10, limit 10 and the exact cutoff', async () => { + const { rule, personRepository } = ruleWith([], [], []); + await rule.evaluate({ ownerId: 'user-1', target }); + expect(personRepository.getDormantPeople).toHaveBeenCalledWith('user-1', { + lastSeenBefore: target.startOf('day').minus({ months: DEFAULT_DORMANCY_MONTHS }).toJSDate(), + minAssets: 10, + limit: 10, + }); + }); + + it('given 7 qualifying people, then exactly 5 candidates are returned, score descending', async () => { + const counts = [30, 25, 20, 15, 12, 10, 7]; + const ids = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7']; + const people = ids.map((id, index) => person(id, `Person ${index}`)); + const days = ids.flatMap((id, index) => dailyCounts(id, '2024-01-10', [counts[index]!])); + const assetsByPerson = new Map(ids.map((id, index) => [id, buildAssets('2024-01-10', [counts[index]!])])); + const { rule } = ruleWith(people, days, (personId) => Promise.resolve(assetsByPerson.get(personId) ?? [])); + + const result = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(result).toHaveLength(5); + const scores = result.map((c) => c.score); + expect(scores).toEqual([...scores].sort((a, b) => b - a)); + }); + + it('given two people with identical scores, then ties break by personId ascending', async () => { + const counts = [10]; + const days = [...dailyCounts('pB', '2024-01-10', counts), ...dailyCounts('pA', '2024-01-10', counts)]; + const assetsA = buildAssets('2024-01-10', counts); + const assetsB = buildAssets('2024-01-10', counts); + const { rule } = ruleWith([person('pB', 'Bea'), person('pA', 'Adam')], days, (personId) => + Promise.resolve(personId === 'pA' ? assetsA : assetsB), + ); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(2); + expect(result[0].score).toBe(result[1].score); + expect(result.map((c) => c.context?.personId)).toEqual(['pA', 'pB']); + }); + + it('given a chapter spanning a month boundary, then the subtitle month/year come from medianTime, not chapter.from', async () => { + const counts = [1, 1, 1, 2, 2, 2, 2]; // 2019-07-29..07-31, 08-01..08-04, heavier in August, sums to 11 + const days = dailyCounts('p1', '2019-07-29', counts); + const assets = buildAssets('2019-07-29', counts); + const { rule } = ruleWith([person('p1', 'Dana')], days, assets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.subtitle).toBe('11 photos · August 2019'); + }); + + it('given a single-day chapter of 8 assets, then it is included (no distinct-day minimum)', async () => { + const counts = [8]; + const days = dailyCounts('p1', '2024-02-01', counts); + const assets = buildAssets('2024-02-01', counts); + const { rule } = ruleWith([person('p1', 'Eve')], days, assets); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + }); + + it('given a chapter dated 4 years back, then recencyBonus contributes 6 to the score', async () => { + const counts = [10]; + const days = dailyCounts('p1', '2022-03-01', counts); + const assets = buildAssets('2022-03-01', counts); + const { rule } = ruleWith([person('p1', 'Finn')], days, assets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.score).toBe(146); // 110 + min(10,30)*3 + recencyBonus(2022,2026)=6 -> 110+30+6 + }); + + it('given equal chapters 2 and 8 years back, then the 2-years-back one scores higher', async () => { + const counts = [10]; + const recentDays = dailyCounts('pRecent', '2024-03-01', counts); + const oldDays = dailyCounts('pOld', '2018-03-01', counts); + const recentAssets = buildAssets('2024-03-01', counts); + const oldAssets = buildAssets('2018-03-01', counts); + const { rule } = ruleWith( + [person('pRecent', 'Gia'), person('pOld', 'Hank')], + [...recentDays, ...oldDays], + (personId) => Promise.resolve(personId === 'pRecent' ? recentAssets : oldAssets), + ); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + const recent = result.find((c) => c.context?.personId === 'pRecent')!; + const old = result.find((c) => c.context?.personId === 'pOld')!; + expect(recent.score).toBeGreaterThan(old.score); + expect(recent.score).toBe(148); // 110 + 30 + recencyBonus(2024,2026)=8 + expect(old.score).toBe(142); // 110 + 30 + recencyBonus(2018,2026)=2 + }); + + it('given a window with 20 assets, then assetIds is capped at 8, evenly spaced and chronological', async () => { + const counts = [20]; + const days = dailyCounts('p1', '2019-05-01', counts); + const assets = buildAssets('2019-05-01', counts); + const { rule } = ruleWith([person('p1', 'Ivy')], days, assets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.assetIds).toHaveLength(8); + const indices = candidate.assetIds.map((id) => Number(id.replace('asset-', ''))); + expect(indices).toEqual([...indices].sort((a, b) => a - b)); + }); + + it('given any candidate, then visibleForDays is 7 and dedupeKey has no year', async () => { + const counts = [10]; + const days = dailyCounts('p1', '2024-04-01', counts); + const assets = buildAssets('2024-04-01', counts); + const { rule } = ruleWith([person('p1', 'Jo')], days, assets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.visibleForDays).toBe(7); + expect(candidate.dedupeKey).not.toMatch(/\d{4}/); + }); + + it('given every pooled candidate fails the chapter bar, then returns [] without any window query', async () => { + const days = [ + ...dailyCounts('p1', '2024-01-01', [3]), + ...dailyCounts('p2', '2024-01-01', [4]), + ...dailyCounts('p3', '2024-01-01', [5]), + ]; + const { rule, assetRepository } = ruleWith([person('p1', 'A'), person('p2', 'B'), person('p3', 'C')], days, []); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + expect(assetRepository.getMemoryAssetsForPersonWindow).not.toHaveBeenCalled(); + }); + + it('given the window query returns fewer assets than chapter.count but still enough, then the candidate is kept reporting the chapter total', async () => { + const counts = [3, 3, 3, 2, 2, 2, 2, 2, 2, 2]; // 23 + const days = dailyCounts('p1', '2023-08-01', counts); + const fewerAssets = buildAssets('2023-08-01', [10]); // only 10, all still within August 2023 + const { rule } = ruleWith([person('p1', 'Anna')], days, fewerAssets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.subtitle).toBe('23 photos · August 2023'); + }); + + it('given the window query returns 4 assets, then the candidate is dropped', async () => { + const counts = [3, 3, 3, 2, 2, 2, 2, 2, 2, 2]; // 23 + const days = dailyCounts('p1', '2023-08-01', counts); + const fewAssets = buildAssets('2023-08-01', [4]); + const { rule } = ruleWith([person('p1', 'Anna')], days, fewAssets); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + }); + + it('given the window query returns zero assets, then the candidate is dropped', async () => { + const counts = [3, 3, 3, 2, 2, 2, 2, 2, 2, 2]; // 23 + const days = dailyCounts('p1', '2023-08-01', counts); + const { rule } = ruleWith([person('p1', 'Anna')], days, []); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + }); + + it("given one candidate's window query rejects, then that candidate is dropped and the others still return", async () => { + const goodCounts = [10]; + const days = [...dailyCounts('pGood', '2024-01-10', goodCounts), ...dailyCounts('pBad', '2024-01-10', goodCounts)]; + const goodAssets = buildAssets('2024-01-10', goodCounts); + const { rule } = ruleWith([person('pGood', 'Kim'), person('pBad', 'Lee')], days, (personId) => { + if (personId === 'pBad') { + throw new Error('boom'); + } + return Promise.resolve(goodAssets); + }); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + expect(result[0].context?.personId).toBe('pGood'); + }); +}); diff --git a/server/src/services/memory-rules/person-throwback.rule.ts b/server/src/services/memory-rules/person-throwback.rule.ts new file mode 100644 index 0000000000000..38152503dd8c8 --- /dev/null +++ b/server/src/services/memory-rules/person-throwback.rule.ts @@ -0,0 +1,140 @@ +import { DateTime } from 'luxon'; +import { AssetRepository, MemoryAsset } from 'src/repositories/asset.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { Chapter, CHAPTER_MAX_SPAN_DAYS, DayCount, densestChapter } from 'src/services/memory-rules/chapter.util'; +import { medianTime, monthName, recencyBonus, sampleAssetsByTime } from 'src/services/memory-rules/curation.util'; +import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; + +export const TRIGGER_DAY = 13; +/** Fallback when `memories.personThrowbackDormancyMonths` is absent (see `config.ts`). */ +export const DEFAULT_DORMANCY_MONTHS = 6; +export const MIN_TOTAL_ASSETS = 10; +export const MIN_CHAPTER_ASSETS = 6; +export const CANDIDATE_POOL = 10; +export const MAX_CANDIDATES = 5; +export const ASSET_CAP = 8; +export const VISIBLE_FOR_DAYS = 7; +export const SCORE_BASE = 110; +export const MAX_COUNT_BONUS = 30; + +interface RankedCandidate { + personId: string; + name: string; + chapter: Chapter; + score: number; +} + +/** + * "Times with Anna" — a person who hasn't appeared in the user's photos for + * `dormancyMonths` or more, resurfaced via their densest chapter (D4/D9). Gap length is never + * shown and never scored (D1/D5); ranking rewards chapter richness, with a mild `recencyBonus` + * nudge (D6). Returns up to `MAX_CANDIDATES` so the engine's per-key dedup can skip an + * already-fired person (D8). + */ +export class PersonThrowbackMemoryRule implements MemoryRule { + readonly id = 'person_throwback'; + + constructor( + private personRepository: Pick, + private assetRepository: Pick, + private dormancyMonths: number = DEFAULT_DORMANCY_MONTHS, + ) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { + if (target.day !== TRIGGER_DAY) { + return []; + } + + const lastSeenBefore = target.startOf('day').minus({ months: this.dormancyMonths }).toJSDate(); + + const people = await this.personRepository.getDormantPeople(ownerId, { + lastSeenBefore, + minAssets: MIN_TOTAL_ASSETS, + limit: CANDIDATE_POOL, + }); + + // Load-bearing: an empty `personIds` array below would emit `IN ()`, which is invalid SQL. + if (people.length === 0) { + return []; + } + + const dayCounts = await this.assetRepository.getMemoryPersonDailyCounts( + ownerId, + people.map((person) => person.id), + { takenBefore: lastSeenBefore }, + ); + + const daysByPerson = new Map(); + for (const row of dayCounts) { + const days = daysByPerson.get(row.personId) ?? []; + days.push({ day: row.day, count: row.count }); + daysByPerson.set(row.personId, days); + } + + const ranked: RankedCandidate[] = []; + for (const person of people) { + const chapter = densestChapter(daysByPerson.get(person.id) ?? [], CHAPTER_MAX_SPAN_DAYS); + if (chapter === null || chapter.count < MIN_CHAPTER_ASSETS) { + continue; + } + + // The chapter window's own year, available before the (costly) per-person window fetch + // below, and identical to the eventual `memoryAt`'s year except when a chapter straddles a + // calendar year boundary. + const chapterYear = DateTime.fromJSDate(chapter.to, { zone: 'utc' }).year; + const score = SCORE_BASE + Math.min(chapter.count, MAX_COUNT_BONUS) * 3 + recencyBonus(chapterYear, target.year); + ranked.push({ personId: person.id, name: person.name, chapter, score }); + } + + ranked.sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.personId < right.personId ? -1 : left.personId > right.personId ? 1 : 0; + }); + + const survivors = ranked.slice(0, MAX_CANDIDATES); + const candidates: MemoryRuleCandidate[] = []; + + for (const candidate of survivors) { + let assets: MemoryAsset[]; + try { + assets = await this.assetRepository.getMemoryAssetsForPersonWindow(ownerId, candidate.personId, { + from: candidate.chapter.from, + to: candidate.chapter.to, + }); + } catch { + // One person's window query failing must not sink the whole rule (row 20). + continue; + } + + // Read skew between the step-5 daily-counts query and this query: `chapter.count` came + // from a different, earlier read, so re-check against the assets actually returned. + if (assets.length < MIN_CHAPTER_ASSETS) { + continue; + } + + const memoryAt = DateTime.fromJSDate(medianTime(assets), { zone: 'utc' }); + + candidates.push({ + ruleId: this.id, + dedupeKey: `person_throwback:${candidate.personId}`, + title: `Times with ${candidate.name}`, + // `chapter.count` — the full chapter total — never `assetIds.length` (capped at ASSET_CAP). + subtitle: `${candidate.chapter.count} photos · ${monthName(memoryAt.month)} ${memoryAt.year}`, + score: candidate.score, + assetIds: sampleAssetsByTime(assets, ASSET_CAP), + memoryAt, + visibleForDays: VISIBLE_FOR_DAYS, + context: { + personId: candidate.personId, + chapterFrom: candidate.chapter.from, + chapterTo: candidate.chapter.to, + count: candidate.chapter.count, + }, + }); + } + + return candidates; + } +} diff --git a/server/src/services/memory-rules/recent-trip.rule.ts b/server/src/services/memory-rules/recent-trip.rule.ts index 9d16db5bcb7e2..18dee908d78e1 100644 --- a/server/src/services/memory-rules/recent-trip.rule.ts +++ b/server/src/services/memory-rules/recent-trip.rule.ts @@ -1,14 +1,12 @@ import { DateTime } from 'luxon'; import { AssetOrderWithRandom, MemoryType } from 'src/enum'; -import { AssetRepository, MemoryAsset, MemoryLocationCluster } from 'src/repositories/asset.repository'; +import { AssetRepository, MemoryLocationCluster } from 'src/repositories/asset.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; +import { curateTripAssets, inferHome, isAwayFromHome, placeKeyOf } from 'src/services/memory-rules/trip.util'; export class RecentTripMemoryRule implements MemoryRule { readonly id = 'recent_trip'; - private static readonly HOME_DOMINANCE_RATIO = 1.25; - private static readonly BURST_WINDOW_MS = 2 * 60 * 1000; - private static readonly SMALL_TRIP_MAX = 6; constructor( private assetRepository: Pick, @@ -36,16 +34,8 @@ export class RecentTripMemoryRule implements MemoryRule { }), ]); - const [home, runnerUp] = baseline; - if (!home?.country) { - return []; - } - - const isAmbiguousHome = - !!runnerUp && - runnerUp.country !== home.country && - runnerUp.assetCount >= home.assetCount / RecentTripMemoryRule.HOME_DOMINANCE_RATIO; - if (isAmbiguousHome) { + const home = inferHome(baseline); + if (!home) { return []; } @@ -58,7 +48,7 @@ export class RecentTripMemoryRule implements MemoryRule { return []; } - const placeKey = `${candidate.country}:${candidate.city ?? ''}`.toLowerCase(); + const placeKey = placeKeyOf(candidate.country, candidate.city); const isCoolingDown = recentRuleMemories.some((memory) => { const data = memory.data as Record; if (data.ruleId !== this.id) { @@ -80,7 +70,7 @@ export class RecentTripMemoryRule implements MemoryRule { takenAfter: recentFrom.toJSDate(), takenBefore: target.endOf('day').toJSDate(), }); - const assetIds = this.curateTripAssets(locationAssets); + const assetIds = curateTripAssets(locationAssets, 10); const placeLabel = candidate.city ? `${candidate.city}, ${candidate.country}` : candidate.country; const dedupeDay = target.toFormat('yyyy-MM-dd'); @@ -113,100 +103,6 @@ export class RecentTripMemoryRule implements MemoryRule { return false; } - if (item.country !== home.country) { - return true; - } - - return !!home.city && !!item.city && item.city !== home.city; - } - - private curateTripAssets(assets: MemoryAsset[]): string[] { - const representatives = this.collapseBurstAssets(assets); - if (representatives.length <= RecentTripMemoryRule.SMALL_TRIP_MAX) { - return representatives.map(({ id }) => id); - } - - const dayBuckets = this.groupAssetsByDay(representatives); - const targetSize = this.getTripTargetSize(dayBuckets.length, representatives.length); - const selected = this.pickDayCoverage(dayBuckets, targetSize); - const selectedIds = new Set(selected.map(({ id }) => id)); - - if (selected.length < targetSize) { - const remaining = representatives.filter(({ id }) => !selectedIds.has(id)); - selected.push(...this.pickEvenlySpaced(remaining, targetSize - selected.length)); - } - - return [...selected] - .toSorted((left, right) => left.localDateTime.getTime() - right.localDateTime.getTime()) - .map(({ id }) => id); - } - - private collapseBurstAssets(assets: MemoryAsset[]): MemoryAsset[] { - const representatives: MemoryAsset[] = []; - let previous: MemoryAsset | undefined; - - for (const asset of assets) { - if ( - !previous || - asset.localDateTime.getTime() - previous.localDateTime.getTime() > RecentTripMemoryRule.BURST_WINDOW_MS - ) { - representatives.push(asset); - } - previous = asset; - } - - return representatives; - } - - private groupAssetsByDay(assets: MemoryAsset[]): MemoryAsset[][] { - const byDay = new Map(); - - for (const asset of assets) { - const dayKey = DateTime.fromJSDate(asset.localDateTime, { zone: 'utc' }).toISODate(); - const dayAssets = byDay.get(dayKey!) ?? []; - dayAssets.push(asset); - byDay.set(dayKey!, dayAssets); - } - - return [...byDay.values()]; - } - - private getTripTargetSize(dayCount: number, representativeCount: number) { - if (representativeCount <= RecentTripMemoryRule.SMALL_TRIP_MAX) { - return representativeCount; - } - - if (dayCount >= 5 || representativeCount >= 18) { - return 10; - } - - if (dayCount >= 4 || representativeCount >= 12) { - return 8; - } - - return 7; - } - - private pickDayCoverage(dayBuckets: MemoryAsset[][], targetSize: number): MemoryAsset[] { - const buckets = dayBuckets.length <= targetSize ? dayBuckets : this.pickEvenlySpaced(dayBuckets, targetSize); - return buckets.map((assets) => assets[Math.floor((assets.length - 1) / 2)]!); - } - - private pickEvenlySpaced(items: T[], count: number): T[] { - if (count <= 0 || items.length === 0) { - return []; - } - - if (count >= items.length) { - return [...items]; - } - - if (count === 1) { - return [items[Math.floor((items.length - 1) / 2)]!]; - } - - const indexes = Array.from({ length: count }, (_, index) => Math.round((index * (items.length - 1)) / (count - 1))); - - return indexes.map((index) => items[index]!); + return isAwayFromHome(item, home); } } diff --git a/server/src/services/memory-rules/season-recap.rule.spec.ts b/server/src/services/memory-rules/season-recap.rule.spec.ts index ed8fa78e1a52c..441eb2041b7a5 100644 --- a/server/src/services/memory-rules/season-recap.rule.spec.ts +++ b/server/src/services/memory-rules/season-recap.rule.spec.ts @@ -1,4 +1,5 @@ import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; import { MemoryPeriodAsset } from 'src/repositories/asset.repository'; import { SeasonRecapMemoryRule } from 'src/services/memory-rules/season-recap.rule'; @@ -16,6 +17,8 @@ const assetsIn = (year: number, month: number, count: number): MemoryPeriodAsset country: null, city: null, isFavorite: false, + type: AssetType.Image, + duration: null, })); const ruleWith = (assets: MemoryPeriodAsset[]) => { diff --git a/server/src/services/memory-rules/theme-search.adapter.spec.ts b/server/src/services/memory-rules/theme-search.adapter.spec.ts new file mode 100644 index 0000000000000..e1ec0bb0ab98b --- /dev/null +++ b/server/src/services/memory-rules/theme-search.adapter.spec.ts @@ -0,0 +1,171 @@ +import { SystemConfig } from 'src/config'; +import { AssetType, AssetVisibility } from 'src/enum'; +import { MemoryThemeSearchAdapter, SEARCH_WINDOW_MARGIN_DAYS } from 'src/services/memory-rules/theme-search.adapter'; + +const buildConfig = ( + overrides: { + machineLearningEnabled?: boolean; + clipEnabled?: boolean; + modelName?: string; + themeMaxDistance?: number; + } = {}, +): SystemConfig => + ({ + machineLearning: { + enabled: overrides.machineLearningEnabled ?? true, + clip: { + enabled: overrides.clipEnabled ?? true, + modelName: overrides.modelName ?? 'ViT-B-32__openai', + }, + }, + memories: { + themeMaxDistance: overrides.themeMaxDistance ?? 0.75, + }, + }) as SystemConfig; + +const buildAdapter = (config: SystemConfig = buildConfig()) => { + const machineLearningRepository = { encodeText: vi.fn().mockResolvedValue('embedding-string') }; + const searchRepository = { searchSmart: vi.fn().mockResolvedValue({ items: [], hasNextPage: false }) }; + const getConfig = vi.fn().mockResolvedValue(config); + const logger = { warn: vi.fn() }; + const adapter = new MemoryThemeSearchAdapter( + machineLearningRepository as never, + searchRepository as never, + getConfig, + logger as never, + ); + return { adapter, machineLearningRepository, searchRepository, getConfig, logger }; +}; + +describe(MemoryThemeSearchAdapter.name, () => { + describe('resolveEmbedding', () => { + it('returns null and never calls encodeText when machine learning is disabled', async () => { + const { adapter, machineLearningRepository } = buildAdapter(buildConfig({ machineLearningEnabled: false })); + + await expect(adapter.resolveEmbedding('sunset', 'a beautiful sunset')).resolves.toBeNull(); + expect(machineLearningRepository.encodeText).not.toHaveBeenCalled(); + }); + + it('returns null and never calls encodeText when clip is disabled', async () => { + const { adapter, machineLearningRepository } = buildAdapter(buildConfig({ clipEnabled: false })); + + await expect(adapter.resolveEmbedding('sunset', 'a beautiful sunset')).resolves.toBeNull(); + expect(machineLearningRepository.encodeText).not.toHaveBeenCalled(); + }); + + it('calls encodeText once for two identical (modelName, language, themeKey) requests, the second a cache hit', async () => { + const { adapter, machineLearningRepository } = buildAdapter(); + + const first = await adapter.resolveEmbedding('sunset', 'a beautiful sunset'); + const second = await adapter.resolveEmbedding('sunset', 'a beautiful sunset'); + + expect(machineLearningRepository.encodeText).toHaveBeenCalledTimes(1); + expect(first).toBe('embedding-string'); + expect(second).toBe('embedding-string'); + }); + + it('calls encodeText again when clip.modelName changes between calls', async () => { + const machineLearningRepository = { encodeText: vi.fn().mockResolvedValue('embedding-string') }; + const searchRepository = { searchSmart: vi.fn() }; + const getConfig = vi + .fn() + .mockResolvedValueOnce(buildConfig({ modelName: 'model-a' })) + .mockResolvedValueOnce(buildConfig({ modelName: 'model-b' })); + const logger = { warn: vi.fn() }; + const adapter = new MemoryThemeSearchAdapter( + machineLearningRepository as never, + searchRepository as never, + getConfig, + logger as never, + ); + + await adapter.resolveEmbedding('sunset', 'a beautiful sunset'); + await adapter.resolveEmbedding('sunset', 'a beautiful sunset'); + + expect(machineLearningRepository.encodeText).toHaveBeenCalledTimes(2); + }); + + it('calls encodeText again when language changes between calls (cache key includes language)', async () => { + const { adapter, machineLearningRepository } = buildAdapter(buildConfig({ modelName: 'model-a' })); + + // Seed the cache as if a prior call had been made for the same model/theme but under a + // different language. If the cache key omitted language, this seeded entry would collide + // with the real call below and encodeText would incorrectly be skipped (a stale-language + // cache hit). + (adapter as unknown as { cache: Map }).cache.set('model-a:fr:sunset', 'stale-fr-embedding'); + + const result = await adapter.resolveEmbedding('sunset', 'a beautiful sunset'); + + expect(machineLearningRepository.encodeText).toHaveBeenCalledTimes(1); + expect(result).toBe('embedding-string'); + expect(result).not.toBe('stale-fr-embedding'); + }); + + it('returns null and does not throw when encodeText rejects', async () => { + const machineLearningRepository = { encodeText: vi.fn().mockRejectedValue(new Error('ml down')) }; + const searchRepository = { searchSmart: vi.fn() }; + const getConfig = vi.fn().mockResolvedValue(buildConfig()); + const logger = { warn: vi.fn() }; + const adapter = new MemoryThemeSearchAdapter( + machineLearningRepository as never, + searchRepository as never, + getConfig, + logger as never, + ); + + await expect(adapter.resolveEmbedding('sunset', 'a beautiful sunset')).resolves.toBeNull(); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe('searchByEmbedding', () => { + it('forwards to searchSmart with the expected options and a 2-day-widened window', async () => { + const { adapter, searchRepository } = buildAdapter(buildConfig({ themeMaxDistance: 0.75 })); + + const takenAfter = new Date('2023-01-01T00:00:00.000Z'); + const takenBefore = new Date('2023-12-31T23:59:59.999Z'); + + await adapter.searchByEmbedding({ + ownerId: 'owner-1', + embedding: 'embedding-string', + takenAfter, + takenBefore, + size: 40, + }); + + expect(SEARCH_WINDOW_MARGIN_DAYS).toBe(2); + expect(searchRepository.searchSmart).toHaveBeenCalledTimes(1); + expect(searchRepository.searchSmart).toHaveBeenCalledWith( + { page: 1, size: 40 }, + { + embedding: 'embedding-string', + userIds: ['owner-1'], + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + maxDistance: 0.75, + takenAfter: new Date('2022-12-30T00:00:00.000Z'), + takenBefore: new Date('2024-01-02T23:59:59.999Z'), + }, + ); + }); + + it('maps searchSmart rows to { id, localDateTime } only', async () => { + const localDateTime = new Date('2023-06-15T12:00:00.000Z'); + const { adapter, searchRepository } = buildAdapter(); + searchRepository.searchSmart.mockResolvedValue({ + items: [{ id: 'asset-1', localDateTime, ownerId: 'owner-1', type: AssetType.Image, extraField: 'ignored' }], + hasNextPage: false, + }); + + const result = await adapter.searchByEmbedding({ + ownerId: 'owner-1', + embedding: 'embedding-string', + takenAfter: new Date('2023-01-01T00:00:00.000Z'), + takenBefore: new Date('2023-12-31T23:59:59.999Z'), + size: 40, + }); + + expect(result).toEqual([{ id: 'asset-1', localDateTime }]); + }); + }); +}); diff --git a/server/src/services/memory-rules/theme-search.adapter.ts b/server/src/services/memory-rules/theme-search.adapter.ts new file mode 100644 index 0000000000000..e45d28abf549f --- /dev/null +++ b/server/src/services/memory-rules/theme-search.adapter.ts @@ -0,0 +1,90 @@ +import { SystemConfig } from 'src/config'; +import { AssetType, AssetVisibility } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { ThemeSearchAsset, ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; +import { isSmartSearchEnabled } from 'src/utils/misc'; + +/** + * `searchAssetBuilder` filters `takenAfter`/`takenBefore` against `asset.fileCreatedAt` + * (`src/utils/database.ts:725-726`), not `localDateTime`, which every memory rule buckets by. + * Widening the search window by this many days on each side ensures no in-year asset is missed + * by that skew; the calling rule then filters the results to the exact year by `localDateTime`. + */ +export const SEARCH_WINDOW_MARGIN_DAYS = 2; +const SEARCH_WINDOW_MARGIN_MS = SEARCH_WINDOW_MARGIN_DAYS * 24 * 60 * 60 * 1000; + +/** `ThemeSearchPort` backed by real CLIP text encoding + smart search. Memoizes embeddings. */ +export class MemoryThemeSearchAdapter implements ThemeSearchPort { + private readonly cache = new Map(); + + constructor( + private machineLearningRepository: Pick, + private searchRepository: Pick, + private getConfig: () => Promise, + private logger: Pick, + ) {} + + async resolveEmbedding(themeKey: string, query: string): Promise { + const config = await this.getConfig(); + if (!isSmartSearchEnabled(config.machineLearning)) { + return null; + } + + const { modelName } = config.machineLearning.clip; + // No caller passes a language in this batch (model default). Still included in the cache + // key so a future non-English deployment cannot serve a stale English embedding. + const language: string | undefined = undefined; + const cacheKey = `${modelName}:${language ?? 'default'}:${themeKey}`; + + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + try { + const embedding = await this.machineLearningRepository.encodeText(query, { modelName, language }); + this.cache.set(cacheKey, embedding); + return embedding; + } catch (error) { + this.logger.warn( + `Failed to resolve theme embedding for "${themeKey}": ${error instanceof Error ? error.message : error}`, + ); + return null; + } + } + + async searchByEmbedding({ + ownerId, + embedding, + takenAfter, + takenBefore, + size, + }: { + ownerId: string; + embedding: string; + takenAfter: Date; + takenBefore: Date; + size: number; + }): Promise { + const config = await this.getConfig(); + const widenedAfter = new Date(takenAfter.getTime() - SEARCH_WINDOW_MARGIN_MS); + const widenedBefore = new Date(takenBefore.getTime() + SEARCH_WINDOW_MARGIN_MS); + + const { items } = await this.searchRepository.searchSmart( + { page: 1, size }, + { + embedding, + userIds: [ownerId], + takenAfter: widenedAfter, + takenBefore: widenedBefore, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + maxDistance: config.memories.themeMaxDistance, + }, + ); + + return items.map(({ id, localDateTime }) => ({ id, localDateTime })); + } +} diff --git a/server/src/services/memory-rules/theme-search.port.ts b/server/src/services/memory-rules/theme-search.port.ts new file mode 100644 index 0000000000000..aadf6fc16ad92 --- /dev/null +++ b/server/src/services/memory-rules/theme-search.port.ts @@ -0,0 +1,17 @@ +export interface ThemeSearchAsset { + id: string; + localDateTime: Date; +} + +export interface ThemeSearchPort { + /** null when smart search is disabled or the embedding cannot be produced. Never throws. */ + resolveEmbedding(themeKey: string, query: string): Promise; + /** Assets ordered by similarity, best first. */ + searchByEmbedding(params: { + ownerId: string; + embedding: string; + takenAfter: Date; + takenBefore: Date; + size: number; + }): Promise; +} diff --git a/server/src/services/memory-rules/theme.catalog.spec.ts b/server/src/services/memory-rules/theme.catalog.spec.ts new file mode 100644 index 0000000000000..435ae5c33fdef --- /dev/null +++ b/server/src/services/memory-rules/theme.catalog.spec.ts @@ -0,0 +1,52 @@ +import { THEMES, themeForMonth } from 'src/services/memory-rules/theme.catalog'; + +describe('THEMES', () => { + it('has 6 entries', () => { + expect(THEMES).toHaveLength(6); + }); + + it('has unique keys', () => { + const keys = THEMES.map((theme) => theme.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('has a non-empty query and label for every entry', () => { + for (const theme of THEMES) { + expect(theme.query.length).toBeGreaterThan(0); + expect(theme.label.length).toBeGreaterThan(0); + } + }); +}); + +describe(themeForMonth.name, () => { + it('is pinned for all 12 months', () => { + const keysByMonth = Array.from({ length: 12 }, (_, index) => themeForMonth(index + 1).key); + expect(keysByMonth).toEqual([ + 'sunset', + 'beach', + 'food', + 'mountains', + 'snow', + 'city_night', + 'sunset', + 'beach', + 'food', + 'mountains', + 'snow', + 'city_night', + ]); + }); + + it('gives the same theme for month 1 and month 7 (6 themes, 12 months)', () => { + expect(themeForMonth(1).key).toBe(themeForMonth(7).key); + }); + + it('depends only on the month, so the same month in different years gives the same theme', () => { + // themeForMonth takes only a month number, so there is no year input to vary — this is + // exactly why rotation is stable across year and leap boundaries (365 % 6 !== 0 would + // break a day-of-year based rotation). + for (let month = 1; month <= 12; month++) { + expect(themeForMonth(month)).toEqual(themeForMonth(month)); + } + }); +}); diff --git a/server/src/services/memory-rules/theme.catalog.ts b/server/src/services/memory-rules/theme.catalog.ts new file mode 100644 index 0000000000000..741c8b6ca9b04 --- /dev/null +++ b/server/src/services/memory-rules/theme.catalog.ts @@ -0,0 +1,19 @@ +export interface Theme { + key: string; + /** CLIP text prompt */ + query: string; + /** human label used in the memory title */ + label: string; +} + +export const THEMES: Theme[] = [ + { key: 'sunset', query: 'a beautiful sunset', label: 'Sunsets' }, + { key: 'beach', query: 'a beach with sand and ocean', label: 'Beach days' }, + { key: 'food', query: 'a plate of food at a meal', label: 'Food' }, + { key: 'mountains', query: 'mountains and hiking trails', label: 'Mountains' }, + { key: 'snow', query: 'a snowy winter landscape', label: 'Snow days' }, + { key: 'city_night', query: 'a city skyline at night', label: 'City lights' }, +]; + +/** Deterministic for a given calendar month, forever. */ +export const themeForMonth = (month: number): Theme => THEMES[(month - 1) % THEMES.length]!; diff --git a/server/src/services/memory-rules/themed.rule.spec.ts b/server/src/services/memory-rules/themed.rule.spec.ts new file mode 100644 index 0000000000000..4ea5516927787 --- /dev/null +++ b/server/src/services/memory-rules/themed.rule.spec.ts @@ -0,0 +1,230 @@ +import { DateTime } from 'luxon'; +import { ThemeSearchAsset, ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; +import { ASSET_CAP, FETCH_SIZE, MAX_YEARS_BACK, ThemedMemoryRule } from 'src/services/memory-rules/themed.rule'; + +const target = DateTime.fromISO('2026-07-22', { zone: 'utc' }); + +const asset = (id: string, year: number, month: number, day: number, hour = 0): ThemeSearchAsset => ({ + id, + localDateTime: DateTime.utc(year, month, day, hour).toJSDate(), +}); + +/** `count` assets spread by hour within a single day of `year`, chronological by index. */ +const yearAssets = (year: number, count: number, prefix = 'a'): ThemeSearchAsset[] => + Array.from({ length: count }, (_, index) => asset(`${prefix}-${year}-${index}`, year, 6, 1, index)); + +interface FakePort { + resolveEmbedding: ReturnType; + searchByEmbedding: ReturnType; +} + +/** + * `assetsByYear` keys off the calendar year the rule is querying for (derived from the widened + * `takenAfter` bound the rule passes), mirroring the real adapter: a single call for year `Y` can + * return assets whose own `localDateTime` spills into `Y-1`/`Y+1` (§3.4.1 widened-window skew). + */ +const ruleWith = ( + assetsByYear: Record = {}, + options: { embedding?: string | null; rejectEmbedding?: boolean } = {}, +): { rule: ThemedMemoryRule; port: FakePort } => { + const resolveEmbedding = options.rejectEmbedding + ? vi.fn().mockRejectedValue(new Error('encode failed')) + : vi.fn().mockResolvedValue(options.embedding === undefined ? 'embedding-string' : options.embedding); + const searchByEmbedding = vi + .fn() + .mockImplementation(({ takenAfter }: { takenAfter: Date }) => + Promise.resolve(assetsByYear[takenAfter.getUTCFullYear()] ?? []), + ); + const port: FakePort = { resolveEmbedding, searchByEmbedding }; + return { rule: new ThemedMemoryRule(port as unknown as ThemeSearchPort), port }; +}; + +describe(ThemedMemoryRule.name, () => { + describe('trigger day gating', () => { + it('emits nothing and never resolves an embedding on days 1, 8, 15, 21, 23', async () => { + for (const day of [1, 8, 15, 21, 23]) { + const { rule, port } = ruleWith(); + const result = await rule.evaluate({ + ownerId: 'user-1', + target: DateTime.fromISO(`2026-07-${String(day).padStart(2, '0')}`, { zone: 'utc' }), + }); + expect(result).toEqual([]); + expect(port.resolveEmbedding).not.toHaveBeenCalled(); + } + }); + + it('fires on day 22, resolving the sunset theme embedding for July', async () => { + const { rule, port } = ruleWith({ 2023: yearAssets(2023, 18) }); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + expect(port.resolveEmbedding).toHaveBeenCalledWith('sunset', 'a beautiful sunset'); + }); + }); + + describe('given 18 qualifying assets in 2023 (theme sunset, target 2026-07-22)', () => { + it('fires with the pinned title/subtitle/dedupeKey/score/visibleForDays/ruleId', async () => { + const { rule } = ruleWith({ 2023: yearAssets(2023, 18) }); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate).toMatchObject({ + ruleId: 'themed', + dedupeKey: 'themed:sunset:2023', + title: 'Sunsets from 2023', + subtitle: '18 photos', + visibleForDays: 5, + context: { year: 2023, theme: 'sunset', count: 18 }, + }); + // SCORE_BASE(70) + min(18,25) + recencyBonus(2023,2026)=max(0,10-3)=7 => 95 + expect(candidate.score).toBe(95); + expect(candidate.assetIds).toHaveLength(16); + }); + }); + + describe('disabled path', () => { + it('returns [] and never calls searchByEmbedding when resolveEmbedding resolves null', async () => { + const { rule, port } = ruleWith({}, { embedding: null }); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + expect(port.searchByEmbedding).not.toHaveBeenCalled(); + }); + }); + + describe('embedding resolution failure', () => { + it('swallows a resolveEmbedding rejection, returning [] without throwing', async () => { + const { rule, port } = ruleWith({}, { rejectEmbedding: true }); + await expect(rule.evaluate({ ownerId: 'user-1', target })).resolves.toEqual([]); + expect(port.searchByEmbedding).not.toHaveBeenCalled(); + }); + }); + + describe('year filter (§3.4.1: takenAfter/takenBefore hit fileCreatedAt, not localDateTime)', () => { + it('counts only in-year assets when a single call returns Y-1/Y/Y+1', async () => { + const raw = [ + ...yearAssets(2022, 3, 'prev'), // Y-1, widened-window spillover + ...yearAssets(2023, 10, 'cur'), // Y + ...yearAssets(2024, 2, 'next'), // Y+1, widened-window spillover + ]; + const { rule } = ruleWith({ 2023: raw }); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate.context).toMatchObject({ year: 2023, count: 10 }); + expect(candidate.subtitle).toBe('10 photos'); + }); + + it('does not fire when 8 raw assets for the year include only 7 in-year (MIN_ASSETS applied after filtering)', async () => { + const raw = [...yearAssets(2023, 7, 'cur'), ...yearAssets(2024, 1, 'next')]; + const { rule } = ruleWith({ 2023: raw }); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toEqual([]); + }); + }); + + describe('the MIN_ASSETS threshold boundary', () => { + it('skips a year with 7 in-year assets but fires with exactly 8', async () => { + const seven = await ruleWith({ 2023: yearAssets(2023, 7) }).rule.evaluate({ ownerId: 'user-1', target }); + expect(seven).toEqual([]); + + const eight = await ruleWith({ 2023: yearAssets(2023, 8) }).rule.evaluate({ ownerId: 'user-1', target }); + expect(eight).toHaveLength(1); + }); + }); + + describe('search window', () => { + it('searches exactly MAX_YEARS_BACK years, newest first, and never the current year', async () => { + const { rule, port } = ruleWith(); + await rule.evaluate({ ownerId: 'user-1', target }); + + expect(port.searchByEmbedding).toHaveBeenCalledTimes(MAX_YEARS_BACK); + expect(port.searchByEmbedding).toHaveBeenNthCalledWith(1, { + ownerId: 'user-1', + embedding: 'embedding-string', + takenAfter: new Date('2025-01-01T00:00:00.000Z'), + takenBefore: new Date('2025-12-31T23:59:59.999Z'), + size: FETCH_SIZE, + }); + expect(port.searchByEmbedding).toHaveBeenNthCalledWith(2, { + ownerId: 'user-1', + embedding: 'embedding-string', + takenAfter: new Date('2024-01-01T00:00:00.000Z'), + takenBefore: new Date('2024-12-31T23:59:59.999Z'), + size: FETCH_SIZE, + }); + expect(port.searchByEmbedding).toHaveBeenNthCalledWith(3, { + ownerId: 'user-1', + embedding: 'embedding-string', + takenAfter: new Date('2023-01-01T00:00:00.000Z'), + takenBefore: new Date('2023-12-31T23:59:59.999Z'), + size: FETCH_SIZE, + }); + }); + }); + + describe('bounds passed to the port', () => { + it('passes size: FETCH_SIZE and native Date bounds (not Luxon DateTime)', async () => { + const { rule, port } = ruleWith(); + await rule.evaluate({ ownerId: 'user-1', target }); + + const firstCallArgs = port.searchByEmbedding.mock.calls[0]![0] as { + size: number; + takenAfter: Date; + takenBefore: Date; + }; + expect(firstCallArgs.size).toBe(FETCH_SIZE); + expect(firstCallArgs.takenAfter).toBeInstanceOf(Date); + expect(firstCallArgs.takenBefore).toBeInstanceOf(Date); + expect(firstCallArgs.takenAfter.constructor.name).toBe('Date'); + }); + }); + + describe('multi-year emission (regression guard: hasRuleMemory dedup happens in the engine, after the rule returns)', () => { + it('returns candidates for BOTH qualifying years, sorted by score desc', async () => { + const { rule } = ruleWith({ 2025: yearAssets(2025, 10, 'y25'), 2023: yearAssets(2023, 20, 'y23') }); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(result).toHaveLength(2); + expect(result.map((candidate) => candidate.context?.year)).toEqual([2023, 2025]); + expect(result.map((candidate) => candidate.dedupeKey)).toEqual(['themed:sunset:2023', 'themed:sunset:2025']); + // 2023: 70 + min(20,25) + recencyBonus(2023,2026)=7 => 97 + // 2025: 70 + min(10,25) + recencyBonus(2025,2026)=9 => 89 + expect(result[0]!.score).toBe(97); + expect(result[1]!.score).toBe(89); + }); + }); + + describe('non-tautological ordering (assetIds must not just echo the similarity order)', () => { + it('returns assetIds in chronological order, evenly sampled and capped at ASSET_CAP', async () => { + const chronological = Array.from({ length: 17 }, (_, index) => asset(`a${index}`, 2023, 6, 1, index)); + const similarityOrder = chronological.toReversed(); // deliberately NOT chronological + const { rule } = ruleWith({ 2023: similarityOrder }); + + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(ASSET_CAP).toBe(16); + expect(candidate.assetIds).toEqual([ + 'a0', + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + 'a7', + 'a9', + 'a10', + 'a11', + 'a12', + 'a13', + 'a14', + 'a15', + 'a16', + ]); + }); + }); + + describe('given zero assets from the port across all years', () => { + it('returns [] without throwing', async () => { + const { rule } = ruleWith({}); + await expect(rule.evaluate({ ownerId: 'user-1', target })).resolves.toEqual([]); + }); + }); +}); diff --git a/server/src/services/memory-rules/themed.rule.ts b/server/src/services/memory-rules/themed.rule.ts new file mode 100644 index 0000000000000..6a43657feadaa --- /dev/null +++ b/server/src/services/memory-rules/themed.rule.ts @@ -0,0 +1,88 @@ +import { DateTime } from 'luxon'; +import { medianTime, recencyBonus, sampleAssetsByTime } from 'src/services/memory-rules/curation.util'; +import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; +import { ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; +import { themeForMonth } from 'src/services/memory-rules/theme.catalog'; + +export const TRIGGER_DAY = 22; +export const MAX_YEARS_BACK = 3; +export const FETCH_SIZE = 40; +export const MIN_ASSETS = 8; +export const ASSET_CAP = 16; +export const VISIBLE_FOR_DAYS = 5; +export const MAX_CANDIDATES = 3; +export const SCORE_BASE = 70; + +/** + * "Sunsets from 2023" — a curated CLIP theme (rotated by calendar month) resurfaced from a past + * year via smart search. Never sees `maxDistance`; the port/adapter owns quality thresholding. + */ +export class ThemedMemoryRule implements MemoryRule { + readonly id = 'themed'; + + constructor(private themeSearchPort: ThemeSearchPort) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { + if (target.day !== TRIGGER_DAY) { + return []; + } + + const theme = themeForMonth(target.month); + + let embedding: string | null; + try { + embedding = await this.themeSearchPort.resolveEmbedding(theme.key, theme.query); + } catch { + // resolveEmbedding is documented to never throw, but guard defensively anyway. + return []; + } + if (embedding === null) { + return []; + } + + const candidates: MemoryRuleCandidate[] = []; + + for (let year = target.year - 1; year >= target.year - MAX_YEARS_BACK; year--) { + // No min(..., target) clamp: this range already excludes the current year. + const takenAfter = DateTime.utc(year, 1, 1).startOf('day'); + const takenBefore = DateTime.utc(year, 12, 31).endOf('day'); + + const assets = await this.themeSearchPort.searchByEmbedding({ + ownerId, + embedding, + takenAfter: takenAfter.toJSDate(), + takenBefore: takenBefore.toJSDate(), + size: FETCH_SIZE, + }); + + // The port's date bounds hit asset.fileCreatedAt (via the adapter's widened window), not + // localDateTime, so re-filter to exactly this year before applying MIN_ASSETS (§3.4.1). + const filtered = assets.filter( + (asset) => DateTime.fromJSDate(asset.localDateTime, { zone: 'utc' }).year === year, + ); + if (filtered.length < MIN_ASSETS) { + continue; + } + + const count = filtered.length; + + candidates.push({ + ruleId: this.id, + dedupeKey: `themed:${theme.key}:${year}`, + title: `${theme.label} from ${year}`, + subtitle: `${count} photos`, + score: SCORE_BASE + Math.min(count, 25) + recencyBonus(year, target.year), + assetIds: sampleAssetsByTime(filtered, ASSET_CAP), + memoryAt: DateTime.fromJSDate(medianTime(filtered), { zone: 'utc' }), + visibleForDays: VISIBLE_FOR_DAYS, + context: { year, theme: theme.key, count }, + }); + } + + // Emit every qualifying year (up to MAX_CANDIDATES), not just the best: hasRuleMemory dedup + // happens in the engine after the rule returns, and recencyBonus always favours the newest + // year, so a 1-candidate rule would make older years permanently unreachable once the + // newest year's memory exists. + return candidates.toSorted((left, right) => right.score - left.score).slice(0, MAX_CANDIDATES); + } +} diff --git a/server/src/services/memory-rules/trip-anniversary.rule.spec.ts b/server/src/services/memory-rules/trip-anniversary.rule.spec.ts new file mode 100644 index 0000000000000..bb02b31d54507 --- /dev/null +++ b/server/src/services/memory-rules/trip-anniversary.rule.spec.ts @@ -0,0 +1,480 @@ +import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; +import { MemoryAsset, MemoryLocationCluster, MemoryPeriodAsset } from 'src/repositories/asset.repository'; +import { recencyBonus } from 'src/services/memory-rules/curation.util'; +import { + MAX_COUNT_BONUS, + OnThisDayPlaceMemoryRule, + SCORE_BASE as PLACE_SCORE_BASE, +} from 'src/services/memory-rules/on-this-day-place.rule'; +import { + ASSET_CAP, + MAX_CANDIDATES, + MAX_PROBE_YEARS, + MIN_PROBE_ASSETS, + MIN_PROBE_DOMINANCE, + MIN_TRIP_ASSETS, + MIN_TRIP_DAYS, + SCORE_BASE as TRIP_SCORE_BASE, + TripAnniversaryMemoryRule, +} from 'src/services/memory-rules/trip-anniversary.rule'; + +const TARGET = DateTime.fromISO('2026-06-10', { zone: 'utc' }); + +let seq = 0; + +/** On-this-day probe fixture: `count` assets in `year` for `city`/`country`, on TARGET's or a given target's month/day. */ +const probeCityAssets = ( + target: DateTime, + year: number, + city: string | null, + count: number, + country: string | null = 'Italy', +): MemoryPeriodAsset[] => + Array.from({ length: count }, () => ({ + id: `probe-${city ?? 'none'}-${year}-${seq++}`, + localDateTime: target.set({ year }).plus({ minutes: seq }).toJSDate(), + year, + country, + city, + isFavorite: false, + type: AssetType.Image, + duration: null, + })); + +const cluster = ( + country: string | null, + city: string | null, + assetCount: number, + dayCount: number, + firstDate: string, + lastDate: string, +): MemoryLocationCluster => ({ + country, + city, + assetCount, + dayCount, + firstDate: new Date(firstDate), + lastDate: new Date(lastDate), +}); + +const locationAsset = (id: string, iso: string): MemoryAsset => ({ id, localDateTime: new Date(iso) }); + +const germanyHome = (): MemoryLocationCluster => + cluster('Germany', 'Berlin', 20, 10, '2020-03-15T00:00:00Z', '2020-06-01T00:00:00Z'); + +/** A trip cluster starting exactly on `year`-06-10 (TARGET's anniversary), meeting both trip thresholds by default. */ +const romeTrip = ( + year: number, + { assetCount = 8, dayCount = 3 }: { assetCount?: number; dayCount?: number } = {}, +): MemoryLocationCluster => + cluster( + 'Italy', + 'Rome', + assetCount, + dayCount, + `${year}-06-10T09:00:00Z`, + DateTime.fromISO(`${year}-06-10T09:00:00Z`, { zone: 'utc' }) + .plus({ days: dayCount - 1 }) + .toISO()!, + ); + +interface FakeAssetRepository { + getMemoryAssetsForPeriod: ReturnType; + getMemoryLocationClusters: ReturnType; + getMemoryAssetsForLocation: ReturnType; +} + +const ruleWith = ( + probeAssets: MemoryPeriodAsset[], +): { rule: TripAnniversaryMemoryRule; assetRepository: FakeAssetRepository } => { + const assetRepository: FakeAssetRepository = { + getMemoryAssetsForPeriod: vi.fn().mockResolvedValue(probeAssets), + getMemoryLocationClusters: vi.fn(), + getMemoryAssetsForLocation: vi.fn().mockResolvedValue([]), + }; + return { rule: new TripAnniversaryMemoryRule(assetRepository as never), assetRepository }; +}; + +describe(TripAnniversaryMemoryRule.name, () => { + beforeEach(() => { + seq = 0; + }); + + describe('given a probe with one dominant city in 2023, a different-country home, and a trip cluster on the anniversary', () => { + it('fires exactly one candidate with the pinned title/subtitle/score/memoryAt/visibleForDays/dedupeKey', async () => { + const { rule, assetRepository } = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: 8, dayCount: 3 })]); + assetRepository.getMemoryAssetsForLocation.mockResolvedValue([ + locationAsset('rome-1', '2023-06-10T09:00:00Z'), + locationAsset('rome-2', '2023-06-11T09:00:00Z'), + locationAsset('rome-3', '2023-06-12T09:00:00Z'), + ]); + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toHaveLength(1); + const [candidate] = result; + expect(candidate).toMatchObject({ + ruleId: 'trip_anniversary', + dedupeKey: 'place_day:2023-06-10:italy:rome', + title: 'Your trip to Rome, Italy', + subtitle: '3 years ago · 8 photos over 3 days', + score: 287, // 260 + 3*4 + min(8,20) + recencyBonus(2023,2026)=7 -> 260+12+8+7 + visibleForDays: 3, + }); + expect(candidate.memoryAt.toISO()).toBe('2023-06-10T09:00:00.000Z'); + expect(candidate.assetIds).toEqual(['rome-1', 'rome-2', 'rome-3']); + expect(candidate.context).toEqual({ + year: 2023, + placeKey: 'italy:rome', + placeLabel: 'Rome, Italy', + country: 'Italy', + city: 'Rome', + assetCount: 8, + dayCount: 3, + tripStart: new Date('2023-06-10T09:00:00Z').toISOString(), + tripEnd: new Date('2023-06-12T09:00:00Z').toISOString(), + }); + + expect(assetRepository.getMemoryLocationClusters).toHaveBeenNthCalledWith(1, 'user-1', { + takenAfter: new Date('2023-03-12T00:00:00.000Z'), + takenBefore: new Date('2023-06-04T23:59:59.999Z'), + }); + expect(assetRepository.getMemoryLocationClusters).toHaveBeenNthCalledWith(2, 'user-1', { + takenAfter: new Date('2023-06-05T00:00:00.000Z'), + takenBefore: new Date('2023-07-01T23:59:59.999Z'), + }); + expect(assetRepository.getMemoryAssetsForLocation).toHaveBeenCalledWith('user-1', { + country: 'Italy', + city: 'Rome', + takenAfter: new Date('2023-06-10T09:00:00Z'), + takenBefore: new Date('2023-06-12T09:00:00Z'), + }); + }); + }); + + describe('shared-key contract with OnThisDayPlaceMemoryRule', () => { + it("trip_anniversary's dedupeKey equals on_this_day_place's REAL output for the same probe fixture", async () => { + const probeAssets = probeCityAssets(TARGET, 2023, 'Rome', 6); + + const placeRule = new OnThisDayPlaceMemoryRule({ + getMemoryAssetsForPeriod: vi.fn().mockResolvedValue(probeAssets), + } as never); + const [placeCandidate] = await placeRule.evaluate({ ownerId: 'user-1', target: TARGET }); + + const { rule: tripRule, assetRepository } = ruleWith(probeAssets); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023)]); + const [tripCandidate] = await tripRule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(placeCandidate.dedupeKey).toBe('place_day:2023-06-10:italy:rome'); + expect(tripCandidate.dedupeKey).toBe(placeCandidate.dedupeKey); + }); + }); + + describe('scoring invariant: trip_anniversary at its minimum outscores on_this_day_place at its maximum', () => { + it('derives both bounds from the two rules exported constants, and the trip score wins', async () => { + // trip_anniversary minimum: MIN_TRIP_DAYS, MIN_TRIP_ASSETS, oldest year (recencyBonus -> 0). + const tripYear = 2016; // target.year - 10 -> recencyBonus(2016, 2026) = max(0, 10-10) = 0 + const { rule: tripRule, assetRepository: tripRepo } = ruleWith( + probeCityAssets(TARGET, tripYear, 'Rome', MIN_PROBE_ASSETS), + ); + tripRepo.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(tripYear, { assetCount: MIN_TRIP_ASSETS, dayCount: MIN_TRIP_DAYS })]); + const [tripCandidate] = await tripRule.evaluate({ ownerId: 'user-1', target: TARGET }); + + // on_this_day_place maximum: count >= 30 (capped), most recent past year (recencyBonus -> 9). + const placeYear = 2025; // target.year - 1 -> recencyBonus(2025, 2026) = 9 + const placeRule = new OnThisDayPlaceMemoryRule({ + getMemoryAssetsForPeriod: vi + .fn() + .mockResolvedValue(probeCityAssets(TARGET, placeYear, 'Lisbon', 30, 'Portugal')), + } as never); + const [placeCandidate] = await placeRule.evaluate({ ownerId: 'user-1', target: TARGET }); + + const expectedTripMinScore = + TRIP_SCORE_BASE + MIN_TRIP_DAYS * 4 + Math.min(MIN_TRIP_ASSETS, 20) + recencyBonus(tripYear, TARGET.year); + const expectedPlaceMaxScore = PLACE_SCORE_BASE + MAX_COUNT_BONUS * 3 + recencyBonus(placeYear, TARGET.year); + + expect(tripCandidate.score).toBe(expectedTripMinScore); + expect(placeCandidate.score).toBe(expectedPlaceMaxScore); + expect(tripCandidate.score).toBeGreaterThan(placeCandidate.score); + }); + }); + + describe('probe short-circuit', () => { + it('returns [] and never calls getMemoryLocationClusters when no past year has a dominant city', async () => { + const { rule, assetRepository } = ruleWith([ + ...probeCityAssets(TARGET, 2023, 'Rome', 2, 'Italy'), + ...probeCityAssets(TARGET, 2023, 'Paris', 2, 'France'), + ]); + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toEqual([]); + expect(assetRepository.getMemoryLocationClusters).not.toHaveBeenCalled(); + }); + }); + + describe('ambiguous home', () => { + it('returns [] and never calls getMemoryAssetsForLocation even though the probe and trip would otherwise qualify', async () => { + const { rule, assetRepository } = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + const ambiguousHome = germanyHome(); // assetCount 20 + const ambiguousRunnerUp = cluster('France', 'Paris', 16, 8, '2020-04-01T00:00:00Z', '2020-04-08T00:00:00Z'); // 20/1.25 = 16 + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([ambiguousHome, ambiguousRunnerUp]) + .mockResolvedValueOnce([romeTrip(2023)]); // would otherwise qualify -- must never be consumed + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toEqual([]); + expect(assetRepository.getMemoryLocationClusters).toHaveBeenCalledTimes(1); + expect(assetRepository.getMemoryAssetsForLocation).not.toHaveBeenCalled(); + }); + }); + + describe('mid-stay rejection', () => { + it('returns [] and never fetches assets when the trip cluster started the day before the anniversary', async () => { + const { rule, assetRepository } = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + const midStayTrip = cluster('Italy', 'Rome', 8, 3, '2023-06-09T09:00:00Z', '2023-06-11T09:00:00Z'); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([midStayTrip]); + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toEqual([]); + expect(assetRepository.getMemoryAssetsForLocation).not.toHaveBeenCalled(); + }); + }); + + describe('boundary pairs', () => { + it('rejects dayCount 1 and accepts dayCount 2 (assetCount fixed at MIN_TRIP_ASSETS)', async () => { + const rejected = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + rejected.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: MIN_TRIP_ASSETS, dayCount: 1 })]); + expect(await rejected.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toEqual([]); + expect(rejected.assetRepository.getMemoryAssetsForLocation).not.toHaveBeenCalled(); + + const accepted = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + accepted.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: MIN_TRIP_ASSETS, dayCount: 2 })]); + expect(await accepted.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toHaveLength(1); + }); + + it('rejects assetCount 6 and accepts assetCount 7 (dayCount fixed at MIN_TRIP_DAYS)', async () => { + const rejected = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + rejected.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: 6, dayCount: MIN_TRIP_DAYS })]); + expect(await rejected.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toEqual([]); + expect(rejected.assetRepository.getMemoryAssetsForLocation).not.toHaveBeenCalled(); + + const accepted = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + accepted.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: 7, dayCount: MIN_TRIP_DAYS })]); + expect(await accepted.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toHaveLength(1); + }); + + it('rejects a probe dominance ratio of 0.5 and accepts exactly MIN_PROBE_DOMINANCE (0.6), without ever calling downstream queries on rejection', async () => { + expect(MIN_PROBE_DOMINANCE).toBe(0.6); + + const rejected = ruleWith([ + ...probeCityAssets(TARGET, 2023, 'Rome', 5, 'Italy'), + ...probeCityAssets(TARGET, 2023, 'Paris', 5, 'France'), + ]); + expect(await rejected.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toEqual([]); + expect(rejected.assetRepository.getMemoryLocationClusters).not.toHaveBeenCalled(); + + const accepted = ruleWith([ + ...probeCityAssets(TARGET, 2023, 'Rome', 6, 'Italy'), + ...probeCityAssets(TARGET, 2023, 'Paris', 4, 'France'), + ]); + accepted.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023)]); + expect(await accepted.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toHaveLength(1); + }); + + it('rejects a probe items.length of 2 and accepts exactly MIN_PROBE_ASSETS (3), without ever calling downstream queries on rejection', async () => { + expect(MIN_PROBE_ASSETS).toBe(3); + + const rejected = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 2)); + expect(await rejected.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toEqual([]); + expect(rejected.assetRepository.getMemoryLocationClusters).not.toHaveBeenCalled(); + + const accepted = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 3)); + accepted.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023)]); + expect(await accepted.rule.evaluate({ ownerId: 'user-1', target: TARGET })).toHaveLength(1); + }); + }); + + describe('leap year', () => { + const leapTarget = DateTime.fromISO('2024-02-29', { zone: 'utc' }); + + it('skips a qualifying 2023 trip (Luxon clamps Feb 29 -> Feb 28) but still fires for a qualifying 2020 (leap) trip', async () => { + const { rule, assetRepository } = ruleWith([ + ...probeCityAssets(leapTarget, 2023, 'Rome', 6), + ...probeCityAssets(leapTarget, 2020, 'Rome', 6), + ]); + // 2023 is skipped before any cluster query (invalid anniversary day), so only 2020's home+trip + // pair is ever consumed. + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([cluster('Italy', 'Rome', 8, 3, '2020-02-29T09:00:00Z', '2020-03-02T09:00:00Z')]); + + const result = await rule.evaluate({ ownerId: 'user-1', target: leapTarget }); + + expect(result).toHaveLength(1); + expect(result[0].context).toMatchObject({ year: 2020 }); + expect(assetRepository.getMemoryLocationClusters).toHaveBeenCalledTimes(2); + }); + }); + + describe('current and future years', () => { + it('skips the current year and future-dated assets, keeping only the qualifying past year', async () => { + const { rule, assetRepository } = ruleWith([ + ...probeCityAssets(TARGET, 2026, 'Rome', 6), // current year (target.year) -- must be skipped + ...probeCityAssets(TARGET, 2028, 'Rome', 6), // future year -- must be skipped + ...probeCityAssets(TARGET, 2023, 'Rome', 6), // qualifying past year + ]); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023)]); + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toHaveLength(1); + expect(result[0].context).toMatchObject({ year: 2023 }); + // exactly one probe year was processed -> exactly one home+trip pair + expect(assetRepository.getMemoryLocationClusters).toHaveBeenCalledTimes(2); + }); + }); + + describe('caps', () => { + it('caps candidates at MAX_CANDIDATES (2), keeping the two highest-scoring years', async () => { + expect(MAX_CANDIDATES).toBe(2); + + const { rule, assetRepository } = ruleWith([ + ...probeCityAssets(TARGET, 2024, 'Rome', 6), + ...probeCityAssets(TARGET, 2023, 'Rome', 6), + ...probeCityAssets(TARGET, 2020, 'Rome', 6), + ]); + assetRepository.getMemoryAssetsForLocation.mockResolvedValue([locationAsset('a1', '2023-06-10T09:00:00Z')]); + // processed in probe-year desc order: 2024, 2023, 2020 -- each a home+trip pair. + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) // 2024 home + .mockResolvedValueOnce([romeTrip(2024, { assetCount: 7, dayCount: 2 })]) // 2024 trip -> score 283 + .mockResolvedValueOnce([germanyHome()]) // 2023 home + .mockResolvedValueOnce([romeTrip(2023, { assetCount: 10, dayCount: 3 })]) // 2023 trip -> score 289 + .mockResolvedValueOnce([germanyHome()]) // 2020 home + .mockResolvedValueOnce([romeTrip(2020, { assetCount: 7, dayCount: 2 })]); // 2020 trip -> score 279 + + const result = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(result).toHaveLength(2); + expect(result.map((c) => c.context?.year)).toEqual([2023, 2024]); // sorted desc by score: 289, 283 (2020's 279 dropped) + }); + + it('caps assets at ASSET_CAP (10), matching curateTripAssets own ceiling', async () => { + expect(ASSET_CAP).toBe(10); + + const { rule, assetRepository } = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023, { assetCount: 12, dayCount: 6 })]); + // 12 well-spaced assets across 6 distinct days -> curateTripAssets ladder tops out at 10. + const bigAssetList: MemoryAsset[] = []; + for (let day = 0; day < 6; day++) { + bigAssetList.push( + locationAsset(`rome-${day}-a`, `2023-06-${String(10 + day).padStart(2, '0')}T09:00:00Z`), + locationAsset(`rome-${day}-b`, `2023-06-${String(10 + day).padStart(2, '0')}T15:00:00Z`), + ); + } + assetRepository.getMemoryAssetsForLocation.mockResolvedValue(bigAssetList); + + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(candidate.assetIds).toHaveLength(10); + expect(new Set(candidate.assetIds).size).toBe(10); + }); + }); + + describe('MAX_PROBE_YEARS', () => { + it('evaluates at most MAX_PROBE_YEARS (4) years, asserted via the cluster-query call count', async () => { + expect(MAX_PROBE_YEARS).toBe(4); + + const { rule, assetRepository } = ruleWith([ + ...probeCityAssets(TARGET, 2025, 'Rome', 6), + ...probeCityAssets(TARGET, 2024, 'Rome', 6), + ...probeCityAssets(TARGET, 2023, 'Rome', 6), + ...probeCityAssets(TARGET, 2022, 'Rome', 6), + ...probeCityAssets(TARGET, 2021, 'Rome', 6), + ...probeCityAssets(TARGET, 2020, 'Rome', 6), + ]); + + let callCount = 0; + assetRepository.getMemoryLocationClusters.mockImplementation(() => { + const isHomeCall = callCount % 2 === 0; + callCount++; + return Promise.resolve(isHomeCall ? [germanyHome()] : []); // trip window always empty -> just counting calls + }); + + await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + // 6 qualifying probe years exist, but only the most recent 4 are evaluated -> 2 cluster calls each = 8. + expect(assetRepository.getMemoryLocationClusters).toHaveBeenCalledTimes(8); + }); + }); + + describe('subtitle pluralization', () => { + it('reads "1 year ago" for a trip one year back and "3 years ago" for a trip three years back', async () => { + const oneYearAgo = ruleWith(probeCityAssets(TARGET, 2025, 'Rome', 6)); + oneYearAgo.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2025)]); + const [candidate1] = await oneYearAgo.rule.evaluate({ ownerId: 'user-1', target: TARGET }); + expect(candidate1.subtitle).toBe('1 year ago · 8 photos over 3 days'); + + const threeYearsAgo = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + threeYearsAgo.assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([romeTrip(2023)]); + const [candidate3] = await threeYearsAgo.rule.evaluate({ ownerId: 'user-1', target: TARGET }); + expect(candidate3.subtitle).toBe('3 years ago · 8 photos over 3 days'); + }); + }); + + describe('city null', () => { + it("falls back to 'Your trip to Italy' (country only) when the trip cluster has no city", async () => { + const { rule, assetRepository } = ruleWith(probeCityAssets(TARGET, 2023, 'Rome', 6)); + assetRepository.getMemoryLocationClusters + .mockResolvedValueOnce([germanyHome()]) + .mockResolvedValueOnce([cluster('Italy', null, 8, 3, '2023-06-10T09:00:00Z', '2023-06-12T09:00:00Z')]); + + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target: TARGET }); + + expect(candidate.title).toBe('Your trip to Italy'); + expect(candidate.dedupeKey).toBe('place_day:2023-06-10:italy:'); + }); + }); + + describe('zero assets', () => { + it('returns [] without throwing when the probe returns no assets', async () => { + const { rule, assetRepository } = ruleWith([]); + + await expect(rule.evaluate({ ownerId: 'user-1', target: TARGET })).resolves.toEqual([]); + expect(assetRepository.getMemoryLocationClusters).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/memory-rules/trip-anniversary.rule.ts b/server/src/services/memory-rules/trip-anniversary.rule.ts new file mode 100644 index 0000000000000..66df1996fc4e1 --- /dev/null +++ b/server/src/services/memory-rules/trip-anniversary.rule.ts @@ -0,0 +1,141 @@ +import { DateTime } from 'luxon'; +import { AssetRepository, MemoryPeriodAsset } from 'src/repositories/asset.repository'; +import { dominantBy, recencyBonus } from 'src/services/memory-rules/curation.util'; +import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; +import { curateTripAssets, findTripStartingOn, inferHome, placeKeyOf } from 'src/services/memory-rules/trip.util'; + +export const MIN_PROBE_ASSETS = 3; +export const MIN_PROBE_DOMINANCE = 0.6; +export const MAX_PROBE_YEARS = 4; +export const GAP_DAYS = 5; +export const TRIP_WINDOW_DAYS = 21; +export const MIN_TRIP_ASSETS = 7; +export const MIN_TRIP_DAYS = 2; +export const HOME_BASELINE_DAYS = 90; +export const ASSET_CAP = 10; +export const MAX_CANDIDATES = 2; +export const SCORE_BASE = 260; + +/** A usable place needs a non-blank city (EXIF city is usually null when absent, but can be ''). */ +const hasCity = (asset: MemoryPeriodAsset): boolean => asset.city !== null && asset.city.trim() !== ''; + +/** "Your trip to Rome" — a past trip resurfaced on the anniversary of the day it began. */ +export class TripAnniversaryMemoryRule implements MemoryRule { + readonly id = 'trip_anniversary'; + + constructor( + private assetRepository: Pick< + AssetRepository, + 'getMemoryAssetsForPeriod' | 'getMemoryLocationClusters' | 'getMemoryAssetsForLocation' + >, + ) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { + const probeYears = await this.probeQualifyingYears(ownerId, target); + if (probeYears.length === 0) { + return []; + } + + const mm = String(target.month).padStart(2, '0'); + const dd = String(target.day).padStart(2, '0'); + const candidates: MemoryRuleCandidate[] = []; + + for (const year of probeYears) { + const anniversary = target.set({ year }).startOf('day'); + if (anniversary.day !== target.day || anniversary.month !== target.month) { + // Luxon silently clamps an invalid (year, month, day) combination (e.g. Feb 29 in a + // non-leap year) to the nearest valid date -- detect the clamp and skip the year. + continue; + } + + const homeClusters = await this.assetRepository.getMemoryLocationClusters(ownerId, { + takenAfter: anniversary.minus({ days: HOME_BASELINE_DAYS }).toJSDate(), + takenBefore: anniversary + .minus({ days: GAP_DAYS + 1 }) + .endOf('day') + .toJSDate(), + }); + const home = inferHome(homeClusters); + if (!home) { + continue; + } + + const tripClusters = await this.assetRepository.getMemoryLocationClusters(ownerId, { + takenAfter: anniversary.minus({ days: GAP_DAYS }).toJSDate(), + takenBefore: anniversary.plus({ days: TRIP_WINDOW_DAYS }).endOf('day').toJSDate(), + }); + const cluster = findTripStartingOn(tripClusters, anniversary, home, { + minAssets: MIN_TRIP_ASSETS, + minDays: MIN_TRIP_DAYS, + }); + if (!cluster || !cluster.country) { + continue; + } + + const locationAssets = await this.assetRepository.getMemoryAssetsForLocation(ownerId, { + country: cluster.country, + city: cluster.city, + takenAfter: cluster.firstDate, + takenBefore: cluster.lastDate, + }); + const assetIds = curateTripAssets(locationAssets, ASSET_CAP); + + const yearsAgo = target.year - year; + const placeKey = placeKeyOf(cluster.country, cluster.city); + const placeLabel = cluster.city ? `${cluster.city}, ${cluster.country}` : cluster.country; + + candidates.push({ + ruleId: this.id, + dedupeKey: `place_day:${year}-${mm}-${dd}:${placeKey}`, + title: cluster.city ? `Your trip to ${cluster.city}, ${cluster.country}` : `Your trip to ${cluster.country}`, + subtitle: `${yearsAgo} year${yearsAgo === 1 ? '' : 's'} ago · ${cluster.assetCount} photos over ${cluster.dayCount} days`, + score: SCORE_BASE + cluster.dayCount * 4 + Math.min(cluster.assetCount, 20) + recencyBonus(year, target.year), + assetIds, + memoryAt: DateTime.fromJSDate(cluster.firstDate, { zone: 'utc' }), + visibleForDays: Math.min(Math.max(cluster.dayCount, 3), 7), + context: { + year, + placeKey, + placeLabel, + country: cluster.country, + city: cluster.city, + assetCount: cluster.assetCount, + dayCount: cluster.dayCount, + tripStart: cluster.firstDate.toISOString(), + tripEnd: cluster.lastDate.toISOString(), + }, + }); + } + + return candidates.toSorted((left, right) => right.score - left.score).slice(0, MAX_CANDIDATES); + } + + /** The most recent `MAX_PROBE_YEARS` past years whose on-this-day photos are dominated by one place. */ + private async probeQualifyingYears(ownerId: string, target: DateTime): Promise { + const probeAssets = await this.assetRepository.getMemoryAssetsForPeriod(ownerId, { + months: [target.month], + day: target.day, + takenBefore: target.endOf('day').toJSDate(), + }); + + const byYear = new Map(); + for (const asset of probeAssets) { + if (asset.year >= target.year || !hasCity(asset)) { + continue; + } + const yearAssets = byYear.get(asset.year) ?? []; + yearAssets.push(asset); + byYear.set(asset.year, yearAssets); + } + + const qualifyingYears: number[] = []; + for (const [year, assets] of byYear) { + const dominant = dominantBy(assets, (asset) => placeKeyOf(asset.country, asset.city)); + if (dominant.items.length >= MIN_PROBE_ASSETS && dominant.ratio >= MIN_PROBE_DOMINANCE) { + qualifyingYears.push(year); + } + } + + return qualifyingYears.toSorted((left, right) => right - left).slice(0, MAX_PROBE_YEARS); + } +} diff --git a/server/src/services/memory-rules/trip.util.spec.ts b/server/src/services/memory-rules/trip.util.spec.ts new file mode 100644 index 0000000000000..0963795ed0ae6 --- /dev/null +++ b/server/src/services/memory-rules/trip.util.spec.ts @@ -0,0 +1,245 @@ +import { DateTime } from 'luxon'; +import { MemoryAsset, MemoryLocationCluster } from 'src/repositories/asset.repository'; +import { + BURST_WINDOW_MS, + curateTripAssets, + findTripStartingOn, + HOME_DOMINANCE_RATIO, + inferHome, + isAwayFromHome, + placeKeyOf, + SMALL_TRIP_MAX, + TripThresholds, +} from 'src/services/memory-rules/trip.util'; + +const cluster = ( + country: string | null, + city: string | null, + assetCount: number, + dayCount: number, + firstDate: string, + lastDate: string, +): MemoryLocationCluster => ({ + country, + city, + assetCount, + dayCount, + firstDate: new Date(firstDate), + lastDate: new Date(lastDate), +}); + +const asset = (id: string, iso: string): MemoryAsset => ({ id, localDateTime: new Date(iso) }); + +// 4 days x 3 assets/day, none within the burst window, so all 12 stay as representatives. +// dayCount=4, representativeCount=12 -> getTripTargetSize ladder = 8. +const buildFourDayAssets = (): MemoryAsset[] => [ + asset('d1a', '2023-08-01T09:00:00Z'), + asset('d1b', '2023-08-01T12:00:00Z'), + asset('d1c', '2023-08-01T15:00:00Z'), + asset('d2a', '2023-08-02T09:00:00Z'), + asset('d2b', '2023-08-02T12:00:00Z'), + asset('d2c', '2023-08-02T15:00:00Z'), + asset('d3a', '2023-08-03T09:00:00Z'), + asset('d3b', '2023-08-03T12:00:00Z'), + asset('d3c', '2023-08-03T15:00:00Z'), + asset('d4a', '2023-08-04T09:00:00Z'), + asset('d4b', '2023-08-04T12:00:00Z'), + asset('d4c', '2023-08-04T15:00:00Z'), +]; + +describe('constants', () => { + it('exposes the documented values', () => { + expect(BURST_WINDOW_MS).toBe(2 * 60 * 1000); + expect(SMALL_TRIP_MAX).toBe(6); + expect(HOME_DOMINANCE_RATIO).toBe(1.25); + }); +}); + +describe('placeKeyOf', () => { + it('lowercases country and city', () => { + expect(placeKeyOf('Italy', 'Rome')).toBe('italy:rome'); + expect(placeKeyOf('ITALY', 'ROME')).toBe('italy:rome'); + }); + + it('handles a null country', () => { + expect(placeKeyOf(null, 'Rome')).toBe(':rome'); + }); + + it('handles a null city', () => { + expect(placeKeyOf('Italy', null)).toBe('italy:'); + }); +}); + +describe('inferHome', () => { + it('returns the top cluster when it dominates', () => { + const home = cluster('Germany', 'Berlin', 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + const runnerUp = cluster('France', 'Paris', 5, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(inferHome([home, runnerUp])).toBe(home); + }); + + it("returns null when the top cluster's country is null", () => { + const home = cluster(null, null, 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + expect(inferHome([home])).toBeNull(); + }); + + it('returns null when a different-country runner-up is within the dominance ratio', () => { + const home = cluster('Germany', 'Berlin', 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + // 20 / 1.25 = 16, runner-up at exactly 16 is ambiguous (>=, not >). + const runnerUp = cluster('France', 'Paris', 16, 8, '2023-04-01T00:00:00Z', '2023-04-08T00:00:00Z'); + expect(inferHome([home, runnerUp])).toBeNull(); + }); + + it('returns the top cluster when the runner-up is same-country (not ambiguous)', () => { + const home = cluster('Germany', 'Berlin', 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + const runnerUp = cluster('Germany', 'Munich', 20, 10, '2023-04-01T00:00:00Z', '2023-04-10T00:00:00Z'); + expect(inferHome([home, runnerUp])).toBe(home); + }); + + it('returns null for an empty cluster list', () => { + expect(inferHome([])).toBeNull(); + }); +}); + +describe('isAwayFromHome', () => { + const home = cluster('Germany', 'Berlin', 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + + it('returns true for a different country', () => { + const item = cluster('France', 'Paris', 8, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(isAwayFromHome(item, home)).toBe(true); + }); + + it('returns true for the same country with a different non-null city', () => { + const item = cluster('Germany', 'Munich', 8, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(isAwayFromHome(item, home)).toBe(true); + }); + + it('returns false for the same country and the same city', () => { + const item = cluster('Germany', 'Berlin', 8, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(isAwayFromHome(item, home)).toBe(false); + }); + + it('returns false when the home city is null', () => { + const homeNoCity = cluster('Germany', null, 20, 12, '2023-01-01T00:00:00Z', '2023-03-20T00:00:00Z'); + const item = cluster('Germany', 'Munich', 8, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(isAwayFromHome(item, homeNoCity)).toBe(false); + }); + + it('returns false when the candidate city is null', () => { + const item = cluster('Germany', null, 8, 3, '2023-04-01T00:00:00Z', '2023-04-03T00:00:00Z'); + expect(isAwayFromHome(item, home)).toBe(false); + }); +}); + +describe('findTripStartingOn', () => { + const home = cluster('Germany', 'Berlin', 40, 20, '2020-01-01T00:00:00Z', '2020-12-30T00:00:00Z'); + const thresholds: TripThresholds = { minAssets: 7, minDays: 2 }; + const anniversary = DateTime.fromISO('2023-06-14', { zone: 'utc' }); + + it('picks a cluster whose firstDate is on the anniversary day and meets both thresholds', () => { + const trip = cluster('France', 'Paris', 8, 3, '2023-06-14T09:00:00Z', '2023-06-16T00:00:00Z'); + expect(findTripStartingOn([trip], anniversary, home, thresholds)).toBe(trip); + }); + + it('rejects a firstDate one day before the anniversary', () => { + const trip = cluster('France', 'Paris', 8, 3, '2023-06-13T09:00:00Z', '2023-06-16T00:00:00Z'); + expect(findTripStartingOn([trip], anniversary, home, thresholds)).toBeNull(); + }); + + it('rejects a firstDate one day after the anniversary', () => { + const trip = cluster('France', 'Paris', 8, 3, '2023-06-15T09:00:00Z', '2023-06-16T00:00:00Z'); + expect(findTripStartingOn([trip], anniversary, home, thresholds)).toBeNull(); + }); + + it('qualifies a firstDate of 23:30Z on the anniversary day (UTC calendar day, not instant)', () => { + const trip = cluster('France', 'Paris', 8, 3, '2023-06-14T23:30:00Z', '2023-06-16T00:00:00Z'); + expect(findTripStartingOn([trip], anniversary, home, thresholds)).toBe(trip); + }); + + it('rejects assetCount one below minAssets and accepts exactly minAssets', () => { + const tooFew = cluster('France', 'Paris', 6, 2, '2023-06-14T09:00:00Z', '2023-06-15T00:00:00Z'); + expect(findTripStartingOn([tooFew], anniversary, home, thresholds)).toBeNull(); + + const enough = cluster('France', 'Paris', 7, 2, '2023-06-14T09:00:00Z', '2023-06-15T00:00:00Z'); + expect(findTripStartingOn([enough], anniversary, home, thresholds)).toBe(enough); + }); + + it('rejects dayCount of 1 and accepts dayCount of 2', () => { + const oneDay = cluster('France', 'Paris', 7, 1, '2023-06-14T09:00:00Z', '2023-06-14T20:00:00Z'); + expect(findTripStartingOn([oneDay], anniversary, home, thresholds)).toBeNull(); + + const twoDays = cluster('France', 'Paris', 7, 2, '2023-06-14T09:00:00Z', '2023-06-15T20:00:00Z'); + expect(findTripStartingOn([twoDays], anniversary, home, thresholds)).toBe(twoDays); + }); + + it('rejects a cluster that meets thresholds but is not away from home', () => { + const sameCity = cluster('Germany', 'Berlin', 8, 3, '2023-06-14T09:00:00Z', '2023-06-16T00:00:00Z'); + expect(findTripStartingOn([sameCity], anniversary, home, thresholds)).toBeNull(); + }); + + it('picks the higher assetCount when two clusters qualify the same day', () => { + const weaker = cluster('France', 'Paris', 8, 2, '2023-06-14T09:00:00Z', '2023-06-15T20:00:00Z'); + const stronger = cluster('Italy', 'Rome', 10, 2, '2023-06-14T09:00:00Z', '2023-06-15T20:00:00Z'); + expect(findTripStartingOn([weaker, stronger], anniversary, home, thresholds)).toBe(stronger); + expect(findTripStartingOn([stronger, weaker], anniversary, home, thresholds)).toBe(stronger); + }); + + it('breaks equal-assetCount ties by the lower placeKeyOf, regardless of input order', () => { + const italy = cluster('Italy', 'Rome', 9, 2, '2023-06-14T09:00:00Z', '2023-06-15T20:00:00Z'); + const france = cluster('France', 'Paris', 9, 2, '2023-06-14T09:00:00Z', '2023-06-15T20:00:00Z'); + // placeKeyOf: 'france:paris' < 'italy:rome' + expect(findTripStartingOn([italy, france], anniversary, home, thresholds)).toBe(france); + expect(findTripStartingOn([france, italy], anniversary, home, thresholds)).toBe(france); + }); + + it('returns null for an empty cluster list', () => { + expect(findTripStartingOn([], anniversary, home, thresholds)).toBeNull(); + }); +}); + +describe('curateTripAssets', () => { + it('collapses assets within the 2-minute burst window into a single representative', () => { + const assets = [ + asset('a1', '2023-06-01T09:00:00Z'), + asset('a2', '2023-06-01T09:01:00Z'), // 60s after a1: collapsed + asset('a3', '2023-06-01T09:02:30Z'), // 90s after a2: collapsed + asset('a4', '2023-06-01T09:10:00Z'), // 7.5min after a3: kept + asset('a5', '2023-06-01T09:20:00Z'), // 10min after a4: kept + ]; + expect(curateTripAssets(assets, 10)).toEqual(['a1', 'a4', 'a5']); + }); + + it('returns all representatives when at or below SMALL_TRIP_MAX after collapsing', () => { + const assets = [ + asset('b1', '2023-07-01T09:00:00Z'), + asset('b2', '2023-07-02T09:00:00Z'), + asset('b3', '2023-07-03T09:00:00Z'), + asset('b4', '2023-07-04T09:00:00Z'), + asset('b5', '2023-07-05T09:00:00Z'), + asset('b6', '2023-07-06T09:00:00Z'), + ]; + expect(assets).toHaveLength(SMALL_TRIP_MAX); + expect(curateTripAssets(assets, 10)).toEqual(['b1', 'b2', 'b3', 'b4', 'b5', 'b6']); + }); + + it('covers every distinct day before topping up remaining slots (cap above the ladder)', () => { + // ladder = min(cap=10, 8) = 8: one middle-of-day pick per day (4), then 4 more evenly + // spaced from what's left. + const result = curateTripAssets(buildFourDayAssets(), 10); + expect(result).toEqual(['d1a', 'd1b', 'd2a', 'd2b', 'd3b', 'd3c', 'd4b', 'd4c']); + }); + + it('never exceeds the cap argument, even when the cap is below the internal ladder', () => { + // ladder would be 8, but cap=4 clamps it: one middle-of-day pick per day, no topping up. + const result = curateTripAssets(buildFourDayAssets(), 4); + expect(result).toEqual(['d1b', 'd2b', 'd3b', 'd4b']); + expect(result.length).toBeLessThanOrEqual(4); + }); + + it('produces a chronologically sorted, duplicate-free result', () => { + const assets = buildFourDayAssets(); + const result = curateTripAssets(assets, 10); + const times = result.map((id) => assets.find((a) => a.id === id)!.localDateTime.getTime()); + expect(times).toEqual([...times].sort((left, right) => left - right)); + expect(new Set(result).size).toBe(result.length); + }); +}); diff --git a/server/src/services/memory-rules/trip.util.ts b/server/src/services/memory-rules/trip.util.ts new file mode 100644 index 0000000000000..69fadc9a9e2cb --- /dev/null +++ b/server/src/services/memory-rules/trip.util.ts @@ -0,0 +1,143 @@ +import { DateTime } from 'luxon'; +import { MemoryAsset, MemoryLocationCluster } from 'src/repositories/asset.repository'; +import { pickEvenlySpaced } from 'src/services/memory-rules/curation.util'; + +export const BURST_WINDOW_MS = 2 * 60 * 1000; +export const SMALL_TRIP_MAX = 6; +export const HOME_DOMINANCE_RATIO = 1.25; + +export interface TripThresholds { + minAssets: number; + minDays: number; +} + +/** Canonical place key. BOTH place-based rules must use this (spec §3.3). */ +export const placeKeyOf = (country: string | null, city: string | null): string => + `${country ?? ''}:${city ?? ''}`.toLowerCase(); + +/** Top cluster, or null when it has no country or a different-country runner-up is within the ratio. */ +export const inferHome = (clusters: MemoryLocationCluster[]): MemoryLocationCluster | null => { + const [home, runnerUp] = clusters; + if (!home?.country) { + return null; + } + + const isAmbiguousHome = + !!runnerUp && runnerUp.country !== home.country && runnerUp.assetCount >= home.assetCount / HOME_DOMINANCE_RATIO; + if (isAmbiguousHome) { + return null; + } + + return home; +}; + +/** Away from home when the country differs, or the country matches but both cities are known and differ. */ +export const isAwayFromHome = (item: MemoryLocationCluster, home: MemoryLocationCluster): boolean => { + if (item.country !== home.country) { + return true; + } + + return !!home.city && !!item.city && item.city !== home.city; +}; + +/** + * The strongest cluster whose UTC calendar day of `firstDate` equals `anniversary`'s UTC day, + * is away from home, and meets both thresholds. Ties break on higher assetCount, then on + * placeKeyOf ascending (deterministic). Returns null when none qualify or `clusters` is empty. + */ +export const findTripStartingOn = ( + clusters: MemoryLocationCluster[], + anniversary: DateTime, + home: MemoryLocationCluster, + thresholds: TripThresholds, +): MemoryLocationCluster | null => { + const qualifying = clusters.filter( + (item) => + DateTime.fromJSDate(item.firstDate, { zone: 'utc' }).hasSame(anniversary, 'day') && + isAwayFromHome(item, home) && + item.assetCount >= thresholds.minAssets && + item.dayCount >= thresholds.minDays, + ); + + const sorted = qualifying.toSorted((left, right) => { + if (right.assetCount !== left.assetCount) { + return right.assetCount - left.assetCount; + } + + const leftKey = placeKeyOf(left.country, left.city); + const rightKey = placeKeyOf(right.country, right.city); + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + + return sorted[0] ?? null; +}; + +const collapseBurstAssets = (assets: MemoryAsset[]): MemoryAsset[] => { + const representatives: MemoryAsset[] = []; + let previous: MemoryAsset | undefined; + + for (const asset of assets) { + if (!previous || asset.localDateTime.getTime() - previous.localDateTime.getTime() > BURST_WINDOW_MS) { + representatives.push(asset); + } + previous = asset; + } + + return representatives; +}; + +const groupAssetsByDay = (assets: MemoryAsset[]): MemoryAsset[][] => { + const byDay = new Map(); + + for (const asset of assets) { + const dayKey = DateTime.fromJSDate(asset.localDateTime, { zone: 'utc' }).toISODate(); + const dayAssets = byDay.get(dayKey!) ?? []; + dayAssets.push(asset); + byDay.set(dayKey!, dayAssets); + } + + return [...byDay.values()]; +}; + +const getTripTargetSize = (dayCount: number, representativeCount: number): number => { + if (representativeCount <= SMALL_TRIP_MAX) { + return representativeCount; + } + + if (dayCount >= 5 || representativeCount >= 18) { + return 10; + } + + if (dayCount >= 4 || representativeCount >= 12) { + return 8; + } + + return 7; +}; + +const pickDayCoverage = (dayBuckets: MemoryAsset[][], targetSize: number): MemoryAsset[] => { + const buckets = dayBuckets.length <= targetSize ? dayBuckets : pickEvenlySpaced(dayBuckets, targetSize); + return buckets.map((assets) => assets[Math.floor((assets.length - 1) / 2)]!); +}; + +/** Burst-collapse + per-day coverage sampling, capped at `cap`. Chronological, duplicate-free. */ +export const curateTripAssets = (assets: MemoryAsset[], cap: number): string[] => { + const representatives = collapseBurstAssets(assets); + if (representatives.length <= SMALL_TRIP_MAX) { + return representatives.map(({ id }) => id); + } + + const dayBuckets = groupAssetsByDay(representatives); + const targetSize = Math.min(cap, getTripTargetSize(dayBuckets.length, representatives.length)); + const selected = pickDayCoverage(dayBuckets, targetSize); + const selectedIds = new Set(selected.map(({ id }) => id)); + + if (selected.length < targetSize) { + const remaining = representatives.filter(({ id }) => !selectedIds.has(id)); + selected.push(...pickEvenlySpaced(remaining, targetSize - selected.length)); + } + + return [...selected] + .toSorted((left, right) => left.localDateTime.getTime() - right.localDateTime.getTime()) + .map(({ id }) => id); +}; diff --git a/server/src/services/memory-rules/video-moments.rule.spec.ts b/server/src/services/memory-rules/video-moments.rule.spec.ts new file mode 100644 index 0000000000000..bacae110ae195 --- /dev/null +++ b/server/src/services/memory-rules/video-moments.rule.spec.ts @@ -0,0 +1,237 @@ +import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; +import { MemoryPeriodAsset } from 'src/repositories/asset.repository'; +import { VideoMomentsMemoryRule } from 'src/services/memory-rules/video-moments.rule'; + +const target = DateTime.fromISO('2026-07-08', { zone: 'utc' }); + +const video = ( + id: string, + year: number, + hour: number, + options: { isFavorite?: boolean; duration?: number | null } = {}, +): MemoryPeriodAsset => ({ + id, + localDateTime: DateTime.fromObject({ year, month: 7, day: 1 }, { zone: 'utc' }).plus({ hours: hour }).toJSDate(), + year, + country: null, + city: null, + isFavorite: options.isFavorite ?? false, + type: AssetType.Video, + duration: options.duration === undefined ? 60_000 : options.duration, +}); + +/** 9 in-band videos, 3 of them favourites (indices 2, 5, 8), all in 2023. */ +const workedExampleAssets = (): MemoryPeriodAsset[] => + Array.from({ length: 9 }, (_, index) => + video(`v-2023-${index}`, 2023, index, { isFavorite: [2, 5, 8].includes(index) }), + ); + +const videosForYear = (year: number): MemoryPeriodAsset[] => + Array.from({ length: 5 }, (_, index) => video(`v-${year}-${index}`, year, index)); + +const ruleWith = (assets: MemoryPeriodAsset[]) => { + const assetRepository = { getMemoryAssetsForPeriod: vi.fn().mockResolvedValue(assets) }; + return { rule: new VideoMomentsMemoryRule(assetRepository as never), assetRepository }; +}; + +describe(VideoMomentsMemoryRule.name, () => { + describe('trigger day gating', () => { + it('emits nothing and never queries on days 1, 7, 9, 15, 22', async () => { + for (const day of [1, 7, 9, 15, 22]) { + const { rule, assetRepository } = ruleWith([]); + const result = await rule.evaluate({ + ownerId: 'user-1', + target: DateTime.fromISO(`2026-07-${String(day).padStart(2, '0')}`, { zone: 'utc' }), + }); + expect(result).toEqual([]); + expect(assetRepository.getMemoryAssetsForPeriod).not.toHaveBeenCalled(); + } + }); + + it('fires on day 8', async () => { + const { rule, assetRepository } = ruleWith(workedExampleAssets()); + const result = await rule.evaluate({ ownerId: 'user-1', target }); + expect(result).toHaveLength(1); + expect(assetRepository.getMemoryAssetsForPeriod).toHaveBeenCalled(); + }); + }); + + describe('given the worked example (9 in-band videos, 3 favourites, year 2023)', () => { + it('fires with the pinned title/subtitle/dedupeKey/visibleForDays/ruleId', async () => { + const { rule, assetRepository } = ruleWith(workedExampleAssets()); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(assetRepository.getMemoryAssetsForPeriod).toHaveBeenCalledWith('user-1', { + months: [7], + type: AssetType.Video, + takenBefore: target.endOf('day').toJSDate(), + }); + expect(candidate).toMatchObject({ + ruleId: 'video_moments', + dedupeKey: 'video_moments:2023-07', + title: 'Video moments from July 2023', + subtitle: '9 videos', + visibleForDays: 5, + context: { year: 2023, month: 7, count: 9, favoriteCount: 3 }, + }); + expect(candidate.assetIds).toHaveLength(8); + }); + + it('computes the worked score: 60 + min(9,15)*2 + min(3,10)*3 + recencyBonus(2023,2026) = 94', async () => { + const { rule } = ruleWith(workedExampleAssets()); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + expect(candidate.score).toBe(94); + }); + }); + + describe('duration band filter', () => { + it('excludes 2_999ms, 180_001ms and null; includes the 3_000ms and 180_000ms boundaries', async () => { + const assets: MemoryPeriodAsset[] = [ + video('clear-1', 2023, 0), + video('boundary-2999', 2023, 1, { duration: 2999 }), + video('boundary-3000', 2023, 2, { duration: 3000 }), + video('boundary-180000', 2023, 3, { duration: 180_000 }), + video('boundary-180001', 2023, 4, { duration: 180_001 }), + video('boundary-null', 2023, 5, { duration: null }), + video('clear-2', 2023, 6), + video('clear-3', 2023, 7), + ]; + const { rule } = ruleWith(assets); + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate.subtitle).toBe('5 videos'); + expect(candidate.context).toMatchObject({ count: 5 }); + expect(candidate.assetIds).toEqual(['clear-1', 'boundary-3000', 'boundary-180000', 'clear-2', 'clear-3']); + }); + }); + + describe('the MIN_ASSETS threshold boundary', () => { + it('skips a year with 2 survivors but includes exactly 3', async () => { + const two = await ruleWith([video('a', 2023, 0), video('b', 2023, 1)]).rule.evaluate({ + ownerId: 'user-1', + target, + }); + expect(two).toEqual([]); + + const three = await ruleWith([video('a', 2023, 0), video('b', 2023, 1), video('c', 2023, 2)]).rule.evaluate({ + ownerId: 'user-1', + target, + }); + expect(three).toHaveLength(1); + }); + }); + + describe('given 4 favourites and 10 non-favourites in band', () => { + it('selects all favourites plus 4 evenly-spaced others, sorted chronologically', async () => { + const others = Array.from({ length: 10 }, (_, index) => video(`o${index}`, 2023, index)); + const favourites = Array.from({ length: 4 }, (_, index) => + video(`f${index}`, 2023, 10 + index, { isFavorite: true }), + ); + const { rule } = ruleWith([...others, ...favourites]); + + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate.assetIds).toEqual(['o0', 'o3', 'o6', 'o9', 'f0', 'f1', 'f2', 'f3']); + }); + }); + + describe('given 12 favourites (exceeding ASSET_CAP)', () => { + it('selects exactly 8, evenly spaced', async () => { + const favourites = Array.from({ length: 12 }, (_, index) => + video(`f${index}`, 2023, index, { isFavorite: true }), + ); + const { rule } = ruleWith(favourites); + + const [candidate] = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate.assetIds).toEqual(['f0', 'f2', 'f3', 'f5', 'f6', 'f8', 'f9', 'f11']); + }); + }); + + describe('the favourite bonus cap', () => { + it('scores 20 favourites the same as 10 favourites + 10 non-favourites (both count=20)', async () => { + const allFavourites = Array.from({ length: 20 }, (_, index) => + video(`a${index}`, 2024, index, { isFavorite: true }), + ); + const mixed = [ + ...Array.from({ length: 10 }, (_, index) => video(`m${index}`, 2024, index, { isFavorite: true })), + ...Array.from({ length: 10 }, (_, index) => video(`n${index}`, 2024, 10 + index)), + ]; + + const [a] = await ruleWith(allFavourites).rule.evaluate({ ownerId: 'user-1', target }); + const [b] = await ruleWith(mixed).rule.evaluate({ ownerId: 'user-1', target }); + + // 60 + min(20,15)*2=30 + min(favoriteCount,10)*3=30 + recencyBonus(2024,2026)=8 = 128, in both cases + expect(a.score).toBe(128); + expect(b.score).toBe(128); + }); + }); + + describe('given 12 in-band videos (exceeding ASSET_CAP)', () => { + it('reports the true count in the subtitle while assetIds stays capped at 8', async () => { + const assets = Array.from({ length: 12 }, (_, index) => video(`v${index}`, 2022, index)); + const [candidate] = await ruleWith(assets).rule.evaluate({ ownerId: 'user-1', target }); + + expect(candidate.subtitle).toBe('12 videos'); + expect(candidate.assetIds).toHaveLength(8); + }); + }); + + describe('subtitle pluralization', () => { + // NOTE: MIN_ASSETS = 3 means a year with only 1 in-band survivor never fires (see the + // MIN_ASSETS boundary case above), so the singular branch of the subtitle ternary is + // unreachable through evaluate(). We pin the plural form at the MIN_ASSETS boundary (3) + // and at a higher count (6) to lock down the exact interpolation. + it('reads "3 videos" at the MIN_ASSETS boundary and "6 videos" at a higher count', async () => { + const three = await ruleWith([video('a', 2023, 0), video('b', 2023, 1), video('c', 2023, 2)]).rule.evaluate({ + ownerId: 'user-1', + target, + }); + expect(three[0].subtitle).toBe('3 videos'); + + const six = await ruleWith( + Array.from({ length: 6 }, (_, index) => video(`v${index}`, 2023, index)), + ).rule.evaluate({ + ownerId: 'user-1', + target, + }); + expect(six[0].subtitle).toBe('6 videos'); + }); + }); + + describe('given qualifying videos in more years than MAX_YEARS', () => { + it('skips the current year and caps candidates at 3, newest first', async () => { + const { rule } = ruleWith([ + ...videosForYear(2021), + ...videosForYear(2022), + ...videosForYear(2023), + ...videosForYear(2024), + ...videosForYear(2026), + ]); + + const result = await rule.evaluate({ ownerId: 'user-1', target }); + + expect(result).toHaveLength(3); + expect(result.map((c) => c.context?.year)).toEqual([2024, 2023, 2022]); + }); + }); + + describe('repository call', () => { + it('passes type: AssetType.Video to getMemoryAssetsForPeriod', async () => { + const { rule, assetRepository } = ruleWith([]); + await rule.evaluate({ ownerId: 'user-1', target }); + expect(assetRepository.getMemoryAssetsForPeriod).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ type: AssetType.Video }), + ); + }); + }); + + describe('given zero assets from the repository', () => { + it('returns [] without throwing', async () => { + const { rule } = ruleWith([]); + await expect(rule.evaluate({ ownerId: 'user-1', target })).resolves.toEqual([]); + }); + }); +}); diff --git a/server/src/services/memory-rules/video-moments.rule.ts b/server/src/services/memory-rules/video-moments.rule.ts new file mode 100644 index 0000000000000..1d3d2b6944bd5 --- /dev/null +++ b/server/src/services/memory-rules/video-moments.rule.ts @@ -0,0 +1,89 @@ +import { DateTime } from 'luxon'; +import { AssetType } from 'src/enum'; +import { AssetRepository, MemoryPeriodAsset } from 'src/repositories/asset.repository'; +import { medianTime, monthName, pickEvenlySpaced, recencyBonus } from 'src/services/memory-rules/curation.util'; +import { MemoryRule, MemoryRuleCandidate, MemoryRuleContext } from 'src/services/memory-rules/memory-rule.interface'; + +export const TRIGGER_DAY = 8; +export const MIN_DURATION_MS = 3000; +export const MAX_DURATION_MS = 180_000; +export const MIN_ASSETS = 3; +export const MAX_YEARS = 3; +export const ASSET_CAP = 8; +export const VISIBLE_FOR_DAYS = 5; +export const MAX_FAVORITE_BONUS = 10; +export const SCORE_BASE = 60; + +const byTime = (left: MemoryPeriodAsset, right: MemoryPeriodAsset): number => + left.localDateTime.getTime() - right.localDateTime.getTime(); + +const inDurationBand = (asset: MemoryPeriodAsset): boolean => + asset.duration !== null && asset.duration >= MIN_DURATION_MS && asset.duration <= MAX_DURATION_MS; + +/** "Video moments from July 2023" — videos filmed in this calendar month in a past year. */ +export class VideoMomentsMemoryRule implements MemoryRule { + readonly id = 'video_moments'; + + constructor(private assetRepository: Pick) {} + + async evaluate({ ownerId, target }: MemoryRuleContext): Promise { + if (target.day !== TRIGGER_DAY) { + return []; + } + + const month = target.month; + const assets = await this.assetRepository.getMemoryAssetsForPeriod(ownerId, { + months: [month], + type: AssetType.Video, + takenBefore: target.endOf('day').toJSDate(), + }); + + const byYear = new Map(); + for (const asset of assets) { + if (asset.year >= target.year || !inDurationBand(asset)) { + continue; + } + const yearAssets = byYear.get(asset.year) ?? []; + yearAssets.push(asset); + byYear.set(asset.year, yearAssets); + } + + const mm = String(month).padStart(2, '0'); + const candidates: MemoryRuleCandidate[] = []; + + for (const [year, survivors] of byYear) { + if (survivors.length < MIN_ASSETS) { + continue; + } + + const count = survivors.length; + const favourites = survivors.filter((asset) => asset.isFavorite).sort(byTime); + const others = survivors.filter((asset) => !asset.isFavorite).sort(byTime); + const favoriteCount = favourites.length; + + const selected = + favourites.length >= ASSET_CAP + ? pickEvenlySpaced(favourites, ASSET_CAP) + : [...favourites, ...pickEvenlySpaced(others, ASSET_CAP - favourites.length)]; + selected.sort(byTime); + + candidates.push({ + ruleId: this.id, + dedupeKey: `video_moments:${year}-${mm}`, + title: `Video moments from ${monthName(month)} ${year}`, + subtitle: `${count} video${count === 1 ? '' : 's'}`, + score: + SCORE_BASE + + Math.min(count, 15) * 2 + + Math.min(favoriteCount, MAX_FAVORITE_BONUS) * 3 + + recencyBonus(year, target.year), + assetIds: selected.map((asset) => asset.id), + memoryAt: DateTime.fromJSDate(medianTime(selected), { zone: 'utc' }), + context: { year, month, count, favoriteCount }, + visibleForDays: VISIBLE_FOR_DAYS, + }); + } + + return candidates.toSorted((left, right) => right.score - left.score).slice(0, MAX_YEARS); + } +} diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 468807b3c6f44..873c57dbe9ea9 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { SystemConfig } from 'src/config'; import { Memory } from 'src/database'; import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -15,6 +16,8 @@ import { isMemoryTypeEnabledForUser, } from 'src/services/memory-rules/memory-type.metadata'; import { createMemoryRules } from 'src/services/memory-rules/memory-type.registry'; +import { MemoryThemeSearchAdapter } from 'src/services/memory-rules/theme-search.adapter'; +import { ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { getPreferences } from 'src/utils/preferences'; @@ -112,11 +115,34 @@ export class MemoryService extends BaseService { ); } - private getMemoryRules(enabledKeys: Iterable): MemoryRule[] { + private themeSearchPort?: ThemeSearchPort; + + /** Overridable seam: the medium test subclasses MemoryService to inject a stub. */ + protected createThemeSearchPort(): ThemeSearchPort { + return new MemoryThemeSearchAdapter( + this.machineLearningRepository, + this.searchRepository, + () => this.getConfig({ withCache: true }), + this.logger, + ); + } + + /** + * Memoized per-service-instance: the adapter holds the embedding cache, so a theme is encoded + * once per process rather than once per user per night. + */ + private getThemeSearchPort(): ThemeSearchPort { + this.themeSearchPort ??= this.createThemeSearchPort(); + return this.themeSearchPort; + } + + private getMemoryRules(enabledKeys: Iterable, memories: SystemConfig['memories']): MemoryRule[] { return createMemoryRules(enabledKeys, { personRepository: this.personRepository, assetRepository: this.assetRepository, memoryRepository: this.memoryRepository, + themeSearchPort: this.getThemeSearchPort(), + memories, }); } @@ -199,8 +225,9 @@ export class MemoryService extends BaseService { enabledRuleKeys: Iterable, ): Promise { const candidates: MemoryRuleCandidate[] = []; + const { memories } = await this.getConfig({ withCache: true }); - for (const rule of this.getMemoryRules(enabledRuleKeys)) { + for (const rule of this.getMemoryRules(enabledRuleKeys, memories)) { try { candidates.push(...(await rule.evaluate({ ownerId, target }))); } catch (error) { diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index d71289b763c58..662da6b8f9cc6 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -202,6 +202,10 @@ describe(ServerService.name, () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ], }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -225,6 +229,10 @@ describe(ServerService.name, () => { 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ]); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index b785822dc4e7a..84512fe428e98 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -166,6 +166,8 @@ const updatedConfig = Object.freeze({ birthday: true, recentTrips: true, types: {}, + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, }, reverseGeocoding: { enabled: true, @@ -451,6 +453,22 @@ describe(SystemConfigService.name, () => { }); }); + it('should default themeMaxDistance to 0.75', async () => { + mocks.systemMetadata.get.mockResolvedValue({}); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ + memories: { themeMaxDistance: 0.75 }, + }); + }); + + it('should default personThrowbackDormancyMonths to 6', async () => { + mocks.systemMetadata.get.mockResolvedValue({}); + + await expect(sut.getSystemConfig()).resolves.toMatchObject({ + memories: { personThrowbackDormancyMonths: 6 }, + }); + }); + it('should accept a per-type memory availability override', async () => { mocks.systemMetadata.get.mockResolvedValue({ memories: { types: { recent_trip: false } } }); diff --git a/server/src/utils/preferences.spec.ts b/server/src/utils/preferences.spec.ts index 7f08d0c805b47..ef121b15e9e51 100644 --- a/server/src/utils/preferences.spec.ts +++ b/server/src/utils/preferences.spec.ts @@ -24,6 +24,10 @@ const getDefaultPreferences = (): UserPreferences => ({ on_this_day_place: true, season_recap: true, people_together: true, + video_moments: true, + trip_anniversary: true, + themed: true, + person_throwback: true, }, }, people: { @@ -179,6 +183,10 @@ describe('getPreferences', () => { on_this_day_place: true, season_recap: true, people_together: true, + video_moments: true, + trip_anniversary: true, + themed: true, + person_throwback: true, }); }); diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index ed469d5a05760..724d1a9c422b9 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -39,6 +39,8 @@ const seedPeriodAsset = async ( withPreview = true, visibility = AssetVisibility.Timeline, deleted = false, + type = AssetType.Image, + duration = null, }: { localDateTime: Date; country?: string | null; @@ -47,6 +49,8 @@ const seedPeriodAsset = async ( withPreview?: boolean; visibility?: AssetVisibility; deleted?: boolean; + type?: AssetType; + duration?: number | null; }, ) => { const { asset } = await ctx.newAsset({ @@ -55,6 +59,8 @@ const seedPeriodAsset = async ( localDateTime, isFavorite, deletedAt: deleted ? new Date() : null, + type, + duration, }); await Promise.all([ ctx.newExif({ assetId: asset.id, country, city }), @@ -1039,6 +1045,125 @@ describe(AssetRepository.name, () => { expect(result.map((r) => r.id)).toEqual([first.id, second.id]); }); + + it('returns type and duration on each row', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const image = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-10T12:00:00Z'), + type: AssetType.Image, + duration: null, + }); + const video = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-11T12:00:00Z'), + type: AssetType.Video, + duration: 5000, + }); + + const result = await sut.getMemoryAssetsForPeriod(user.id, { + months: [7], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: image.id, type: AssetType.Image, duration: null }), + expect.objectContaining({ id: video.id, type: AssetType.Video, duration: 5000 }), + ]), + ); + }); + + it('type: AssetType.Video returns only videos', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-10T12:00:00Z'), + type: AssetType.Image, + duration: null, + }); + const video1 = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-11T12:00:00Z'), + type: AssetType.Video, + duration: 5000, + }); + const video2 = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-12T12:00:00Z'), + type: AssetType.Video, + duration: 8000, + }); + + const result = await sut.getMemoryAssetsForPeriod(user.id, { + months: [7], + type: AssetType.Video, + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.id).toSorted()).toEqual([video1.id, video2.id].toSorted()); + for (const row of result) { + expect(row.type).toBe(AssetType.Video); + } + }); + + it('omitting type returns both images and videos', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-10T12:00:00Z'), + type: AssetType.Image, + duration: null, + }); + await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-11T12:00:00Z'), + type: AssetType.Video, + duration: 5000, + }); + await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-12T12:00:00Z'), + type: AssetType.Video, + duration: 8000, + }); + + const result = await sut.getMemoryAssetsForPeriod(user.id, { + months: [7], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result).toHaveLength(3); + }); + + it('returns a video with a null duration', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const video = await seedPeriodAsset(ctx, user.id, { + localDateTime: new Date('2023-07-10T12:00:00Z'), + type: AssetType.Video, + duration: null, + }); + + const result = await sut.getMemoryAssetsForPeriod(user.id, { + months: [7], + takenBefore: new Date('2026-01-01T00:00:00Z'), + }); + + expect(result.map((r) => r.id)).toEqual([video.id]); + expect(result[0].type).toBe(AssetType.Video); + expect(result[0].duration).toBeNull(); + }); + + it('includes an asset exactly on takenBefore', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const takenBefore = new Date('2023-07-10T12:00:00Z'); + const asset = await seedPeriodAsset(ctx, user.id, { localDateTime: takenBefore }); + + const result = await sut.getMemoryAssetsForPeriod(user.id, { + months: [7], + takenBefore, + }); + + expect(result.map((r) => r.id)).toEqual([asset.id]); + }); }); describe('getMemoryFacesForPeriod', () => { diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 4d75a4bfd8466..91319e776ef79 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -1,6 +1,6 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; -import { AssetFileType, MemoryType } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility, MemoryType, UserMetadataKey } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -12,6 +12,7 @@ import { PersonRepository } from 'src/repositories/person.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; +import { ThemeSearchAsset, ThemeSearchPort } from 'src/services/memory-rules/theme-search.port'; import { MemoryService } from 'src/services/memory.service'; import { newMediumService } from 'test/medium.factory'; import { factory } from 'test/small.factory'; @@ -46,26 +47,81 @@ const seedRuleAsset = async ( city = null, country = null, isFavorite = false, - }: { ownerId: string; localDateTime: string; city?: string | null; country?: string | null; isFavorite?: boolean }, + type = AssetType.Image, + duration = null, + visibility = AssetVisibility.Timeline, + withPreview = true, + }: { + ownerId: string; + localDateTime: string; + city?: string | null; + country?: string | null; + isFavorite?: boolean; + type?: AssetType; + duration?: number | null; + visibility?: AssetVisibility; + withPreview?: boolean; + }, ) => { const assetRepo = ctx.get(AssetRepository); - const { asset } = await ctx.newAsset({ ownerId, localDateTime, isFavorite }); + const { asset } = await ctx.newAsset({ ownerId, localDateTime, isFavorite, type, duration, visibility }); + const files = [{ assetId: asset.id, type: AssetFileType.Thumbnail, path: `/thumb-${asset.id}.jpg` }]; + if (withPreview) { + files.push({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview-${asset.id}.jpg` }); + } await Promise.all([ ctx.newExif({ assetId: asset.id, city, country }), ctx.newJobStatus({ assetId: asset.id }), - assetRepo.upsertFiles([ - { assetId: asset.id, type: AssetFileType.Preview, path: `/preview-${asset.id}.jpg` }, - { assetId: asset.id, type: AssetFileType.Thumbnail, path: `/thumb-${asset.id}.jpg` }, - ]), + assetRepo.upsertFiles(files), ]); return asset; }; +/** + * A dormant person with a qualifying chapter: 4 assets in Jan 2020 (padding the lifetime total to + * MIN_TOTAL_ASSETS (10) without competing for chapter density) plus a 6-day, one-photo-per-day + * chapter in Aug 2023 (exactly MIN_CHAPTER_ASSETS (6), the only dense window). Both clusters sit + * well before any dormancy cutoff used in this file's target dates (2026-02-13 or later). + */ +const seedDormantPersonChapter = async ( + ctx: ReturnType['ctx'], + { + ownerId, + name, + overrides = {}, + }: { ownerId: string; name: string; overrides?: { type?: string; isHidden?: boolean } }, +) => { + const { person } = await ctx.newPerson({ ownerId, name, ...overrides }); + + for (let hour = 10; hour < 14; hour++) { + const asset = await seedRuleAsset(ctx, { ownerId, localDateTime: `2020-01-10T${hour}:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: person.id, isVisible: true }); + } + + const chapterAssetIds: string[] = []; + for (let day = 5; day <= 10; day++) { + const asset = await seedRuleAsset(ctx, { ownerId, localDateTime: `2023-08-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: person.id, isVisible: true }); + chapterAssetIds.push(asset.id); + } + + return { person, chapterAssetIds }; +}; + describe(MemoryService.name, () => { beforeEach(async () => { defaultDatabase = await getKyselyDB(); }); + // Each test opens its own connection pool (up to 10 conns) via getKyselyDB() and nothing + // previously closed it, so pools accumulated for the lifetime of the whole file. With enough + // tests in one file that exhausts Postgres's max_connections ("sorry, too many clients + // already"). Close the pool after every test so at most one test's connections are open at a + // time. + afterEach(async () => { + await defaultDatabase.destroy(); + }); + describe('create', () => { it('should create a new memory', async () => { const { sut, ctx } = setup(); @@ -831,6 +887,607 @@ describe(MemoryService.name, () => { }); }); + describe('onMemoriesCreate — Tier 3 rules (end-to-end generation)', () => { + it('creates a video_moments rule memory for videos in the target month of a past year (day 8)', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 8 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // 3 videos in the 3s-180s duration band, filmed in July of a past year (2023). + const videoIds: string[] = []; + for (const day of [5, 10, 15]) { + const asset = await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2023-07-${day}T12:00:00Z`, + type: AssetType.Video, + duration: 5000, + }); + videoIds.push(asset.id); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + type: MemoryType.Rule, + data: expect.objectContaining({ + ruleId: 'video_moments', + title: 'Video moments from July 2023', + subtitle: '3 videos', + context: expect.objectContaining({ year: 2023, month: 7, count: 3, favoriteCount: 0 }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...videoIds].toSorted()); + }); + + it('does not create a video_moments rule memory the day before the trigger day', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 7 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // Identical to the positive case above (band-qualifying videos), but evaluated on day 7 -- + // proves the TRIGGER_DAY gate, not the duration band. + for (const day of [5, 10, 15]) { + await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2023-07-${day}T12:00:00Z`, + type: AssetType.Video, + duration: 5000, + }); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toEqual([]); + }); + + it('does not create a video_moments rule memory for videos outside the duration band', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 8 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // Same day (8) and month/year pattern as the positive case, but every video is below + // MIN_DURATION_MS -- proves the duration band, not the trigger day. + for (const day of [5, 10, 15]) { + await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2023-07-${day}T12:00:00Z`, + type: AssetType.Video, + duration: 1000, + }); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toEqual([]); + }); + + it('creates a trip_anniversary rule memory for a multi-day away cluster starting on the anniversary', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 15 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // Home baseline: a dominant Berlin/Germany cluster >=90 days before the anniversary + // (2024-07-15), ending well before the GAP_DAYS pre-window. + for (const localDateTime of [ + '2024-05-01T12:00:00Z', + '2024-05-15T12:00:00Z', + '2024-06-01T12:00:00Z', + '2024-06-15T12:00:00Z', + '2024-07-01T12:00:00Z', + ]) { + await seedRuleAsset(ctx, { ownerId: user.id, localDateTime, city: 'Berlin', country: 'Germany' }); + } + + // Away cluster: Paris/France, first day exactly on the anniversary, across 2 distinct days, + // 7 assets total (meets MIN_TRIP_ASSETS/MIN_TRIP_DAYS exactly). The 3 day-1 assets also + // satisfy the cheap on-this-day probe (>=3 geotagged, single dominant city). + const parisIds: string[] = []; + for (const localDateTime of ['2024-07-15T12:00:00Z', '2024-07-15T13:00:00Z', '2024-07-15T14:00:00Z']) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime, city: 'Paris', country: 'France' }); + parisIds.push(asset.id); + } + for (const localDateTime of [ + '2024-07-16T12:00:00Z', + '2024-07-16T13:00:00Z', + '2024-07-16T14:00:00Z', + '2024-07-16T15:00:00Z', + ]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime, city: 'Paris', country: 'France' }); + parisIds.push(asset.id); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + type: MemoryType.Rule, + data: expect.objectContaining({ + ruleId: 'trip_anniversary', + title: 'Your trip to Paris, France', + subtitle: '2 years ago · 7 photos over 2 days', + context: expect.objectContaining({ + year: 2024, + country: 'France', + city: 'Paris', + assetCount: 7, + dayCount: 2, + }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...parisIds].toSorted()); + }); + + it('does not create a trip_anniversary rule memory when the away cluster spans a single day', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 15 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // Identical home baseline to the positive case above. + for (const localDateTime of [ + '2024-05-01T12:00:00Z', + '2024-05-15T12:00:00Z', + '2024-06-01T12:00:00Z', + '2024-06-15T12:00:00Z', + '2024-07-01T12:00:00Z', + ]) { + await seedRuleAsset(ctx, { ownerId: user.id, localDateTime, city: 'Berlin', country: 'Germany' }); + } + + // Same 7-asset away cluster and same first day as the positive case, but ALL 7 assets fall + // on that single day -> dayCount 1. Proves the MIN_TRIP_DAYS gate, not asset count or home. + for (const localDateTime of [ + '2024-07-15T09:00:00Z', + '2024-07-15T10:00:00Z', + '2024-07-15T11:00:00Z', + '2024-07-15T12:00:00Z', + '2024-07-15T13:00:00Z', + '2024-07-15T14:00:00Z', + '2024-07-15T15:00:00Z', + ]) { + await seedRuleAsset(ctx, { ownerId: user.id, localDateTime, city: 'Paris', country: 'France' }); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'trip_anniversary')).toBe(false); + }); + + it('creates a themed rule memory from a stubbed theme search port (day 22)', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 22 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // 8 assets in the target year (2025), spread across months that never land on month=7/day=22 + // so the place-based rules' on-this-day probe stays empty and doesn't compete for slots. + const themeAssets: ThemeSearchAsset[] = []; + for (const localDateTime of [ + '2025-01-10T12:00:00Z', + '2025-03-05T12:00:00Z', + '2025-05-20T12:00:00Z', + '2025-06-11T12:00:00Z', + '2025-08-02T12:00:00Z', + '2025-09-14T12:00:00Z', + '2025-11-03T12:00:00Z', + '2025-12-25T12:00:00Z', + ]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime }); + themeAssets.push({ id: asset.id, localDateTime: new Date(localDateTime) }); + } + + const searchByEmbedding = vi.fn().mockResolvedValue(themeAssets); + const stub: ThemeSearchPort = { + resolveEmbedding: vi.fn().mockResolvedValue('embedding-stub'), + searchByEmbedding, + }; + // Instance-override of the protected factory seam: getThemeSearchPort() memoizes lazily on + // first use, so the override must land before the first onMemoriesCreate() call. + (sut as unknown as { createThemeSearchPort: () => ThemeSearchPort }).createThemeSearchPort = () => stub; + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + type: MemoryType.Rule, + data: expect.objectContaining({ + ruleId: 'themed', + title: 'Sunsets from 2025', + subtitle: '8 photos', + context: expect.objectContaining({ year: 2025, theme: 'sunset', count: 8 }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual(themeAssets.map(({ id }) => id).toSorted()); + }); + + it('does not create a themed rule memory when the theme search port resolves no embedding', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 22 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + const searchByEmbedding = vi.fn(); + const stub: ThemeSearchPort = { + resolveEmbedding: vi.fn().mockResolvedValue(null), + searchByEmbedding, + }; + (sut as unknown as { createThemeSearchPort: () => ThemeSearchPort }).createThemeSearchPort = () => stub; + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'themed')).toBe(false); + expect(searchByEmbedding).not.toHaveBeenCalled(); + }); + + it('does not insert a third rule memory when the daily slot budget is already full', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2026, month: 7, day: 8 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + + // Data that would otherwise qualify for a video_moments memory today (see the positive + // video_moments case above). + for (const day of [5, 10, 15]) { + await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2023-07-${day}T12:00:00Z`, + type: AssetType.Video, + duration: 5000, + }); + } + + // Fill both daily slots with pre-existing rule memories already visible today. + const showAt = now.startOf('day').toJSDate(); + const hideAt = now.endOf('day').toJSDate(); + await ctx.newMemory({ + ownerId: user.id, + type: MemoryType.Rule, + data: { ruleId: 'dummy_a', dedupeKey: 'dummy_a:x', title: 'Dummy A', subtitle: '', score: 1, context: {} }, + memoryAt: now.toJSDate(), + showAt, + hideAt, + }); + await ctx.newMemory({ + ownerId: user.id, + type: MemoryType.Rule, + data: { ruleId: 'dummy_b', dedupeKey: 'dummy_b:x', title: 'Dummy B', subtitle: '', score: 1, context: {} }, + memoryAt: now.toJSDate(), + showAt, + hideAt, + }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: now.toJSDate() }); + expect(memories).toHaveLength(2); + expect(memories.map((memory) => (memory.data as { ruleId?: string }).ruleId).toSorted()).toEqual([ + 'dummy_a', + 'dummy_b', + ]); + }); + }); + + describe('onMemoriesCreate — person_throwback (end-to-end generation)', () => { + // Trigger day 13, per §3.5. Dormancy cutoff = personThrowbackDormancyMonths (default 6) before + // this, i.e. 2026-02-13 -- every fixture's assets below (Jan 2020, Aug 2023) sit well before that. + const target = DateTime.fromObject({ year: 2026, month: 8, day: 13 }, { zone: 'utc' }) as DateTime; + + it('creates a person_throwback rule memory for a dormant named person with a dense chapter', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + const { person, chapterAssetIds } = await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna' }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + type: MemoryType.Rule, + memoryAt: expect.any(Date), + showAt: target.startOf('day').toJSDate(), + hideAt: target.startOf('day').plus({ days: 6 }).endOf('day').toJSDate(), + data: expect.objectContaining({ + ruleId: 'person_throwback', + title: `Times with ${person.name}`, + subtitle: '6 photos · August 2023', + context: expect.objectContaining({ personId: person.id, count: 6 }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...chapterAssetIds].toSorted()); + }); + + it('does not create a person_throwback memory when the person is a pet', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + // Identical dormant-chapter fixture to the positive case above (10 total / 6-asset chapter), + // except `person.type = 'pet'` -- proves the D7 pet exclusion, not some unrelated gap. + await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Rex', overrides: { type: 'pet' } }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'person_throwback')).toBe(false); + }); + + it('does not create a person_throwback memory when the person is hidden', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + // Same otherwise-qualifying fixture, `person.isHidden = true` only. + await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna', overrides: { isHidden: true } }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'person_throwback')).toBe(false); + }); + + it('does not create a person_throwback memory when the person has no name', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + // Same otherwise-qualifying fixture, `person.name = ''` only. + await seedDormantPersonChapter(ctx, { ownerId: user.id, name: '' }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'person_throwback')).toBe(false); + }); + + it('still creates a person_throwback memory when recent photos exist but are archived', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + const { person, chapterAssetIds } = await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna' }); + + // A recent (2026), otherwise-qualifying photo of Anna that is Archived, not Timeline. If the + // dormancy query didn't filter visibility, this would push her last-seen date past the + // cutoff and she would no longer look dormant. + const recentArchived = await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: '2026-07-01T12:00:00Z', + visibility: AssetVisibility.Archive, + }); + await ctx.newAssetFace({ assetId: recentArchived.id, personId: person.id, isVisible: true }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + ruleId: 'person_throwback', + subtitle: '6 photos · August 2023', + context: expect.objectContaining({ count: 6 }), + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...chapterAssetIds].toSorted()); + }); + + it('excludes assets with no Preview file from both the dormancy count and the chapter', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + + // Anna qualifies on real (preview-bearing) data alone -- the standard 10-total/6-chapter + // fixture. 6 no-preview decoys land on the SAME 6 chapter days (a different hour), so a + // regression in either the density query (getMemoryPersonDailyCounts) or the final window + // fetch (getMemoryAssetsForPersonWindow) would inflate the subtitle count and/or the + // attached asset list past the real 6 -- both queries scan that identical date range. + const { person: anna, chapterAssetIds } = await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna' }); + for (const day of [5, 6, 7, 8, 9, 10]) { + const asset = await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2023-08-${day}T18:00:00Z`, + withPreview: false, + }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true }); + } + + // Ben's only real (preview-bearing) history is a 6-asset chapter with no padding -- below + // MIN_TOTAL_ASSETS (10) on its own. 8 more no-preview assets, on one day far from the + // chapter, would push his lifetime total over 10 if getDormantPeople's own preview filter + // didn't exclude them -- the chapter query still finds his real 6-asset Aug chapter either + // way, so this isolates the dormancy-count filter specifically. + const { person: ben } = await ctx.newPerson({ ownerId: user.id, name: 'Ben' }); + for (const day of [5, 6, 7, 8, 9, 10]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-08-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true }); + } + for (let hour = 10; hour < 18; hour++) { + const asset = await seedRuleAsset(ctx, { + ownerId: user.id, + localDateTime: `2020-01-10T${hour}:00:00Z`, + withPreview: false, + }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true }); + } + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + ruleId: 'person_throwback', + title: `Times with ${anna.name}`, + subtitle: '6 photos · August 2023', + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...chapterAssetIds].toSorted()); + }); + + it('excludes assets whose face is soft-deleted or invisible from the chapter', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + + // Anna qualifies on real (visible-face) data alone. 6 decoy assets land on the SAME 6 + // chapter days, each with a real Preview file but a face that is invisible or soft-deleted + // for Anna -- if either the density query or the final window fetch wrongly counted them, + // the subtitle count and/or attached asset list would balloon past the real 6. + const { person: anna, chapterAssetIds } = await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna' }); + for (const day of [5, 6, 7]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-08-${day}T18:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: false }); + } + for (const day of [8, 9, 10]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-08-${day}T18:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true, deletedAt: new Date() }); + } + + // Ben's only real (visible-face) history is a 6-asset chapter with no padding -- below + // MIN_TOTAL_ASSETS on its own. 8 more assets far from the chapter, split between invisible + // and soft-deleted faces, would push his lifetime total over 10 if getDormantPeople's own + // face filters didn't exclude them. + const { person: ben } = await ctx.newPerson({ ownerId: user.id, name: 'Ben' }); + for (const day of [5, 6, 7, 8, 9, 10]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-08-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true }); + } + for (let hour = 10; hour < 14; hour++) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2020-01-10T${hour}:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: false }); + } + for (let hour = 14; hour < 18; hour++) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2020-01-10T${hour}:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: ben.id, isVisible: true, deletedAt: new Date() }); + } + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + ruleId: 'person_throwback', + title: `Times with ${anna.name}`, + subtitle: '6 photos · August 2023', + }), + }), + ]); + expect(memories[0]?.assets.map(({ id }) => id).toSorted()).toEqual([...chapterAssetIds].toSorted()); + }); + + it('does not create a person_throwback memory when the user has the type toggled off', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Anna' }); + await ctx.get(UserRepository).upsertMetadata(user.id, { + key: UserMetadataKey.Preferences, + value: { memories: { types: { person_throwback: false } } }, + }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories.some((memory) => (memory.data as { ruleId?: string }).ruleId === 'person_throwback')).toBe(false); + }); + + it('skips a person whose person_throwback already fired and uses a different dormant person instead', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const { user } = await ctx.newUser(); + + // Anna's chapter (9 assets) is deliberately bigger than Ben's (6, the standard fixture), so + // her score is STRICTLY higher (110 + 9*3 + 7 = 144 vs 110 + 6*3 + 7 = 135) -- deterministic + // regardless of the two people's random UUID order, so the engine always considers Anna + // first. That pins the dedupe skip on Anna specifically, rather than leaving the outcome to + // coincide with the (unrelated) one-slot-per-multi-day-rule cap depending on which of two + // equally-scored candidates happened to sort first. + const { person: anna } = await ctx.newPerson({ ownerId: user.id, name: 'Anna' }); + for (let hour = 10; hour < 14; hour++) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2020-01-10T${hour}:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true }); + } + for (const day of [5, 6, 7, 8, 9, 10, 11, 12, 13]) { + const asset = await seedRuleAsset(ctx, { ownerId: user.id, localDateTime: `2023-08-${day}T12:00:00Z` }); + await ctx.newAssetFace({ assetId: asset.id, personId: anna.id, isVisible: true }); + } + + const { person: ben } = await seedDormantPersonChapter(ctx, { ownerId: user.id, name: 'Ben' }); + + // Anna's person_throwback already fired a year before `target`. Dates sit well outside + // `target`'s search window, so this pre-existing memory doesn't itself occupy one of + // today's two rule slots -- the test isolates the dedupeKey skip (D8), not the slot cap. + const dedupeKeyAnna = `person_throwback:${anna.id}`; + const priorShowAt = DateTime.fromObject({ year: 2025, month: 8, day: 13 }, { zone: 'utc' }); + await ctx.newMemory({ + ownerId: user.id, + type: MemoryType.Rule, + data: { + ruleId: 'person_throwback', + dedupeKey: dedupeKeyAnna, + title: `Times with ${anna.name}`, + subtitle: '9 photos · August 2023', + score: 144, + context: { personId: anna.id, count: 9 }, + }, + memoryAt: priorShowAt.plus({ days: 2 }).toJSDate(), + showAt: priorShowAt.toJSDate(), + hideAt: priorShowAt.plus({ days: 6 }).endOf('day').toJSDate(), + }); + + vi.setSystemTime(target.toJSDate()); + await sut.onMemoriesCreate(); + + // Only Ben's fresh memory shows up today -- Anna's dedupeKey already fired, so the engine + // skips her (score-sorted first, since her chapter is bigger) and falls through to the next + // dormant candidate the rule returned (D8). + const memories = await memoryRepo.search(user.id, { type: MemoryType.Rule, for: target.toJSDate() }); + expect(memories).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ ruleId: 'person_throwback', title: `Times with ${ben.name}` }), + }), + ]); + + // Anna's original memory is still there, and only there -- exactly one, not a second copy. + const priorMemories = await memoryRepo.search(user.id, { + type: MemoryType.Rule, + for: priorShowAt.plus({ days: 2 }).toJSDate(), + }); + expect(priorMemories.map((memory) => (memory.data as { dedupeKey?: string }).dedupeKey)).toEqual([dedupeKeyAnna]); + }); + }); + describe('onMemoriesCleanup', () => { it('should run without error', async () => { const { sut } = setup(); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 73dc71956d8cc..941c1af353012 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -18,6 +18,8 @@ export const newAssetRepositoryMock = (): Mocked ({ - memories: { retentionDays, birthday: true, recentTrips: true, types: {} }, + memories: { + retentionDays, + birthday: true, + recentTrips: true, + types: {}, + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, + }, }) as SystemConfigDto; const mocks = vi.hoisted(() => ({ featureFlags: { configFile: false }, - systemConfig: { memories: { retentionDays: 365, birthday: true, recentTrips: true, types: {} } } as SystemConfigDto, + systemConfig: { + memories: { + retentionDays: 365, + birthday: true, + recentTrips: true, + types: {}, + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, + }, + } as SystemConfigDto, defaultSystemConfig: { - memories: { retentionDays: 365, birthday: true, recentTrips: true, types: {} }, + memories: { + retentionDays: 365, + birthday: true, + recentTrips: true, + types: {}, + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, + }, } as SystemConfigDto, cloneValue: vi.fn(), cloneDefaultValue: vi.fn(), @@ -59,7 +82,8 @@ describe('MemoriesSettings', () => { it('renders the retention input from the memories config', () => { render(MemoriesSettings); - const input = screen.getByRole('spinbutton') as HTMLInputElement; + // Number inputs render in declaration order: retention, theme distance, dormancy. + const [input] = screen.getAllByRole('spinbutton') as HTMLInputElement[]; expect(input).toHaveAttribute('type', 'number'); expect(input).toHaveValue(365); @@ -67,6 +91,78 @@ describe('MemoriesSettings', () => { expect(screen.getByText('admin.memory_retention_setting_description')).toBeInTheDocument(); }); + it('renders the themed threshold and person-throwback dormancy inputs from the memories config', () => { + render(MemoriesSettings); + + const [, themeDistance, dormancy] = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + + expect(themeDistance).toHaveValue(0.75); + expect(themeDistance).toHaveAttribute('max', '2'); + expect(dormancy).toHaveValue(6); + expect(dormancy).toHaveAttribute('min', '1'); + expect(screen.getByText('admin.memory_theme_max_distance_setting')).toBeInTheDocument(); + expect(screen.getByText('admin.memory_person_throwback_dormancy_setting')).toBeInTheDocument(); + }); + + it('disables a type-specific knob when its memory type is turned off', async () => { + const user = userEvent.setup(); + render(MemoriesSettings); + + const [, themeDistance, dormancy] = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + expect(themeDistance).toBeEnabled(); + + await user.click(screen.getByRole('switch', { name: 'admin.memory_type_themed_setting' })); + + // Only the disabled type's own knob is gated; the other type's stays editable. + expect(themeDistance).toBeDisabled(); + expect(dormancy).toBeEnabled(); + }); + + it('saves an edited themed threshold and dormancy window', async () => { + const user = userEvent.setup(); + render(MemoriesSettings); + + const [, themeDistance, dormancy] = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + await user.clear(themeDistance); + await user.type(themeDistance, '0.8'); + await user.clear(dormancy); + await user.type(dormancy, '12'); + await user.click(screen.getByRole('button', { name: 'save' })); + + expect(handleSystemConfigSave).toHaveBeenCalledWith( + expect.objectContaining({ + memories: expect.objectContaining({ themeMaxDistance: 0.8, personThrowbackDormancyMonths: 12 }), + }), + ); + }); + + // Mirrors MEMORY_TYPE_METADATA in server/src/services/memory-rules/memory-type.metadata.ts. + // Guards against the UI list drifting out of sync with the server registry — a type missing + // here is a type no admin can ever turn off. + const ALL_MEMORY_TYPE_KEYS = [ + 'on_this_day', + 'birthday', + 'recent_trip', + 'month_recap', + 'favorites_throwback', + 'on_this_day_place', + 'season_recap', + 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', + ]; + + it('renders a switch for every server-registered memory type', () => { + render(MemoriesSettings); + + for (const key of ALL_MEMORY_TYPE_KEYS) { + expect(screen.getByRole('switch', { name: `admin.memory_type_${key}_setting` })).toBeInTheDocument(); + } + expect(screen.getAllByRole('switch')).toHaveLength(ALL_MEMORY_TYPE_KEYS.length); + }); + it('renders a switch per memory type, defaulting unset types to enabled', () => { mocks.systemConfig = makeConfig(); mocks.systemConfig.memories.types = { birthday: false }; @@ -94,6 +190,8 @@ describe('MemoriesSettings', () => { retentionDays: 365, birthday: true, recentTrips: true, + themeMaxDistance: 0.75, + personThrowbackDormancyMonths: 6, types: { on_this_day: true, birthday: false, @@ -103,6 +201,10 @@ describe('MemoriesSettings', () => { on_this_day_place: true, season_recap: true, people_together: true, + video_moments: true, + trip_anniversary: true, + themed: true, + person_throwback: true, }, }, }); diff --git a/web/src/routes/admin/system-settings/MemoriesSettings.svelte b/web/src/routes/admin/system-settings/MemoriesSettings.svelte index 37ee6cba33a78..4122ec51d38de 100644 --- a/web/src/routes/admin/system-settings/MemoriesSettings.svelte +++ b/web/src/routes/admin/system-settings/MemoriesSettings.svelte @@ -18,6 +18,10 @@ 'on_this_day_place', 'season_recap', 'people_together', + 'video_moments', + 'trip_anniversary', + 'themed', + 'person_throwback', ]; const disabled = $derived(featureFlagsManager.value.configFile); @@ -55,6 +59,45 @@ bind:checked={memoryTypes[key]} {disabled} /> + + + {#if key === 'themed'} +
+ +
+ {/if} + + {#if key === 'person_throwback'} +
+ +
+ {/if} {/each}