/* ============================================================ 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, };