diff --git a/tests/unit/playerGrouping.test.js b/tests/unit/playerGrouping.test.js
index 05b67cb..988dfae 100644
--- a/tests/unit/playerGrouping.test.js
+++ b/tests/unit/playerGrouping.test.js
@@ -35,6 +35,15 @@ describe('dedupeLeaders', () => {
expect(out).toHaveLength(1);
});
+
+ test('reads player_name too (ledger rows) — not just player', () => {
+ const out = dedupeLeaders([
+ { player_name: 'Alec Bohm', stat: 'strikeouts', line: 1.6 },
+ { player_name: 'Alec Bohm', stat: 'strikeouts', line: 1.3 },
+ ]);
+ expect(out).toHaveLength(1);
+ });
+
test('empty / malformed → empty, no throw', () => {
expect(dedupeLeaders(null)).toEqual([]);
expect(dedupeLeaders([{}])).toEqual([]);
diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx
index 2c46a37..af923e0 100644
--- a/web/src/app/ledger/page.tsx
+++ b/web/src/app/ledger/page.tsx
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { GradePill } from '@/components/GradeCard';
+import { groupIntoLadders } from '@/lib/playerGrouping';
+import { gradeColor } from '@/lib/vyndrTokens';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
@@ -169,8 +171,8 @@ export default function LedgerPage() {
) : (
- {rows.map((row, i) => (
-
+ {groupIntoLadders(rows).map((g, i) => (
+
))}
)}
@@ -326,8 +328,10 @@ function ClvChip({ row }: { row: LedgerRow }) {
);
}
-function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
+function LedgerCard({ row, index, ladder }: { row: LedgerRow; index: number; ladder?: LedgerRow[] }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
+ const rungs = Array.isArray(ladder) ? ladder : [row];
+ const isLadder = rungs.length > 1;
return (
@@ -351,9 +355,27 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
)}
-
- {row.side} {row.line} {row.stat.replace(/_/g, ' ')}
-
+ {/* P0-3 — ONE card per player+market. Multiple lines nest as a ladder of
+ rungs (each its own side/line + grade) instead of N separate cards. */}
+ {isLadder ? (
+ <>
+
+ {row.stat.replace(/_/g, ' ')} · {rungs.length} lines
+
+
+ {rungs.map((rung) => (
+
+ {rung.side}{rung.line}
+ {rung.grade}
+
+ ))}
+
+ >
+ ) : (
+
+ {row.side} {row.line} {row.stat.replace(/_/g, ' ')}
+
+ )}
{row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
diff --git a/web/src/components/vyndr/MarketBreadth.tsx b/web/src/components/vyndr/MarketBreadth.tsx
index 6c36c87..598b47f 100644
--- a/web/src/components/vyndr/MarketBreadth.tsx
+++ b/web/src/components/vyndr/MarketBreadth.tsx
@@ -16,6 +16,7 @@
import SectionHead from './SectionHead';
import { edgeColor } from '@/lib/colorContract';
+import { dedupeLeaders } from '@/lib/playerGrouping';
export interface BreadthRow {
player?: string;
@@ -57,7 +58,10 @@ export default function MarketBreadth({
title?: string;
max?: number;
}) {
- const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max));
+ // P0-3 — group by player+market so a player's alt-line ladder shows as ONE
+ // consensus row (the audit's Ben Williamson ×5). No per-market cap here — a
+ // consensus table isn't a ranked board, it just shouldn't repeat a player.
+ const rows = dedupeLeaders(Array.isArray(items) ? items : [], 0).slice(0, Math.max(0, max));
if (rows.length === 0) return null; // self-hide — no honest consensus to show
return (
diff --git a/web/src/lib/playerGrouping.js b/web/src/lib/playerGrouping.js
index f8a599b..ca77852 100644
--- a/web/src/lib/playerGrouping.js
+++ b/web/src/lib/playerGrouping.js
@@ -37,8 +37,9 @@ function dedupeLeaders(rows, perMarketCap = 4) {
const marketCount = new Map();
const out = [];
for (const r of Array.isArray(rows) ? rows : []) {
- if (!r || !r.player) continue;
- const key = playerMarketKey(r.player, r.stat);
+ const who = r.player || r.player_name;
+ if (!r || !who) continue;
+ const key = playerMarketKey(who, r.stat);
if (seen.has(key)) continue; // one row per player+market
const fam = marketFamily(r.stat);
const n = marketCount.get(fam) || 0;
@@ -64,10 +65,11 @@ function groupIntoLadders(rows, betterOf) {
const byKey = new Map();
const order = [];
for (const r of Array.isArray(rows) ? rows : []) {
- if (!r || !r.player) continue;
- const key = playerMarketKey(r.player, r.stat);
+ const who = r.player || r.player_name;
+ if (!r || !who) continue;
+ const key = playerMarketKey(who, r.stat);
if (!byKey.has(key)) {
- byKey.set(key, { player: r.player, stat: r.stat, market: marketFamily(r.stat), primary: r, ladder: [r] });
+ byKey.set(key, { player: who, stat: r.stat, market: marketFamily(r.stat), primary: r, ladder: [r] });
order.push(key);
} else {
const g = byKey.get(key);