Module: preferences · Dimension: correctness · Severity: medium · Location: apps/server/src/preferences/internal/engine.ts:154
Problem
In enrichCandidates, once opts.deadlineMs passes, the outer loop breaks (line 154) and the inner slice map returns null for every remaining candidate (line 158). Those candidates are never pushed into enriched, so rankCandidates returns a SHORTER list than the input. This also drops candidates whose features are already inline on the MediaItem (featuresForCandidate line 176 rawItemToCandidateFeatures needs zero IO) — the deadline check at line 158 runs before that code path even executes, despite the RankOptions doc saying 'catalog reads unbounded' and 'Items past deadline: no features' (i.e. they should still be ranked, just without features).
Why it matters
The sole production caller, catalog/internal/recommendation-writer.ts:77-79, does ranked = rankCandidates(...) then topN = ranked.slice(0, TOP_N). When a user has a cold metadata cache and the per-row deadline elapses partway, every un-enriched candidate is silently omitted, so the persisted rec list is truncated to far fewer than TOP_N items even though plenty of candidates were supplied. A feature-less candidate would still score profileScore 0 and rank by upstream position (normUpstream), preserving list length; dropping it discards that signal entirely.
Suggested fix
When the deadline elapses, keep the candidate in enriched with whatever features are available (direct/inline extraction or an empty CandidateFeatures) rather than returning null, so the item is still ranked on upstream order. Reserve dropping for candidates that are genuinely unresolvable (no id/type).
Fable-panel validated: 3/3 votes.
Filed from automated per-module audit workflow (Opus/Sonnet research → Fable-panel decision). Unverified by a human — triage before acting.
Module:
preferences· Dimension: correctness · Severity: medium · Location:apps/server/src/preferences/internal/engine.ts:154Problem
In enrichCandidates, once opts.deadlineMs passes, the outer loop
breaks (line 154) and the inner slice map returns null for every remaining candidate (line 158). Those candidates are never pushed intoenriched, so rankCandidates returns a SHORTER list than the input. This also drops candidates whose features are already inline on the MediaItem (featuresForCandidate line 176rawItemToCandidateFeaturesneeds zero IO) — the deadline check at line 158 runs before that code path even executes, despite the RankOptions doc saying 'catalog reads unbounded' and 'Items past deadline: no features' (i.e. they should still be ranked, just without features).Why it matters
The sole production caller, catalog/internal/recommendation-writer.ts:77-79, does
ranked = rankCandidates(...)thentopN = ranked.slice(0, TOP_N). When a user has a cold metadata cache and the per-row deadline elapses partway, every un-enriched candidate is silently omitted, so the persisted rec list is truncated to far fewer than TOP_N items even though plenty of candidates were supplied. A feature-less candidate would still score profileScore 0 and rank by upstream position (normUpstream), preserving list length; dropping it discards that signal entirely.Suggested fix
When the deadline elapses, keep the candidate in
enrichedwith whatever features are available (direct/inline extraction or an empty CandidateFeatures) rather than returning null, so the item is still ranked on upstream order. Reserve dropping for candidates that are genuinely unresolvable (no id/type).Fable-panel validated: 3/3 votes.
Filed from automated per-module audit workflow (Opus/Sonnet research → Fable-panel decision). Unverified by a human — triage before acting.