'use strict'; /** * MULTI-AXIS ARCHETYPE CLASSIFIER (Layer 2, Session 69). * * A player is not one label. He is a BLEND across INDEPENDENT axes — Skubal is * a STARTER *and* a PUNCHOUT arm *and* a CHASE-BAIT arm, three true things at * once. Single-label classification is lossy, and its failure mode is the FLEX * disease: when nothing matches, invent a bucket. * * THERE IS NO FALLBACK HERE. Unclassified on an axis = absent on that axis. A * genuinely unremarkable player surfaces one axis, or none, and "no standout * profile" is the honest output. `classify()`'s FLEX-at-weight-1.0 is replaced. * * ── AXIS INDEPENDENCE, MEASURED (not assumed) ──────────────────────────── * Correlations over the real store (467 batters PA>=50, 531 pitchers IP>=10). * Anything |r| >= 0.70 is ONE underlying trait and was COLLAPSED, so we never * show one trait as two archetypes (fake granularity): * * COLLAPSED — batter * k% ~ whiff% r = +0.89 -> one SWING-AND-MISS axis * hard-hit% ~ avg exit velo r = +0.88 -> folded into POWER as intensity * chase% ~ swing% r = +0.87 -> one AGGRESSION axis * chase% ~ bb% r = -0.72 -> same axis, inverted (discipline) * barrel% ~ hard-hit% r = +0.70 -> at the line; barrel leads, hard-hit * is an intensity input, not an axis * COLLAPSED — pitcher * k% ~ whiff% r = +0.76 -> one STRIKEOUT axis * gb% ~ fb% r = -0.73 -> one signed BATTED-BALL TILT axis * * INDEPENDENT — kept as real, separate axes * pitcher k% ~ gb% r = -0.10 PUNCHOUT and SINKER are orthogonal * pitcher velo ~ k% r = +0.14 velocity is its OWN trait, not a * pitcher velo ~ whiff% r = +0.07 proxy for missing bats * pitcher velo ~ gb% r = +0.07 * pitcher arm angle ~ gb% r = -0.17 * batter barrel ~ launch r = +0.23 power and launch are separate * batter k% ~ chase% r = +0.05 whiffing and chasing are separate * * ── CUT-LINES ARE REAL PERCENTILES ─────────────────────────────────────── * p75 = distinctive, p90 = elite, measured on this season's store — PER ROLE * where the tails differ (reliever GB% p90 is 54.1 vs a starter's 48.9, even * though the medians are identical at 42.5). No guessed numbers. */ /** Sample floors. Below these an axis is ABSENT — the structural prevention of * classifying a 12-PA cameo as confidently as a 400-PA season. */ const MIN_PA = 50; const MIN_IP = 10; /** Tier thresholds, from the measured distribution. `hi` = p75 (distinctive), * `elite` = p90. `dir: -1` means LOW values are the distinctive end. */ const B = (hi, elite, dir = 1) => ({ hi, elite, dir }); const BATTER_AXES = Object.freeze({ power: { name: 'POWER', metric: 'barrel_pct', band: B(10.6, 13.3), // hard-hit% and exit velo ride along as intensity (r=0.70/0.69) rather than // as their own axes — one trait, one archetype. tiers: { hi: 'SLUGGER', elite: 'BOMBER' }, }, swing_miss: { name: 'SWING & MISS', metric: 'k_pct', band: B(27.4, 31.9), tiers: { hi: 'WHIFF RISK', elite: 'WINDMILL' }, }, contact: { name: 'CONTACT', metric: 'k_pct', band: B(17.7, 14.0, -1), tiers: { hi: 'TECHNICIAN', elite: 'SURGEON' }, }, patience: { name: 'PLATE DISCIPLINE', metric: 'chase_pct', band: B(26.2, 22.7, -1), tiers: { hi: 'GRINDER', elite: 'SNIPER' }, }, aggression: { name: 'AGGRESSION', metric: 'chase_pct', band: B(34.8, 39.7), tiers: { hi: 'FREE SWINGER', elite: 'HACKER' }, }, launch: { name: 'LAUNCH', metric: 'avg_launch_angle', band: B(17.2, 20.1), tiers: { hi: 'TOPSPIN', elite: 'LOFT' }, }, line_drive: { name: 'LINE DRIVE', metric: 'sweet_spot_pct', band: B(36.6, 40.0), tiers: { hi: 'SLASH', elite: 'BARREL FINDER' }, }, }); /** Pitcher bands differ by role where the TAILS differ, even where the medians * agree. Measured: SP n=123, RP n=408. */ const PITCHER_AXES = Object.freeze({ strikeout: { name: 'STRIKEOUT', metric: 'k_pct', byRole: { starter: B(25.4, 28.6), default: B(26.3, 29.5) }, tiers: { hi: 'PUNCHOUT', elite: 'WHIFF' }, }, ground_ball: { name: 'GROUND BALL', metric: 'gb_pct', byRole: { starter: B(45.7, 48.9), default: B(48.0, 54.1) }, tiers: { hi: 'SINKER', elite: 'SEAM' }, }, fly_ball: { name: 'FLY BALL', metric: 'fb_pct', byRole: { starter: B(28.8, 31.1), default: B(31.4, 35.6) }, tiers: { hi: 'FLY BALL', elite: 'ELEVATOR' }, }, control: { name: 'CONTROL', metric: 'bb_pct', byRole: { starter: B(6.5, 5.5, -1), default: B(7.5, 6.0, -1) }, tiers: { hi: 'SURGEON ARM', elite: 'PINPOINT' }, }, wild: { name: 'WILDNESS', metric: 'bb_pct', byRole: { starter: B(9.0, 10.7), default: B(12.1, 14.9) }, tiers: { hi: 'NIBBLER', elite: 'SCATTERGUN' }, }, chase: { name: 'CHASE BAIT', metric: 'chase_pct', byRole: { starter: B(33.1, 34.6), default: B(32.5, 35.2) }, tiers: { hi: 'BAIT', elite: 'TRAPDOOR' }, }, contact_allowed: { name: 'HARD CONTACT ALLOWED', metric: 'barrel_pct', byRole: { starter: B(9.7, 10.8), default: B(9.5, 12.0) }, tiers: { hi: 'HITTABLE', elite: 'BATTING PRACTICE' }, }, velocity: { // Measured independent of k% (0.14), whiff% (0.07) and gb% (0.07) — this is // genuinely its own trait, which is why it earns an axis rather than being // folded into STRIKEOUT. Coverage recovered 53% -> 99% by the Session-69 // velo fix, so it is buildable rather than shelved. name: 'VELOCITY', metric: '_velo', byRole: { default: B(96.2, 97.7) }, tiers: { hi: 'CANNON', elite: 'HOWITZER' }, }, slot: { name: 'ARM SLOT', metric: 'arm_angle', byRole: { default: B(29.5, 20.1, -1) }, tiers: { hi: 'SIDEARM', elite: 'SUBMARINE' }, }, }); /** Role axis — from TRUE usage (statsapi gamesStarted/saves/holds), never the * IP proxy. Always surfaced when known: it is context, not a distinction. */ const ROLE_LABEL = Object.freeze({ starter: 'STARTER', closer: 'CLOSER', setup: 'SETUP', reliever: 'RELIEVER', }); const num = (v) => { if (v == null || v === '') return null; const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : null; }; function bandFor(axis, roleDetail) { if (axis.band) return axis.band; const by = axis.byRole || {}; return by[roleDetail] || by.default || null; } /** * scoreAxis — returns { tier, label, value, strength } or NULL. * NULL means one of three honest things, never "average archetype": * the metric is absent · the sample is too thin · the player is unremarkable * on this axis (between the tails). */ function scoreAxis(axis, row, roleDetail) { const v = axis.metric === '_velo' ? primaryVelo(row) : num(row[axis.metric]); if (v == null) return null; const band = bandFor(axis, roleDetail); if (!band) return null; const { hi, elite, dir } = band; const passHi = dir > 0 ? v >= hi : v <= hi; if (!passHi) return null; // league-average on this axis → ABSENT, not "mid" const passElite = dir > 0 ? v >= elite : v <= elite; // strength: 0.5 at the p75 line, 1.0 at p90 and beyond. Used to rank which // traits are the most distinctive, never rendered as a probability. const span = Math.abs(elite - hi) || 1; const over = Math.abs(v - hi) / span; return { axis: axis.name, tier: passElite ? 'elite' : 'hi', label: passElite ? axis.tiers.elite : axis.tiers.hi, value: v, strength: Math.min(1, 0.5 + over * 0.5), }; } function primaryVelo(row) { const mix = row.pitch_mix; if (!Array.isArray(mix) || !mix.length) return null; const withVelo = mix.filter((p) => num(p.velo) != null); if (!withVelo.length) return null; // The fastest offering — a CANNON is defined by his best velo, not his mix. return Math.max(...withVelo.map((p) => num(p.velo))); } /** * classifyPlayer(row) — the multi-axis result. * * { * role: 'batter'|'pitcher', roleDetail, sufficient, * vector: { axisKey: {tier,label,value,strength} | null }, ← Layer 3 reads ALL * blend: [ {label, axis, tier, strength} ], ← top 2-3 to SURFACE * absent: [axisKey…], ← axes with no data (distinct from unremarkable) * note ← honest copy when the blend is empty * } */ function classifyPlayer(row = {}) { const isPitcher = row.role === 'pitcher'; const axes = isPitcher ? PITCHER_AXES : BATTER_AXES; const roleDetail = row.role_detail || null; const sample = isPitcher ? num(row.sample_ip) : num(row.sample_pa); const floor = isPitcher ? MIN_IP : MIN_PA; const sufficient = sample != null && sample >= floor; const vector = {}; const absent = []; for (const [key, axis] of Object.entries(axes)) { if (!sufficient) { vector[key] = null; absent.push(key); continue; } const metricPresent = axis.metric === '_velo' ? primaryVelo(row) != null : num(row[axis.metric]) != null; if (!metricPresent) { vector[key] = null; absent.push(key); continue; } vector[key] = scoreAxis(axis, row, roleDetail); } // Mutually-exclusive pairs live on ONE underlying axis; only one end can fire, // and if both somehow did we keep the stronger rather than showing a player as // both patient and aggressive. for (const [a, b] of [['contact', 'swing_miss'], ['patience', 'aggression'], ['control', 'wild'], ['ground_ball', 'fly_ball']]) { if (vector[a] && vector[b]) { if (vector[a].strength >= vector[b].strength) vector[b] = null; else vector[a] = null; } } const scored = Object.entries(vector) .filter(([, v]) => v) .map(([key, v]) => ({ key, ...v })) .sort((x, y) => (y.tier === 'elite') - (x.tier === 'elite') || y.strength - x.strength); // Surface the top 3 genuinely distinctive traits. NO filler: a player above // no p75 line surfaces nothing. const blend = scored.slice(0, 3).map((s) => ({ label: s.label, axis: s.axis, key: s.key, tier: s.tier, value: s.value, strength: Math.round(s.strength * 100) / 100, })); let note = null; if (!sufficient) { note = `Not enough ${isPitcher ? 'innings' : 'plate appearances'} yet — no profile claimed.`; } else if (!blend.length) { note = 'No standout profile — league-average across every measured axis.'; } return { role: isPitcher ? 'pitcher' : 'batter', roleDetail, roleLabel: roleDetail ? ROLE_LABEL[roleDetail] || null : null, sufficient, sample, bats: row.bats || null, throws: row.throws || null, vector, blend, absent, note, }; } module.exports = { classifyPlayer, scoreAxis, primaryVelo, BATTER_AXES, PITCHER_AXES, ROLE_LABEL, MIN_PA, MIN_IP, __internals: { bandFor, num }, };