Files
vyndr/web/src/lib/tierRecord.js
T
builtbykev 9bafc76092 Wave 3: Record by grade tier — shared TierRecord across dashboard, /u, ledger
Addition 2 (non-negotiable): the model's record must show PER GRADE TIER
(A+ went X-Y, A X-Y, …) everywhere the record appears. A blended % hides the
proof that higher grades win more — the tier calibration IS the credibility.

The backend already computed `by_tier` in getModelAggregate; this is display
propagation via ONE shared component (the class fix, not five one-offs).

- web/src/lib/tierRecord.js — testable CommonJS row-builder. W-L counts ALWAYS
  (honest at any n); hit-% only when the upstream n≥20 gate passed (hit_pct !=
  null), else "RECORD BUILDING · N settled". Order A+ A B C D F. A-tier is the
  only edge (green) tier — no glow below A, red reserved for outcomes.
- web/src/components/vyndr/TierRecord.tsx — the ONE shared table. Presentational
  (byTier) for /u + ledger; self-fetch (/api/ledger/model, sport-scoped) for the
  dashboard. Fully self-hides until a tier has a settled read.
- Ledger swaps its inline TierCalibration for the shared component (single
  source). /u PublicProfile renders it below the blended hero (by_tier added to
  the aggregate type; it already flows through the route + proxy untouched).
  Dashboard gains a compact per-tier surface.
- Endpoint/proxy audit: profiles.js + ledger.js return the full aggregate
  (by_tier included); both Next proxies pass the body through — no threading
  needed. No change to the gate or math in getModelAggregate.

Tests: tests/unit/tierRecord.test.js (row logic + tier order + edge contract +
source-assert all three surfaces render the shared component). ledgerService
test gains an A+-stands-alone bucketing case. Full suite green (243 suites /
2961 tests); web next build exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:58:01 -04:00

100 lines
3.6 KiB
JavaScript

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