Merge Wave 3 (wiring/data): record by grade tier (Addition 2)
ONE shared TierRecord component (lib/tierRecord.js + TierRecord.tsx) on dashboard + /u + ledger — per-tier calibration (A+ X-Y, A X-Y, …), W-L always, hit-% only at n>=20 per tier. by_tier flows through all endpoints untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.settled).toBe(3);
|
||||||
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
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
|
// Session 61 — the odds index prefers a fully-priced book row (the null
|
||||||
|
|||||||
@@ -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('<TierRecord');
|
||||||
|
expect(src).toContain('by_tier'); // the aggregate carries the tier buckets
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard imports AND renders TierRecord', () => {
|
||||||
|
const src = read('app/dashboard/page.tsx');
|
||||||
|
expect(src).toMatch(/TierRecord/);
|
||||||
|
expect(src).toContain('<TierRecord');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ledger uses the shared TierRecord (single source, no inline table)', () => {
|
||||||
|
const src = read('app/ledger/page.tsx');
|
||||||
|
expect(src).toContain('TierRecord');
|
||||||
|
expect(src).toContain('<TierRecord');
|
||||||
|
// the old inline per-tier component is gone (folded into the shared one)
|
||||||
|
expect(src).not.toContain('function TierCalibration');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
|
|||||||
// below as intelligence layers on top of the raw odds.
|
// below as intelligence layers on top of the raw odds.
|
||||||
import Slate from '@/components/Slate';
|
import Slate from '@/components/Slate';
|
||||||
// Session 55 — the self-learning loop's track record, live in the header.
|
// Session 55 — the self-learning loop's track record, live in the header.
|
||||||
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
|
import { AccuracyBadge, Skeleton, SkeletonList, TierRecord } from '@/components/vyndr';
|
||||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
||||||
import { emptyStateCopy } from '@/lib/emptyState';
|
import { emptyStateCopy } from '@/lib/emptyState';
|
||||||
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
||||||
@@ -306,6 +306,15 @@ export default function DashboardPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<TierRecord sport={sport} title="MODEL RECORD BY GRADE" dense />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
|
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
|
||||||
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
the varying signal (selectTopGrades: tier → confidence → edge) so the
|
||||||
row isn't identical-weight noise; when tonight is empty we fall back to
|
row isn't identical-weight noise; when tonight is empty we fall back to
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
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';
|
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 (
|
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
|
||||||
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
|
|
||||||
const b = tiers[t];
|
|
||||||
const ready = b.hit_pct != null;
|
|
||||||
return (
|
|
||||||
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
|
|
||||||
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
|
/** 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
|
* 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
|
* distribution renders as one giant bar + six empty stubs — it reads as
|
||||||
@@ -305,7 +281,9 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ClvDistribution agg={agg} />
|
<ClvDistribution agg={agg} />
|
||||||
<TierCalibration agg={agg} minSample={minSample} />
|
{/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
|
||||||
|
identical here, on the dashboard, and on /u. */}
|
||||||
|
<TierRecord byTier={agg.by_tier} minSample={minSample} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { GradePill } from '@/components/GradeCard';
|
import { GradePill } from '@/components/GradeCard';
|
||||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
import BookWordmark from '@/components/vyndr/BookWordmark';
|
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.
|
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||||||
@@ -37,6 +39,8 @@ interface ProfileRow {
|
|||||||
revised_from_grade?: string | null;
|
revised_from_grade?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null }
|
||||||
|
|
||||||
interface ProfileAggregate {
|
interface ProfileAggregate {
|
||||||
settled: number;
|
settled: number;
|
||||||
hits: number;
|
hits: number;
|
||||||
@@ -47,6 +51,9 @@ interface ProfileAggregate {
|
|||||||
clv_beat: number;
|
clv_beat: number;
|
||||||
beat_close_pct: number | null;
|
beat_close_pct: number | null;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
// Wave 3 (Addition 2) — per-tier calibration (survives the API + proxy
|
||||||
|
// untouched, inside the aggregate). The credibility centerpiece.
|
||||||
|
by_tier?: Record<string, TierBucket>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfilePayload {
|
interface ProfilePayload {
|
||||||
@@ -126,6 +133,14 @@ export default function PublicProfile({ handle }: { handle: string }) {
|
|||||||
{/* Record header — never a percentage under min_sample settles. */}
|
{/* Record header — never a percentage under min_sample settles. */}
|
||||||
<RecordHeader agg={agg} minSample={minSample} />
|
<RecordHeader agg={agg} minSample={minSample} />
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="surface diagonal-cut" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<TierRecord byTier={agg.by_tier} minSample={minSample} dense />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.entries.length === 0 ? (
|
{data.entries.length === 0 ? (
|
||||||
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
||||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||||
|
|||||||
@@ -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<string, TierBucket> | 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<string, TierBucket>; 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 (
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
marginTop: dense ? 0 : 14,
|
||||||
|
paddingTop: dense ? 0 : 12,
|
||||||
|
borderTop: dense ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
color: 'var(--text-tertiary, #707080)',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title} · CALIBRATION
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="table"
|
||||||
|
aria-label="Model hit rate by grade tier"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: dense
|
||||||
|
? 'repeat(auto-fit, minmax(120px, 1fr))'
|
||||||
|
: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||||
|
gap: dense ? 8 : 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.tier}
|
||||||
|
role="row"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
padding: dense ? '10px 12px' : '12px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${r.edge ? 'var(--g-a, #00D4A0)' : 'var(--border-hi, #2A2A38)'}`,
|
||||||
|
// GLOW RESERVED FOR A-TIER ONLY — a faint green wash on A-tier,
|
||||||
|
// flat surface below. No red on any tier label.
|
||||||
|
background: r.edge
|
||||||
|
? 'color-mix(in srgb, var(--g-a, #00D4A0) 8%, transparent)'
|
||||||
|
: 'var(--bg-surface, #0C0C12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Tier label — green only for A-tier (the edge). */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: dense ? 13 : 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.tier}-TIER
|
||||||
|
</span>
|
||||||
|
{/* W-L record — ALWAYS shown, honest at any n. */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: dense ? 15 : 17,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.02em',
|
||||||
|
color: 'var(--text-primary, #F0F0F0)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.record}
|
||||||
|
</span>
|
||||||
|
{/* Hit-% ONLY past the n≥20 gate; else building copy. */}
|
||||||
|
{r.ready ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: dense ? 11 : 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.hitPct}% HIT
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: dense ? 9.5 : 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--amber, #FFB347)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RECORD BUILDING · {r.settled} settled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f
|
|||||||
export { default as ClaimMeter } from './ClaimMeter';
|
export { default as ClaimMeter } from './ClaimMeter';
|
||||||
export { default as AccuracyBadge } from './AccuracyBadge';
|
export { default as AccuracyBadge } from './AccuracyBadge';
|
||||||
export { default as ModelRecord } from './ModelRecord';
|
export { default as ModelRecord } from './ModelRecord';
|
||||||
|
export { default as TierRecord } from './TierRecord';
|
||||||
|
|
||||||
/* Player Intelligence (Session 42) */
|
/* Player Intelligence (Session 42) */
|
||||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user