69. Score coherence audit (2026-05-19)
Bug reproduction (Alpine Select AG, CH market)
User screenshot of /recommendations showed a top buy hero card with:
| Field | Value shown |
|---|---|
| Header score badge | 85 / 100 |
| Insider Signal | 3 / 35 |
| Historical Win Rate | 12 / 25 |
| Expected Return | 5 / 20 |
| Filing Recency | 33 / 20 |
| Composite total | 53 / 100 |
Two visible inconsistencies:
- Header
85does not equal composite total53. - Filing Recency
33 / 20is over its own cap.
End-to-end trace of a CH signal
Declarationrow pulled bybuildRecoIteminsrc/lib/recommendation-engine.ts.- Market resolution:
marketOf(amfId)returnschfor Alpine Select. - Market weights selected:
MARKET_WEIGHTS.ch = { sig: 10, wr: 35, ret: 10, rec: 45 }. Total = 100. - Each component is computed against its market weight:
signalPts = signalNorm * 10(clamp 0..10)winRatePts = (winRate/100) * 35(clamp 0..35)returnPts = clamp(0, 10, 10/2 + ...)(clamp 0..10)recencyPts = clamp(0, 45, 45 * 0.5^(d/21) - staleness)(clamp 0..45)
recoScore = round(signal + wr + ret + rec). Raw composite for a fresh CH signal lands in the 50..55 band.getRecommendationsthen runsboostRecoScoresForDisplaywhich linearly stretches the population into[50, 85]for ranking purposes (see commit notes inrecommendation-engine.tsnearboostRecoScoresForDisplay).- Component renders
RecoHeroCard.ScoreBreakdownBarsandRecoCard.ExpandedDetail.
Where the divergences came from
| # | Divergence | Root cause |
|---|---|---|
| 1 | Filing Recency 33 / 20 |
UI hardcoded max: 20 from the v3 fr weight table; CH actually uses rec: 45. Recency bar reached 33 because the raw component is correct, only the label cap was stale. |
| 2 | Insider Signal X / 35 |
Same. CH uses sig: 10, label said 35. The value 3 is actually 3 / 10. |
| 3 | Historical Win Rate X / 25 |
Same. CH uses wr: 35. Component value 12 is 12 / 35 (≈34 %), which now matches the header's quoted "31 %" win rate. |
| 4 | Expected Return X / 20 |
Same. CH uses ret: 10. 5 / 10 (50 %) is the correct framing for a +5.5 % expected return on a side with mean ~1 %, σ ~8 %. |
| 5 | Composite total 53 ≠ header 85 |
boostRecoScoresForDisplay rescaled recoScore to 85 for the badge but did not touch the breakdown points. The breakdown was therefore reporting the raw composite while the header reported the boosted display score. |
| 6 | "Estimated T+90 return +5.5 %" vs Expected Return 5 / 20 |
Same as #4. Conversion is z-score based, not linear; 5 / 10 is correct given side priors. |
| 7 | Hardcoded maxes were also wrong for the other 27 markets with MARKET_WEIGHTS != { 35, 25, 20, 20 } (FR, US, DE, UK, CA, IT, ES, NL, BE, NO, FI, DK, AU, AT, IE, KR, IN, JP, HK, BR, SA, BG, SG, ZA, NZ, PH...). All non-FR cards rendered with the wrong cap labels. |
Divergences fixed: 7.
Design decision
User requested Option A: "breakdown must add up to the displayed score". We implemented it via proportional rescaling rather than dropping the boost:
- The scoring math in
buildRecoItemis unchanged (V12 production stable). - Each
RecoItemnow carriesscoreBreakdownMax(per-market) andcompositeRaw(the un-boosted composite).sum(scoreBreakdown.*) === compositeRawafterbuildRecoItem. boostRecoScoresForDisplaynow rescales the breakdown points AND the maxes by the same multiplicative factorboosted / rawSum, then reconciles the rounded sum to exactly equal the boosted score (largest-remainder method). After the boost:sum(scoreBreakdown.*) === recoScorealways. Bar fill ratios are preserved (pts / maxis invariant under the rescale).- The "Composite total" line on the hero card now also surfaces the raw score
parenthetically (
(raw 53)) with a tooltip explaining the normalisation. This preserves user trust by exposing both numbers without confusion. RecoHeroCard,RecoCard, andRecoConvictionGridall consumeitem.scoreBreakdownMaxinstead of hardcoded values.
Why proportional scaling rather than killing the boost: the boost is required
by applyPremiumDefaults (filter floor recoScore >= 60); removing it would
empty the /recommendations page for non-FR markets. Tightening the filter
floor instead would change ranking semantics, which is out of scope here.
Validation (post-fix)
npx tsc --noEmit: 0 errors.npm run lint:emdash: clean.npm run lint:emoji: clean (443 files scanned).sum(scoreBreakdown)invariant: enforced by largest-remainder reconciliation insideboostRecoScoresForDisplay. Sum equalsrecoScoreto the unit.- Per-market cap invariant: enforced by
Math.min(W.<axis>, ...)defensive clamp at construction time.Filing Recency 33 / 20cannot recur.
Files touched
src/lib/recommendation-engine.ts—RecoItemextended withscoreBreakdownMax+compositeRaw; defensive cap clamp in builder;boostRecoScoresForDisplaynow rescales breakdown.src/components/RecoHeroCard.tsx—ScoreBreakdownBarsaccepts per-axis max props; composite total line now surfaces the raw composite.src/components/RecoCard.tsx—ExpandedDetailreadsitem.scoreBreakdownMaxfor segment caps.src/components/RecoConvictionGrid.tsx—RadarSmallaccepts per-axis max props.src/app/recommendations/page.tsx—maskRecoszeroes the new optional fields for unauthenticated visitors (PII-leak parity).