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:
Kev
2026-07-13 14:04:21 -04:00
8 changed files with 454 additions and 27 deletions
+19
View File
@@ -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
+130
View File
@@ -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 -1
View File
@@ -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
+4 -26
View File
@@ -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>
); );
} }
+15
View File
@@ -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 }}>
+176
View File
@@ -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>
);
}
+1
View File
@@ -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';
+99
View File
@@ -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,
};