Files
MangaCircus/docs/superpowers/specs/2026-10-25-discovery-wider-pool-design.md
T
MaashkinoozheandClaude Opus 5.5 cd6a30a76b Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)
- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:43:31 +01:00

307 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Discovery: wider candidate pool — design
Status: implemented on branch feature/discovery-wider-pool. Date: 2026-10-25. Builds on the
deterministic Discovery Scout ([`docs/discovery.md`](../../discovery.md),
[`vision/discovery-scout.md`](../../../vision/discovery-scout.md)).
## 1. Goal
Today Discover can only suggest titles from the curated Greatest catalog plus
AniList *relations* (sequels, spin-offs) of Greatest and library titles, and only
when those are already in the local metadata cache. A run makes no network call.
Users run out of new suggestions quickly.
This change widens the candidate pool with four seed kinds, fetched server-side
from AniList:
1. **Community recommendations** — "readers who liked X recommend Y" for the
user's anchor titles.
2. **Same creator** — other works by the story/art authors of anchor titles.
3. **Top in your tags** — well-rated titles in the user's strongest genres/tags.
4. **Fresh** — recently started or currently releasing, trending titles.
Success: suggestions the user did not already know, each with a traceable
reason and evidence link, without exceeding AniList's rate limits or making the
reader noticeably slower.
### Unchanged rules from Phase A
- Personalized discovery and reading activity stay separate opt-ins, both off
by default; feedback stays private to the account.
- Discovery never edits the scraper queue, library or collections and never
downloads anything.
- Every suggestion carries evidence; missing data stays missing.
- The reader is the only writer of discovery state; no new Compose service, no
database, no LLM.
- All fetching runs in the reader container, never in the browser.
- The repository is public: no host names, paths or personal seeds in code,
docs or fixtures.
### Non-goals
- No other metadata provider (AniList's API covers all four seed kinds).
- No LLM rationale or rerank (vision Phase C).
- No edition finding or queueing from a suggestion (vision Phase B).
- No worker container; it is only a fallback if section 7's latency check fails.
## 2. Components
| Unit | Responsibility | Depends on |
|---|---|---|
| `discovery/anilist.js` (new) | GraphQL queries for the four seed kinds and for a title by ID; one shared rate limiter; schema check and normalization of responses | `fetch`, `discovery/cache.js` |
| `discovery/cache.js` (new) | `data/discovery-cache/`: atomic writes, TTL per entry kind, size cap with oldest-first pruning | filesystem |
| `discovery/harvest.js` (new) | Anchor selection per user, request plan, budgets, single-flight run state, stop reasons | `anilist.js`, `cache.js`, `db` (read-only) |
| `discovery/engine.js` (changed) | Builds candidates from Greatest, relations **and the harvest cache**; new score parts, evidence kinds, slot mix, `isAdult` filter | `cache.js` (read-only) |
| `server.js` (changed) | Nightly scheduler, admin "Harvest now", per-user "Refresh my suggestions", admin status fields; route handlers stay thin | `harvest.js`, `engine.js`, `discovery/store.js` |
| `public/discover.*` (changed) | Refresh button states, new reason labels, fetch date in evidence, admin harvest panel | reader API |
The existing `discovery/store.js` (atomic `discovery.json`, `.prev` recovery,
fail-closed on corruption) is reused unchanged. The reader keeps reading the
scraper's AniList cache read-only for relations; the harvester never writes into
`data/scraper/`.
### 2.1 `discovery/anilist.js`
Queries (all `type: MANGA`, unauthenticated, `https://graphql.anilist.co`):
| Kind | Query | Page size |
|---|---|---|
| `title` | `Media(id)` with the fields of `scraper/metadata/anilist.js` plus `isAdult averageScore popularity trending tags { name rank isAdult } recommendations staff` | 1 |
| `recs` | `Media(id).recommendations(sort: RATING_DESC)` → `mediaRecommendation` + `rating` | 25 |
| `creator` | `Staff(id).staffMedia(type: MANGA, sort: POPULARITY_DESC)` | 25 |
| `tagTop` | `Page.media(tag_in or genre_in: [one tag], sort: SCORE_DESC, popularity_greater: 1000)` | 25 |
| `fresh` | `Page.media(status: RELEASING, startDate_greater: <today − 2 years>, sort: TRENDING_DESC)` | 50 |
- **Rate limiter:** at most 25 requests per minute, one at a time, shared by all
harvests in the process. AniList allows more, so this stays below its limit
with headroom.
- **Normalized record:** `{ anilistId, title, altTitles, format, isAdult,
status, year, averageScore, popularity, trending, tags[{name, rank}],
authors[{staffId, name, role}], coverUrl, siteUrl, fetchedAt }`.
Each list response becomes a list of `{ anilistId, rating? }` plus one record
per entry it contains.
- **Formats:** only `MANGA` and `ONE_SHOT` are kept; novels and anime are dropped.
- **Response check:** responses are checked against a schema before they are
cached; an invalid one counts as a failed request. Titles, descriptions and
tags are treated as untrusted text: stored as data, escaped when shown.
### 2.2 `discovery/cache.js`
- **Location:** `data/discovery-cache/<kind>/<key>.json`. Every entry has
`{ schema: 1, kind, key, fetchedAt, data }`.
- **Lifetime:** `title` and `recs` entries live 30 days, `creator` 30 days,
`tagTop` and `fresh` 7 days. An expired entry is still used for scoring until
it is refreshed. Only the harvest checks the TTL.
- **Size cap:** 5,000 files. On write, the oldest `fetchedAt` entries are pruned
first.
- **Writes:** temporary file then rename, as in the existing stores. A corrupt
entry is ignored and deleted.
### 2.3 `discovery/harvest.js`
**Anchors per opted-in user.** Each anchor has a weight; the list is
deduplicated by AniList ID and capped at the 15 strongest:
| Source | Weight |
|---|---|
| Greatest vote `flame` | 5 |
| Greatest vote `good` | 3 |
| Discover feedback `interested` | 4 |
| Discover feedback `maybe` | 2 |
| Completed chapter in a library title (only with reading activity opted in) | 3 |
| Owned library title with an AniList ID | 1 |
**User's top tags.** The 3 tags with the highest summed anchor weight, using
the anchors' cached `title` records (tag rank ≥ 60, no adult tags unless the
user enabled mature titles).
**Request plan, in this order:**
1. `title` for anchors that are not cached.
2. `recs` for each anchor.
3. `creator` for the story/art staff of anchors weighted 3 or more.
4. `tagTop` for each top tag.
5. One global `fresh` list.
Requests whose cache entry is still within its TTL are skipped. Then follow-up
`title` requests run for new candidates that are missing tags or `isAdult`, up
to the remaining budget.
**Budgets:** nightly harvest 300 requests, per-user refresh 60 requests. When
the budget runs out, the plan stops; whatever was cached so far is still used.
**Run state:** one harvest at a time (single-flight).
- A queued per-user refresh waits behind a running harvest; a second refresh
for the same user while one is queued returns `409`.
- The run record is persisted in `discovery.json` `harvest`:
`{ id, kind: nightly|admin|user, user?, status, startedAt, finishedAt,
requests, budget, stopReason }`.
## 3. Data flow
```
anchors ─▶ request plan ─▶ AniList (rate-limited) ─▶ discovery-cache
│
Greatest + scraper AniList cache (relations) ─────────────┤
▼
engine.generate() ─▶ discovery.json (atomic publish)
```
Harvest and scoring are separate steps. Scoring is local and never touches the
network, so a failed harvest means older candidates, never an empty or broken
feed.
- **After a nightly or admin harvest:** re-score every opted-in user.
- **After a per-user refresh:** re-score only that user. The other users' feed
snapshots are carried over unchanged into the same publish.
## 4. Scoring
`engine.generate()` builds the candidate map from Greatest, relations, and every
record referenced by the harvest cache, limited to 2,000 candidates; the
replaced Phase A cap was 50. Exclusions stay as in Phase A:
- titles the user already voted on in Greatest
- titles dismissed or hidden in Discover
- owned titles, matched by ID or by normalized title + year
- queued titles
The mature filter now uses `isAdult` (title-level, or an adult tag with
rank ≥ 60). The Phase A tag regex is kept only for candidates with no `isAdult`
data.
New score parts, all bounded:
| Part | Value | Rule |
|---|---|---|
| `communityRec` | 0–5 | `min(5, 1 + log2(1 + rating) / 2) × anchorWeight / 5`; strongest anchor counts |
| `sameCreator` | 0–4 | `4 × anchorWeight / 5` for a shared story/art author with an anchor weighted ≥ 3 |
| `tagTop` | 0–3 | `3` for the user's top tag, `2` second, `1` third, only if `averageScore ≥ 75` |
| `fresh` | 0–2 | `2` if releasing and started within 2 years, `1` if only trending |
Existing parts (`editorial`, `relation`, `sharedTaste`, `era`, `reading`,
`interest`) and their weights are unchanged.
**Slot mix** for the 20 feed slots, applied greedily by descending score:
- At most 8 items whose strongest part comes from the same seed kind.
- Up to 4 slots reserved for `fresh` candidates when such candidates exist.
- About 20% (4 slots) taken from candidates outside the user's strongest tag
cluster, as in the vision doc.
- If a rule cannot be met, the slot falls back to the next best candidate; the
feed is never padded.
**Reasons.** A candidate found by several seeds collects every reason and
evidence item. The tile shows the reason of the strongest part; the detail
panel lists all of them. Reason templates:
| Kind | Template |
|---|---|
| `recs` | "AniList readers who liked {anchor} recommend this ({rating})." |
| `creator` | "Also by {author} — you {read/liked} {anchor}." |
| `tagTop` | "Top-rated in {tag}, one of your most-liked tags." |
| `fresh` | "New: started {year}, ongoing and trending." |
Evidence entries use `kind: recommendation|creator|tag-top|fresh`, link to the
AniList record, and carry `fetchedAt`.
## 5. API and UI
Routes:
- `POST /api/discovery/refresh` — caller only, requires opt-in. Returns
`{ harvestId, position }`. Returns `429` with `nextAllowedAt` within 1 hour of
the caller's last refresh, and `409` if one is already queued for the caller.
- `GET /api/discovery` — adds
`refresh: { state: idle|queued|running, position?, nextAllowedAt? }` and
`fetchedAt` on evidence.
- `POST /api/admin/discovery/harvest` — admin only; starts a full harvest.
Returns `409` while one runs.
- `GET /api/admin/discovery` — adds the last harvest record, cache file count
and size, and the next nightly time.
- `POST /api/admin/discovery/settings` (new) — `{ harvestEnabled: boolean,
harvestAt: "HH:MM" }`. Defaults: `true` and `03:30`, overridable by
`DISCOVERY_HARVEST_AT`. The value is validated; nothing else is accepted.
UI on `discover.html`:
- **Refresh my suggestions** button with three states: idle, queued/fetching
(with queue position), and cooldown ("next refresh possible at 14:20").
- Reason labels per section 4; evidence shows "fetched {date}".
- The settings text for **Personalized discovery** says that the server sends
AniList the IDs of titles you liked, without login, to find related work.
- Admin panel: last harvest (time, requests / budget, stop reason), cache size,
next nightly run, **Harvest now**, enable switch and time.
## 6. Failure handling
| Condition | Behaviour |
|---|---|
| HTTP 429, 403, or a challenge page | Stop the harvest at once with `stopReason`, shown in the admin panel. No retry until the next nightly slot or admin action; never change identity, user agent or host. |
| Timeout or 5xx | Skip that request and count it. Five in a row abort the run (`stopReason: upstream-errors`). A timeout never counts as an empty result. |
| Invalid response | Dropped, counted as a failed request, not cached. |
| Restart during a harvest | On startup the run is marked `interrupted`. If the nightly slot was missed, one harvest runs after startup; there is never a catch-up storm. A queued per-user refresh is dropped and the user can press the button again. |
| Corrupt `discovery.json` | Unchanged from Phase A: reads and runs fail closed; the file is never overwritten. |
| No network | Harvest fails with a stop reason; scoring uses whatever is cached. A fresh install behaves exactly like Phase A. |
| Account deletion | Unchanged (feedback removed). Cache entries are not user-specific and stay. |
## 7. Performance
- Harvesting is I/O-bound and single-flight. At 25 requests per minute, a full
nightly harvest takes about 12 minutes.
- Scoring 2,000 candidates for each user must keep the reader event loop
responsive. Each user is scored in its own `setImmediate` turn, and a test
asserts that one user's scoring stays below 200 ms on the fixture set.
- If production shows reader stalls, moving the job to a worker container is
recorded as a backlog item; it is not part of this change.
## 8. Testing
All network tests use recorded, anonymized AniList fixtures; no test calls the
live API.
**Unit:**
- query builders and response normalization
- schema rejection of malformed responses
- dropping non-manga formats
- the rate limiter, with a fake clock
- stop on 429/403/challenge, and the consecutive-error abort
- cache TTL, size pruning, and corrupt-entry handling
- anchor weights, cap and dedup; reading activity used only with opt-in
- top-tag selection
- each new score part
- slot-mix limits and fallback
- merging reasons from several seeds
- `isAdult` filter
- exclusion of owned, queued, voted and dismissed titles
**Integration:**
- per-user refresh: queue position, `429` cooldown, `409` duplicate
- admin harvest single-flight
- re-scoring after a per-user refresh leaves other users' feeds unchanged
- interrupted-run recovery
- one missed-night run after startup
- no harvest or score ever changes the scraper queue or library
- settings validation
**Browser** (existing disposable Compose suite):
- refresh button states
- reason labels for each seed kind
- evidence fetch date
- admin harvest panel
- desktop and mobile layout
## 9. Documentation
- `docs/discovery.md`: seed kinds, budgets, cache, nightly time, privacy note,
stop reasons.
- `docs/deployment.md`: `data/discovery-cache/` (safe to delete, rebuilt by the
next harvest; may be left out of backups) and `DISCOVERY_HARVEST_AT`.
- `docs/architecture.md`: the new routes.
- `vision/discovery-scout.md`: mark the candidate-input part of section 4 as
implemented.