/* ============================================================ Session 69 — LAYER 2: the multi-axis archetype classifier. A player is a BLEND across independent axes, not one label. These lock the three things that make that honest: axes that are genuinely independent, cut-lines at real percentiles, and NO fallback bucket anywhere. ============================================================ */ const axes = require('../../src/services/archetypeAxes'); const { classifyPlayer, BATTER_AXES, PITCHER_AXES } = axes; // Real rows from statcast_aggregates (2026-07-21). const SKUBAL = { role: 'pitcher', role_detail: 'starter', throws: 'L', sample_ip: 82.2, k_pct: 30.5, bb_pct: 3.4, chase_pct: 36.7, barrel_pct: 6.7, gb_pct: 49, fb_pct: 22.6, arm_angle: 46.9, pitch_mix: [{ type: 'FF', velo: 96.7 }, { type: 'CH', velo: 87.3 }, { type: 'SL', velo: 89.4 }], }; const JUDGE = { role: 'batter', bats: 'R', sample_pa: 261, k_pct: 27.6, bb_pct: 16.1, chase_pct: 25.8, barrel_pct: 21.7, hard_hit_pct: 57.3, avg_launch_angle: 14.6, sweet_spot_pct: 33.6, }; const KWAN = { role: 'batter', bats: 'L', sample_pa: 365, k_pct: 9.9, bb_pct: 12.9, chase_pct: 22.1, barrel_pct: 0.4, hard_hit_pct: 9.7, avg_launch_angle: 14, sweet_spot_pct: 39.2, }; const BELL = { role: 'batter', bats: 'S', sample_pa: 387, k_pct: 21.7, bb_pct: 7.5, chase_pct: 30.6, barrel_pct: 10.3, hard_hit_pct: 43.4, avg_launch_angle: 13.9, sweet_spot_pct: 33.8, }; const RODEN_THIN = { role: 'batter', bats: 'L', sample_pa: 21, k_pct: 19, chase_pct: 30.4, barrel_pct: 13.3 }; describe('multi-axis blend — several true things at once', () => { it('Skubal is a STARTER and a strikeout arm and a ground-ball arm and a control arm', () => { const r = classifyPlayer(SKUBAL); expect(r.roleLabel).toBe('STARTER'); const labels = r.blend.map((b) => b.label); expect(labels).toContain('WHIFF'); // k% 30.5 ≥ starter p90 28.6 expect(labels).toContain('SEAM'); // gb% 49 ≥ starter p90 48.9 expect(labels).toContain('PINPOINT'); // bb% 3.4 ≤ starter elite 5.5 expect(r.blend.length).toBeGreaterThanOrEqual(3); }); it('surfaces at most 3 traits but stores the FULL vector for Layer 3', () => { const r = classifyPlayer(SKUBAL); expect(r.blend.length).toBeLessThanOrEqual(3); expect(Object.keys(r.vector)).toEqual(Object.keys(PITCHER_AXES)); expect(Object.values(r.vector).filter(Boolean).length).toBeGreaterThan(r.blend.length); }); it('Judge: elite power AND patient AND strikeout-prone — all three real', () => { const labels = classifyPlayer(JUDGE).blend.map((b) => b.label); expect(labels).toContain('BOMBER'); expect(labels).toContain('GRINDER'); expect(labels).toContain('WHIFF RISK'); }); it('Kwan: elite contact AND elite discipline, with NO power claimed', () => { const r = classifyPlayer(KWAN); const labels = r.blend.map((b) => b.label); expect(labels).toContain('SURGEON'); expect(labels).toContain('SNIPER'); expect(labels).not.toContain('SLUGGER'); expect(labels).not.toContain('BOMBER'); expect(r.vector.power).toBeNull(); // 0.4 barrel% — absent, not "low power" }); }); describe('NO FALLBACK — the FLEX disease is structurally impossible', () => { it('an unremarkable player surfaces NOTHING and says so', () => { const r = classifyPlayer(BELL); expect(r.blend).toEqual([]); expect(r.sufficient).toBe(true); expect(r.note).toMatch(/No standout profile/); // He used to classify as DRIVER. Being average is not an archetype. }); it('a thin sample claims NOTHING — every axis absent, not a guess', () => { const r = classifyPlayer(RODEN_THIN); expect(r.sufficient).toBe(false); expect(r.blend).toEqual([]); expect(r.absent.length).toBe(Object.keys(BATTER_AXES).length); expect(r.note).toMatch(/Not enough plate appearances/); // Alan Roden was FLEX at weight 1.0 — a confident label on 21 PA. }); it('classify() no longer emits a fallback archetype for ANY sport', () => { const { classify } = require('../../src/services/archetypeService'); for (const sport of ['mlb', 'nba', 'wnba', 'mma']) { const r = classify(sport, {}); expect(r.primary).toBeNull(); expect(r.blend).toEqual([]); } }); it('never pads the blend to a fixed length', () => { expect(classifyPlayer(KWAN).blend.length).toBeLessThanOrEqual(3); expect(classifyPlayer(BELL).blend.length).toBe(0); }); }); describe('honest-absent PER AXIS', () => { it('a velo-less pitcher keeps every other axis', () => { const r = classifyPlayer({ ...SKUBAL, pitch_mix: [{ type: 'FF', velo: null }] }); expect(r.vector.velocity).toBeNull(); expect(r.absent).toContain('velocity'); expect(r.blend.length).toBeGreaterThan(0); // other axes unaffected }); it('distinguishes NO DATA from LEAGUE-AVERAGE', () => { const noData = classifyPlayer({ role: 'batter', sample_pa: 300 }); expect(noData.absent).toContain('power'); // metric missing const average = classifyPlayer(BELL); expect(average.absent).not.toContain('power'); // metric present, just unremarkable expect(average.vector.power).toBeNull(); }); }); describe('axis independence — collapsed pairs cannot both fire', () => { it.each([ ['contact', 'swing_miss'], ['patience', 'aggression'], ])('%s and %s are one underlying axis', (a, b) => { for (const row of [JUDGE, KWAN, BELL]) { const v = classifyPlayer(row).vector; expect(v[a] && v[b]).toBeFalsy(); } }); it('pitcher ground-ball and fly-ball never both fire (r = -0.73)', () => { const v = classifyPlayer(SKUBAL).vector; expect(v.ground_ball && v.fly_ball).toBeFalsy(); }); it('velocity is its OWN axis — measured independent of strikeout (r = 0.14)', () => { // A hard thrower who misses no bats still earns CANNON; a soft-tossing // strikeout artist still earns WHIFF. Neither implies the other. const hardNoK = classifyPlayer({ role: 'pitcher', role_detail: 'starter', sample_ip: 80, k_pct: 18, pitch_mix: [{ type: 'FF', velo: 98.5 }], }); expect(hardNoK.blend.map((b) => b.label)).toContain('HOWITZER'); expect(hardNoK.vector.strikeout).toBeNull(); }); }); describe('cut-lines are the REAL measured percentiles, per role where tails differ', () => { it('reliever GB% needs a higher bar than a starter (p90 54.1 vs 48.9)', () => { const base = { role: 'pitcher', sample_ip: 40, gb_pct: 50 }; const sp = classifyPlayer({ ...base, role_detail: 'starter' }); const rp = classifyPlayer({ ...base, role_detail: 'reliever' }); expect(sp.vector.ground_ball.tier).toBe('elite'); // 50 ≥ SP p90 48.9 expect(rp.vector.ground_ball.tier).toBe('hi'); // 50 < RP p90 54.1 }); it('p75 is distinctive, p90 is elite', () => { const at75 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.6 }); const at90 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 13.3 }); expect(at75.vector.power.tier).toBe('hi'); expect(at90.vector.power.tier).toBe('elite'); const below = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.5 }); expect(below.vector.power).toBeNull(); }); it('sample floors gate every axis (PA>=50, IP>=10)', () => { expect(classifyPlayer({ role: 'batter', sample_pa: 49, barrel_pct: 25 }).blend).toEqual([]); expect(classifyPlayer({ role: 'pitcher', sample_ip: 9, k_pct: 40 }).blend).toEqual([]); expect(classifyPlayer({ role: 'batter', sample_pa: 50, barrel_pct: 25 }).blend.length).toBe(1); }); });