Skip to content

feat(memories): person throwback — resurface a chapter with someone dormant 12+ months (#9) - #831

Merged
Deeds67 merged 13 commits into
feat/memory-types-tier3from
feat/memory-person-throwback
Jul 26, 2026
Merged

feat(memories): person throwback — resurface a chapter with someone dormant 12+ months (#9)#831
Deeds67 merged 13 commits into
feat/memory-types-tier3from
feat/memory-person-throwback

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Implements roadmap item #9 "Someone you haven't seen" as person_throwback.
Stacked on #812 — review that first; this PR targets feat/memory-types-tier3.

Spec: docs/plans/2026-07-22-memory-person-throwback-spec.md

What it does

Once a month (day 13), resurfaces the densest chapter of photos with a person who has not
appeared in your library for 12+ months.

Times with Anna
23 photos · August 2023

The framing decision that drives everything

The dormancy gap is a silent selector — it decides who to resurface and is never shown to the
user. No "You haven't seen Anna in 2 years".

That matters because this rule, by construction, over-samples people who left your life. Saying so
out loud lands badly when the person has died or the friendship ended. As a silent selector it
reads like any other memory. Every other decision follows from this:

Decision Why
12-month threshold, no upper bound Photo-absence ≠ real absence. A shorter threshold admits many people you still see — harmless false positives here, and they dilute how much the rule concentrates on people who are genuinely gone.
Densest chapter, not the last one Densest ≈ a real event ≈ the best photos. "Last chapter" is the heaviest possible cut if someone died; an all-years spread reads as an in-memoriam reel.
Gap length is never scored Ranking by dormancy would put the most-likely-deceased person first. Ranks by chapter richness instead.
recencyBonus applied Conventional, and it mildly favours recently-dormant people — same lever, further dilution.
Pets excluded (type='person') A pet dormant 12+ months has overwhelmingly died, with none of the "maybe I just don't photograph them" ambiguity that makes the human case safe. Roadmap #10 owns pets.

Two defects the spec review caught before any code

Trigger day 26 would have meant the rule never ran. createRuleMemories returns early when
remainingSlots === 0 (memory.service.ts:130), and slots are counted from memories still
visible
that day — not created that day. Day 26 is the exact intersection of people_together
(20–26) and themed (22–26), so whenever both fired, nothing evaluated at all. Moved to day 13,
chosen by mapping every rule's window; §5.1 documents the analysis and the ASCII occupancy map.

Score bands were compared by base, not achievable range. on_this_day_place maxes at 200 and
people_together is unbounded, both routinely beating the original formula's max of 140. Re-tuned
to mirror people_together's shape (110 + min(n,30)*3 + bonus) — capped where that rule is not.

Also fixed pre-code: a Date/DateTime mismatch that would not have compiled, a dead dayCount
field, dead DormantPerson fields, and two wrong i18n key shapes.

Design notes worth a reviewer's attention

  • The rule returns up to 5 candidates, not 1. hasRuleMemory dedup happens in the engine
    after the rule returns, so a 1-candidate rule whose key already fired contributes nothing —
    permanently. This is the trap Tier 3 hit.
  • Density runs on daily counts, not fetched assets. A dormant ex-partner can have thousands of
    photos; any LIMIT guess silently picks the wrong chapter. getMemoryPersonDailyCounts returns
    one row per person-day, and assets are fetched only for the winning ≤14-day window.
  • Read skew is guarded. chapter.count and assetIds come from two different queries; without
    the post-fetch re-check, a deletion in between yields a memory with too few — or zero — assets.

Testing

TDD throughout: every test written and observed red first.

  • 9 pure unit tests (chapter.util)
  • 20 rule unit tests — including the pinned worked score, the empty-pool guard asserted via the
    absent second query, and read skew at two severities
  • 9 medium tests on a real DB — pet/hidden/unnamed/archived/no-preview/invisible-face exclusion,
    the per-user toggle, and the D8 dedup guarantee
  • Server 5313 unit + 36 medium passing; web 3749 passing; tsc, eslint, prettier clean;
    SQL docs regenerated (597 → 600 queries)

Every medium row was mutation-tested: the relevant predicate was commented out and the row
confirmed to go red. That caught the D8 test passing by accident half the time on tied scores and
random UUID tie-breaks — it was rebuilt with deliberately unequal scores.

Follow-ups (deliberately not here)

  1. Per-person excludeFromMemories, honoured by every person-based rule. The shipped birthday
    rule currently wishes a deceased person a happy birthday with no opt-out short of isHidden
    a stronger claim than anything this PR makes. Its own PR, spec §8.
  2. birthday samples 60 assets by UUID (getMemoryAssetsForPerson orders by asset.id), which
    skews its per-year spread for anyone with >60 photos. Its own issue.
  3. The engine silently swallows a badly-chosen trigger day — no error, no log, the rule just
    never runs. Worth fixing in memory.service.ts so the next rule can't repeat the day-26 mistake.

Roadmap #9 flipped to Shipped; #12 annotated (that themed already rides CLIP embeddings).

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web labels Jul 22, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Jul 22, 2026
Deeds67 added 2 commits July 22, 2026 23:43
…ormancy

`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.
@Deeds67
Deeds67 merged commit e561cef into feat/memory-types-tier3 Jul 26, 2026
41 checks passed
Deeds67 added a commit that referenced this pull request Jul 26, 2026
…eo moments (#812)

* 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.
Deeds67 added a commit that referenced this pull request Jul 26, 2026
…nce) (#792)

* docs(memories): Tier 2 people_together spec (pair co-occurrence memory)

* docs(memories): harden people_together spec — TDD/BDD, full edge coverage, /impl-loop slices

* feat(memories): pairCounts curation helper for people-together

* feat(memories): getMemoryFacesForPeriod repository query

* feat(memories): people-together memory type

* chore(memories): people-together generation test + roadmap status

* fix(memories): add people_together to e2e server-config fixture + docs

* feat(memories): Tier 3 — trip anniversary, themed (smart search), video moments (#812)

* 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.
Deeds67 added a commit that referenced this pull request Jul 30, 2026
…nce) (#792)

* docs(memories): Tier 2 people_together spec (pair co-occurrence memory)

* docs(memories): harden people_together spec — TDD/BDD, full edge coverage, /impl-loop slices

* feat(memories): pairCounts curation helper for people-together

* feat(memories): getMemoryFacesForPeriod repository query

* feat(memories): people-together memory type

* chore(memories): people-together generation test + roadmap status

* fix(memories): add people_together to e2e server-config fixture + docs

* feat(memories): Tier 3 — trip anniversary, themed (smart search), video moments (#812)

* 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.
Deeds67 added a commit that referenced this pull request Jul 30, 2026
…nce) (#792)

* docs(memories): Tier 2 people_together spec (pair co-occurrence memory)

* docs(memories): harden people_together spec — TDD/BDD, full edge coverage, /impl-loop slices

* feat(memories): pairCounts curation helper for people-together

* feat(memories): getMemoryFacesForPeriod repository query

* feat(memories): people-together memory type

* chore(memories): people-together generation test + roadmap status

* fix(memories): add people_together to e2e server-config fixture + docs

* feat(memories): Tier 3 — trip anniversary, themed (smart search), video moments (#812)

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant