diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 082e2db..672daf7 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -251,6 +251,25 @@ describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => { expect(agg.by_tier.B.settled).toBe(3); expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building }); + + test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => { + const sb = fakeSb(); + const rows = [ + ...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })), + ...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })), + ...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })), + ]; + sb._state.selectResults = [rows]; + sb._state.countResult = 0; + const agg = await ledger.getModelAggregate({ sb }); + // A+ is its own bucket — never merged into the first-letter A bucket. + expect(agg.by_tier['A+'].settled).toBe(5); + expect(agg.by_tier['A+'].hits).toBe(5); + // A + A- fold together by first letter (but NOT A+). + expect(agg.by_tier.A.settled).toBe(6); + expect(agg.by_tier.A.hits).toBe(4); + expect(agg.by_tier.A.misses).toBe(2); + }); }); // Session 61 — the odds index prefers a fully-priced book row (the null diff --git a/tests/unit/tierRecord.test.js b/tests/unit/tierRecord.test.js new file mode 100644 index 0000000..a7d7469 --- /dev/null +++ b/tests/unit/tierRecord.test.js @@ -0,0 +1,130 @@ +// Wave 3 (Addition 2) — RECORD BY GRADE TIER. +// The tier calibration IS the credibility: A+ wins more than A, A more than +// B. This suite locks (a) the shared row-building logic, (b) the tier order, +// and (c) that the ONE shared TierRecord component is imported + rendered on +// all three record surfaces (dashboard, /u, ledger). Plain-JS Jest — the .tsx +// is asserted against source (same pattern as publicProfilePage/vyndrAppShell). + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const exists = (rel) => fs.existsSync(path.join(WEB, rel)); + +const { buildTierRows, TIER_ORDER, anyTierReady, isEdgeTier } = require('../../web/src/lib/tierRecord'); + +describe('buildTierRows — honesty rules', () => { + // A ≥20 tier carries a real hit_pct (gate applied upstream); a small tier + // arrives with hit_pct null even though it has settles. + const byTier = { + 'A+': { settled: 22, hits: 18, misses: 4, pushes: 0, hit_pct: 82 }, + A: { settled: 25, hits: 17, misses: 8, pushes: 0, hit_pct: 68 }, + B: { settled: 3, hits: 2, misses: 1, pushes: 0, hit_pct: null }, // small sample + }; + + test('shows each tier W-L record ALWAYS, even at small n', () => { + const rows = buildTierRows(byTier); + const b = rows.find((r) => r.tier === 'B'); + expect(b).toBeTruthy(); + expect(b.record).toBe('2-1'); // honest at n=3 + const ap = rows.find((r) => r.tier === 'A+'); + expect(ap.record).toBe('18-4'); + }); + + test('shows the hit-% ONLY when hit_pct != null; else RECORD BUILDING', () => { + const rows = buildTierRows(byTier); + const ap = rows.find((r) => r.tier === 'A+'); + expect(ap.ready).toBe(true); + expect(ap.hitPct).toBe(82); + + // Small-sample tier: no percentage, building copy with the settled count. + const b = rows.find((r) => r.tier === 'B'); + expect(b.ready).toBe(false); + expect(b.hitPct).toBeNull(); + expect(b.buildingLabel).toBe('RECORD BUILDING · 3 settled'); + }); + + test('never fabricates a 0% from a null hit_pct (Number(null)===0 guard)', () => { + const rows = buildTierRows({ C: { settled: 5, hits: 0, misses: 0, pushes: 0, hit_pct: null } }); + expect(rows[0].hitPct).toBeNull(); + expect(rows[0].ready).toBe(false); + }); + + test('omits tiers with zero settled reads (no fake empty rows)', () => { + const rows = buildTierRows({ A: { settled: 0, hits: 0, misses: 0, hit_pct: null }, B: { settled: 4, hits: 3, misses: 1, hit_pct: null } }); + expect(rows.map((r) => r.tier)).toEqual(['B']); + }); + + test('empty / missing map → no rows (self-hides)', () => { + expect(buildTierRows(null)).toEqual([]); + expect(buildTierRows(undefined)).toEqual([]); + expect(buildTierRows({})).toEqual([]); + }); +}); + +describe('tier order + edge color contract', () => { + test('renders A+ → F in canonical order', () => { + expect(TIER_ORDER).toEqual(['A+', 'A', 'B', 'C', 'D', 'F']); + }); + + test('buildTierRows preserves A+ → F order regardless of map insertion order', () => { + const rows = buildTierRows({ + F: { settled: 2, hits: 0, misses: 2, hit_pct: null }, + A: { settled: 3, hits: 2, misses: 1, hit_pct: null }, + 'A+': { settled: 4, hits: 4, misses: 0, hit_pct: null }, + C: { settled: 5, hits: 2, misses: 3, hit_pct: null }, + }); + expect(rows.map((r) => r.tier)).toEqual(['A+', 'A', 'C', 'F']); + }); + + test('GLOW RESERVED FOR A-TIER — only A+/A are edge tiers', () => { + expect(isEdgeTier('A+')).toBe(true); + expect(isEdgeTier('A')).toBe(true); + expect(isEdgeTier('B')).toBe(false); + expect(isEdgeTier('C')).toBe(false); + expect(isEdgeTier('F')).toBe(false); + }); + + test('anyTierReady reflects the upstream n≥20 gate', () => { + expect(anyTierReady({ A: { settled: 5, hits: 3, misses: 2, hit_pct: null } })).toBe(false); + expect(anyTierReady({ A: { settled: 25, hits: 17, misses: 8, hit_pct: 68 } })).toBe(true); + }); +}); + +describe('the ONE shared component reaches all three record surfaces', () => { + test('TierRecord.tsx exists and is exported from the vyndr barrel', () => { + expect(exists('components/vyndr/TierRecord.tsx')).toBe(true); + expect(read('components/vyndr/index.ts')).toContain("export { default as TierRecord } from './TierRecord'"); + }); + + test('TierRecord renders W-L always + % only when ready, else BUILDING', () => { + const src = read('components/vyndr/TierRecord.tsx'); + expect(src).toContain('buildTierRows'); + expect(src).toContain('{r.record}'); // W-L always + expect(src).toContain('{r.hitPct}% HIT'); // % only in the ready branch + expect(src).toContain('RECORD BUILDING · {r.settled} settled'); + }); + + test('/u PublicProfile imports AND renders TierRecord', () => { + const src = read('app/u/[handle]/PublicProfile.tsx'); + expect(src).toMatch(/import TierRecord from '@\/components\/vyndr\/TierRecord'/); + expect(src).toContain(' { + const src = read('app/dashboard/page.tsx'); + expect(src).toMatch(/TierRecord/); + expect(src).toContain(' { + const src = read('app/ledger/page.tsx'); + expect(src).toContain('TierRecord'); + expect(src).toContain(' + {/* Wave 3 (Addition 2) — the model's record BY GRADE TIER. Self-fetches + /api/ledger/model (scoped to the selected sport) and fully self-hides + until a tier has a settled read — no empty header. The SAME shared + TierRecord that renders on /u and the ledger; the calibration + gradient is the proof higher grades win more. */} +
+ +
+ {/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on the varying signal (selectTopGrades: tier → confidence → edge) so the row isn't identical-weight noise; when tonight is empty we fall back to diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index b41528f..6a73355 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; -import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark } from '@/components/vyndr'; +import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** @@ -177,30 +177,6 @@ export default function LedgerPage() { ); } -const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F']; - -/** Session 60 (5.5) — per-tier calibration: the separation between tiers is - * the proof the grades mean something. A tier under n≥20 shows "building", - * never a small-sample percentage. Self-hides until ANY tier is ready. */ -function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { - const tiers = agg.by_tier || {}; - const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null); - if (!anyReady) return null; - return ( -
- {TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => { - const b = tiers[t]; - const ready = b.hit_pct != null; - return ( - - {t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`} - - ); - })} -
- ); -} - /** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat * distribution renders as one giant bar + six empty stubs — it reads as @@ -305,7 +281,9 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe )} - + {/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table, + identical here, on the dashboard, and on /u. */} + ); } diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index ed29f34..8272ee6 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import BookWordmark from '@/components/vyndr/BookWordmark'; +import TierRecord from '@/components/vyndr/TierRecord'; +import { buildTierRows } from '@/lib/tierRecord'; /** * PublicProfile (A1 Session 10) — the public ledger record for one handle. @@ -37,6 +39,8 @@ interface ProfileRow { revised_from_grade?: string | null; } +interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null } + interface ProfileAggregate { settled: number; hits: number; @@ -47,6 +51,9 @@ interface ProfileAggregate { clv_beat: number; beat_close_pct: number | null; pending: number; + // Wave 3 (Addition 2) — per-tier calibration (survives the API + proxy + // untouched, inside the aggregate). The credibility centerpiece. + by_tier?: Record; } interface ProfilePayload { @@ -126,6 +133,14 @@ export default function PublicProfile({ handle }: { handle: string }) { {/* Record header — never a percentage under min_sample settles. */} + {/* Wave 3 (Addition 2) — the record BY GRADE TIER, the proof that higher + grades win more. Same shared component as the dashboard + ledger. */} + {agg && buildTierRows(agg.by_tier || {}).length > 0 && ( +
+ +
+ )} + {data.entries.length === 0 ? (

diff --git a/web/src/components/vyndr/TierRecord.tsx b/web/src/components/vyndr/TierRecord.tsx new file mode 100644 index 0000000..a07de97 --- /dev/null +++ b/web/src/components/vyndr/TierRecord.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { buildTierRows } from '@/lib/tierRecord'; + +/** + * TierRecord (Wave 3, Addition 2) — the ONE shared record-by-grade-tier + * table. Rendered identically on the dashboard, the /u public profile, and + * the ledger, so the calibration story is told the same way everywhere. + * + * The tier gradient IS the credibility: A+ hits more than A, A more than B. + * Each tier shows its W-L record ALWAYS (honest even at small n); the hit-% + * only appears when the server already cleared the n≥20 gate (`hit_pct != + * null`) — below it, "RECORD BUILDING · N settled". The gate lives in + * getModelAggregate; this component never re-derives it. + * + * COLOR CONTRACT: A-tier (A+/A) earns the green edge color; everything below + * A stays neutral. Red is reserved for settled-negative outcomes — never a + * tier label. + * + * TWO MODES: + * • presentational — pass `byTier` (ledger + /u already hold the aggregate). + * • self-fetch — pass `sport?` (dashboard) → reads /api/ledger/model. + * All three surfaces render THIS component (the class fix). + */ + +interface TierBucket { + settled: number; + hits: number; + misses: number; + pushes?: number; + hit_pct: number | null; +} + +export default function TierRecord({ + byTier, + minSample = 20, + sport, + title = 'RECORD BY GRADE', + dense = false, +}: { + /** Presentational mode: the getModelAggregate().by_tier map. */ + byTier?: Record | null; + minSample?: number; + /** Self-fetch mode (no byTier): scope the /api/ledger/model read. */ + sport?: string; + title?: string; + /** Compact layout for tighter surfaces (dashboard). */ + dense?: boolean; +}) { + const [fetched, setFetched] = useState<{ by_tier?: Record; min_sample?: number } | null>(null); + + useEffect(() => { + if (byTier) return; // presentational — nothing to fetch + let active = true; + const params = new URLSearchParams({ limit: '1' }); + if (sport) params.set('sport', sport.toLowerCase()); + fetch(`/api/ledger/model?${params}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (!active || !data?.aggregate) return; + setFetched({ by_tier: data.aggregate.by_tier, min_sample: data.min_sample }); + }) + .catch(() => { /* self-hide */ }); + return () => { active = false; }; + }, [byTier, sport]); + + const tiers = byTier || fetched?.by_tier || {}; + const ms = byTier ? minSample : Number(fetched?.min_sample) > 0 ? Number(fetched?.min_sample) : minSample; + const rows = buildTierRows(tiers, ms); + if (rows.length === 0) return null; // no settled reads in any tier yet + + return ( +

+
+ {title} · CALIBRATION +
+
+ {rows.map((r) => ( +
+ {/* Tier label — green only for A-tier (the edge). */} + + {r.tier}-TIER + + {/* W-L record — ALWAYS shown, honest at any n. */} + + {r.record} + + {/* Hit-% ONLY past the n≥20 gate; else building copy. */} + {r.ready ? ( + + {r.hitPct}% HIT + + ) : ( + + RECORD BUILDING · {r.settled} settled + + )} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 082db2b..abc0ddf 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -18,6 +18,7 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f export { default as ClaimMeter } from './ClaimMeter'; export { default as AccuracyBadge } from './AccuracyBadge'; export { default as ModelRecord } from './ModelRecord'; +export { default as TierRecord } from './TierRecord'; /* Player Intelligence (Session 42) */ export { default as ArchetypeBadge } from './ArchetypeBadge'; diff --git a/web/src/lib/tierRecord.js b/web/src/lib/tierRecord.js new file mode 100644 index 0000000..4e848f5 --- /dev/null +++ b/web/src/lib/tierRecord.js @@ -0,0 +1,99 @@ +/* ============================================================ + VYNDR — RECORD BY GRADE TIER (Wave 3, Addition 2). + Plain CommonJS so the TierRecord.tsx component imports it AND + the Jest suite can require it directly (no TS/Babel transform). + + The credibility of the model IS the tier calibration: A+ wins + more than A, A more than B, and so on. A single blended "67%" + hides that gradient. This module turns a `by_tier` map (from + ledgerService.getModelAggregate) into ordered, display-ready + rows so EVERY surface (dashboard, /u, ledger) renders the same + honest table. + + HONESTY RULES (do not soften): + - The W-L record (counts) is shown ALWAYS, even at small n — + an honest 3-1 beats hiding it. + - The hit-% is shown ONLY when that tier's `hit_pct != null` + (the n≥20 gate is already applied UPSTREAM in + getModelAggregate — never re-derive it here). Below the gate: + "RECORD BUILDING · N settled". + - A+ stands alone; A/B/C/D/F bucket by first letter (mirrors + outcomeService + getModelAggregate's `tierOf`). + ============================================================ */ + +/* Canonical render order. A+ is its own bucket; the rest are + first-letter buckets. Mirrors getModelAggregate.tierOf. */ +const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F']; + +/* A-tier (A+ / A) is the only bucket that earns the green edge + color — "GLOW RESERVED FOR A-TIER ONLY" (design reference §05). + Everything below A stays neutral/muted; red is reserved for + settled-negative outcomes, never a tier label. */ +function isEdgeTier(tier) { + return tier === 'A+' || tier === 'A'; +} + +/* One tier's display row. `hitPct` is passed through verbatim — + null means "below the n≥20 gate" and the row renders BUILDING. */ +function buildTierRow(tier, bucket, minSample) { + const settled = Number(bucket && bucket.settled) || 0; + const hits = Number(bucket && bucket.hits) || 0; + const misses = Number(bucket && bucket.misses) || 0; + const pushes = Number(bucket && bucket.pushes) || 0; + // hit_pct is the gated value from the server — strict null check + // (Number(null) === 0 would fabricate a 0% record). + const hitPct = bucket && bucket.hit_pct != null ? Number(bucket.hit_pct) : null; + const ready = hitPct != null; + return { + tier, + settled, + hits, + misses, + pushes, + record: `${hits}-${misses}`, + hitPct, + ready, + edge: isEdgeTier(tier), + // BUILDING copy the UI shows in place of a small-sample %. + buildingLabel: `RECORD BUILDING · ${settled} settled`, + minSample: Number(minSample) || 20, + }; +} + +/** + * Turn a `by_tier` map into ordered rows, one per tier that has + * at least one settled read. Order is always A+ → F. Tiers with + * zero settles are omitted (nothing to prove yet — never a fake + * empty row). + * + * @param {Object} byTier getModelAggregate().by_tier + * @param {number} [minSample=20] + * @returns {Array} ordered display rows + */ +function buildTierRows(byTier, minSample = 20) { + const map = byTier && typeof byTier === 'object' ? byTier : {}; + const rows = []; + for (const tier of TIER_ORDER) { + const bucket = map[tier]; + if (!bucket) continue; + const row = buildTierRow(tier, bucket, minSample); + if (row.settled <= 0) continue; // no settled reads → not shown + rows.push(row); + } + return rows; +} + +/** True when at least one tier has a real (gated) hit-% to show — + * a surface can use this to decide whether to show the "PROVEN" + * framing vs the plain building table. */ +function anyTierReady(byTier) { + return buildTierRows(byTier).some((r) => r.ready); +} + +module.exports = { + TIER_ORDER, + isEdgeTier, + buildTierRow, + buildTierRows, + anyTierReady, +};