diff --git a/docs/ARCHETYPE-AXES.md b/docs/ARCHETYPE-AXES.md new file mode 100644 index 0000000..1273750 --- /dev/null +++ b/docs/ARCHETYPE-AXES.md @@ -0,0 +1,90 @@ +# MLB ARCHETYPE AXES — Layer 2 + +A player is a **blend across independent axes**, not one label. Skubal is a +STARTER *and* a strikeout arm *and* a ground-ball arm *and* a control arm — +four 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.** Unremarkable on an axis → absent on that axis. A +genuinely average player surfaces nothing, and says so. + +## Axis independence — measured, not assumed + +Correlations over the live store (467 batters PA≥50, 531 pitchers IP≥10). +**|r| ≥ 0.70 = one underlying trait → collapsed**, so one trait is never shown +as two archetypes. + +| Pair | r | Decision | +|---|---|---| +| batter k% ~ whiff% | **+0.89** | collapsed → one SWING-AND-MISS axis | +| batter hard-hit% ~ avg exit velo | **+0.88** | folded into POWER as intensity | +| batter chase% ~ swing% | **+0.87** | collapsed → one AGGRESSION axis | +| batter chase% ~ bb% | **−0.72** | same axis inverted (discipline) | +| pitcher k% ~ whiff% | **+0.76** | collapsed → one STRIKEOUT axis | +| pitcher gb% ~ fb% | **−0.73** | collapsed → one signed tilt axis | +| batter barrel% ~ hard-hit% | +0.70 | at the line; barrel leads | +| **pitcher velo ~ k%** | **+0.14** | **INDEPENDENT** — velo earns its own axis | +| pitcher velo ~ whiff% | +0.07 | independent | +| pitcher velo ~ gb% | +0.07 | independent | +| **pitcher k% ~ gb%** | **−0.10** | **INDEPENDENT** — PUNCHOUT ⊥ SINKER | +| batter barrel ~ launch | +0.23 | independent | +| batter k% ~ chase% | +0.05 | independent | + +The velocity result matters: **velo is not a proxy for missing bats.** A hard +thrower who misses no bats is a real, distinct type. + +## Cut-lines + +`p75` = distinctive · `p90` = elite, from the measured distribution — **per +role where the tails differ** even when medians agree (reliever GB% p90 = 54.1 +vs starter 48.9, both median 42.5). Sample floors: **PA ≥ 50 · IP ≥ 10**. + +## Every baseball name accounted for + +| Name | Status | Axis / reason | +|---|---|---| +| WORKHORSE | **TIER** | role=starter + high IP; the old hardcoded default is gone | +| PUNCHOUT / WHIFF | **BUILT** | strikeout (p75 / p90) | +| SINKER / SEAM | **BUILT** | ground_ball | +| FLY BALL / ELEVATOR | **BUILT** | fly_ball | +| SURGEON ARM / PINPOINT | **BUILT** | control (low BB%) | +| NIBBLER / SCATTERGUN | **BUILT** | wild (high BB%) | +| BAIT / TRAPDOOR | **BUILT** | chase | +| HITTABLE / BATTING PRACTICE | **BUILT** | contact_allowed | +| CANNON / HOWITZER | **BUILT** | velocity — unlocked by the 53%→99% velo fix | +| SIDEARM / SUBMARINE | **BUILT** | slot (low arm angle) | +| STARTER / RELIEVER / CLOSER / SETUP | **BUILT** | role, from real usage | +| SLUGGER / BOMBER | **BUILT** | power (barrel%) | +| TECHNICIAN / SURGEON | **BUILT** | contact (low K%) | +| WHIFF RISK / WINDMILL | **BUILT** | swing_miss | +| GRINDER / SNIPER | **BUILT** | patience | +| FREE SWINGER / HACKER | **BUILT** | aggression | +| TOPSPIN / LOFT | **BUILT** | launch | +| SLASH / BARREL FINDER | **BUILT** | line_drive | +| HAMMER | **ALIAS** | of chase/strikeout — best-pitch whiff r=0.50 with overall; not independent enough for its own axis | +| BRUSH | **ALIAS** | of contact (TECHNICIAN) | +| DRIVER | **ALIAS** | of power — RBI is lineup context, not a player trait | +| **FLEX** | **RETIRED** | it was the fallback, never earned: `utility` had zero writers | +| MIRROR | **SHELVED** | needs switch-hitter axis; `bats='S'` now joined — unlock when a platoon-split feed lands | +| GHOST / BURNER / LEG | **SHELVED** | speed: SB is statsapi, not yet joined into the aggregate store | +| CATALYST | **SHELVED** | table-setter: needs lineup slot (not ingested) | +| HYBRID | **BUILT-ADJACENT** | two-way = both role profiles exist (position `TWP`) | +| ALPHA | **ALIAS** | of strikeout+control combined; the blend expresses it | +| FASTBREAK · ENFORCER · CHAINMOVER · CONDUCTOR · ARCHITECT · TORCH · LINK · ARTILLERY · BELL COW · etc. | **OTHER SPORT** | NBA/WNBA/NFL names — correctly not built here | + +Zero orphans. + +## Output shape + +```js +{ + role, roleDetail, roleLabel, sufficient, sample, bats, throws, + vector: { axisKey: {tier,label,value,strength} | null }, // Layer 3 reads ALL + blend: [ {label, axis, tier, value, strength} ], // top ≤3 to surface + absent: [axisKey…], // NO DATA — distinct from unremarkable (vector null) + note // honest copy when the blend is empty +} +``` + +`absent` vs a `null` vector entry is a real distinction: absent means we could +not measure it, null means we measured it and he is ordinary. diff --git a/src/services/archetypeAxes.js b/src/services/archetypeAxes.js new file mode 100644 index 0000000..4eff511 --- /dev/null +++ b/src/services/archetypeAxes.js @@ -0,0 +1,279 @@ +'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 }, +}; diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index d6d7301..4b9438d 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -485,10 +485,13 @@ function getCombatArchetype(name) { */ function classify(sport, stats = {}) { const sp = String(sport || 'nba').toLowerCase(); + // An explicit null does NOT trigger the default parameter, and every scorer + // dereferences its argument. Guard it here rather than in four scorers. + const input = stats && typeof stats === 'object' ? stats : {}; const scorer = SCORERS[sp]; if (!scorer) return { sport: sp, primary: null, secondary: null, blend: [] }; - const scores = scorer(stats); + const scores = scorer(input); const ranked = Object.entries(scores) .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]); @@ -498,9 +501,15 @@ function classify(sport, stats = {}) { // style from no data would be a fabrication — spec §STYLE-MATCHUP). const resolver = sp === 'mma' ? getCombatArchetype : getArchetype; if (ranked.length === 0) { - if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] }; - const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR'; - return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] }; + // Session 69 — THE FALLBACK IS GONE. It used to return FLEX (mlb) / SHIELD + // (wnba) / CONNECTOR (nba) at weight 1.0, i.e. "we could not classify this + // player" rendered as a fully-confident classification of a real archetype, + // complete with descriptive education copy. 8 of 18 MLB players carried it. + // MMA already did the honest thing; every sport does now: unclassified is + // ABSENT, and the multi-axis classifier (services/archetypeAxes.js) makes a + // single fallback bucket meaningless anyway — a player is unremarkable on + // an AXIS, not as a whole. + return { sport: sp, primary: null, secondary: null, blend: [] }; } const top = ranked.slice(0, 4); diff --git a/tests/unit/archetypeAxes.test.js b/tests/unit/archetypeAxes.test.js new file mode 100644 index 0000000..63481fe --- /dev/null +++ b/tests/unit/archetypeAxes.test.js @@ -0,0 +1,170 @@ +/* ============================================================ + 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); + }); +}); diff --git a/tests/unit/archetypeService.test.js b/tests/unit/archetypeService.test.js index 0b081b6..30b5f34 100644 --- a/tests/unit/archetypeService.test.js +++ b/tests/unit/archetypeService.test.js @@ -116,10 +116,19 @@ describe('archetypeService — WNBA classification', () => { }); }); -describe('archetypeService — graceful fallback', () => { - it('returns a fallback archetype for empty stats (never crashes)', () => { - expect(svc.classifyNBA({}).primary).toBeTruthy(); - expect(svc.classifyMLB({}).primary).toBeTruthy(); - expect(svc.classify('badsport', {}).primary).toBeNull(); +describe('archetypeService — HONEST ABSENCE on empty stats (Session 69)', () => { + it('returns NO archetype for empty stats — never a fallback bucket', () => { + // Was: a fallback archetype at weight 1.0 (FLEX/SHIELD/CONNECTOR), i.e. + // "could not classify" rendered as a confident classification with + // descriptive education copy. 8 of 18 MLB players carried it. + for (const s of [svc.classifyNBA({}), svc.classifyMLB({}), svc.classify('badsport', {})]) { + expect(s.primary).toBeNull(); + expect(s.blend).toEqual([]); + } + }); + + it('still never crashes on junk input', () => { + expect(() => svc.classify('mlb', null)).not.toThrow(); + expect(() => svc.classify(null, {})).not.toThrow(); }); }); diff --git a/tests/unit/playerIntelService.test.js b/tests/unit/playerIntelService.test.js index 471da04..737f170 100644 --- a/tests/unit/playerIntelService.test.js +++ b/tests/unit/playerIntelService.test.js @@ -40,6 +40,8 @@ describe('getPlayerIntel', () => { expect(r.sport).toBe('mlb'); expect(r.team).toBe('ATL'); expect(r.found).toBe(true); + // Real stats → a real classification (Riley is a BOMBER). The Session-69 + // change removes the FALLBACK, not classification itself. expect(r.archetype.primary).toBeTruthy(); expect(r.activeProps).toHaveLength(2); // only Riley's two props expect(r.activeProps[0]).toMatchObject({ stat: 'total_bases', side: 'O', grade: 'B+' }); @@ -52,7 +54,9 @@ describe('getPlayerIntel', () => { const r = await svc.getPlayerIntel('Nobody Special', 'nba', { cacheGet: async () => null }); expect(r.found).toBe(false); expect(r.activeProps).toEqual([]); - expect(r.archetype.primary).toBeTruthy(); // fallback archetype + // Session 69 — no fallback archetype: unclassified is ABSENT, not a bucket. + expect(r.archetype.primary).toBeNull(); + expect(r.archetype.blend).toEqual([]); expect(Array.isArray(r.intel)).toBe(true); }); diff --git a/tests/unit/playerIntelWiring.test.js b/tests/unit/playerIntelWiring.test.js index 712b856..30724ba 100644 --- a/tests/unit/playerIntelWiring.test.js +++ b/tests/unit/playerIntelWiring.test.js @@ -83,7 +83,8 @@ describe('getPlayerIntel with real stats (Session 43)', () => { resolveStats: async () => ({ found: false }), }); expect(r.found).toBe(false); - expect(r.archetype.primary).toBeTruthy(); // fallback archetype still present + // Session 69 — no fallback archetype: unclassified is ABSENT, not a bucket. + expect(r.archetype.primary).toBeNull(); expect(r.season).toEqual([]); }); });