Layer 2: multi-axis archetype classifier; the FLEX fallback is gone

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, and single-label classification threw three of them away.

AXIS INDEPENDENCE WAS MEASURED, NOT ASSUMED. Correlations over the live store
(467 batters, 531 pitchers); anything |r| >= 0.70 is one underlying trait and
was collapsed so we never show one trait as two archetypes. Batter k% ~ whiff%
+0.89, hard-hit% ~ exit velo +0.88, chase% ~ swing% +0.87, chase% ~ bb% -0.72;
pitcher k% ~ whiff% +0.76, gb% ~ fb% -0.73 — all collapsed.

The survivors are genuinely orthogonal, and one result is worth stating: pitcher
velocity correlates +0.14 with K%, +0.07 with whiff% and +0.07 with GB%.
Velocity is NOT a proxy for missing bats — a hard thrower who misses no bats is
a real distinct type, so CANNON earns its own axis rather than being folded into
STRIKEOUT. Pitcher K% ~ GB% is -0.10, so PUNCHOUT and SINKER are independent,
which is exactly the multi-axis thesis.

Cut-lines are the measured p75 (distinctive) and p90 (elite), per role where the
tails differ even when the medians agree: reliever GB% p90 is 54.1 against a
starter's 48.9, both with a median of 42.5.

THE FALLBACK IS DELETED. classify() used to return FLEX (mlb) / SHIELD (wnba) /
CONNECTOR (nba) at weight 1.0 when nothing scored — "could not classify"
rendered as a fully-confident classification of a real archetype, with
descriptive education copy attached. 8 of 18 MLB players carried it, and FLEX
could never be earned because its only scoring input had zero writers. Every
sport now does what MMA already did: unclassified is absent.

Induced on real players. Skubal: STARTER, throws L, WHIFF + SEAM + PINPOINT, all
elite. Judge: BOMBER + GRINDER + WHIFF RISK — elite power, patient, strikes out,
three true things. Kwan: SURGEON + SNIPER + SLASH with NO power claimed (0.4
barrel% is absent, not "low power"). Josh Bell, who used to classify as DRIVER:
empty blend, "No standout profile — league-average across every measured axis."
Alan Roden, who was FLEX at weight 1.0 on 21 PA: every axis absent, "Not enough
plate appearances yet — no profile claimed."

Per-axis honest-absence holds: a velo-less pitcher keeps every other axis, and
NO DATA is distinguishable from LEAGUE-AVERAGE rather than collapsing into one
shrug. The full vector is stored for Layer 3; only the top three distinctive
traits surface.

Three existing tests asserted the fallback and were updated to assert absence.
One of them surfaced a real robustness gap: classify(sport, null) threw, because
an explicit null does not trigger a default parameter and every scorer
dereferences its argument. Guarded.

Every baseball name is accounted for in docs/ARCHETYPE-AXES.md — built, alias,
tier, or shelved with its unlock condition. Zero orphans; cross-sport names left
for their sport.

Tests 3601 passed / 293 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-20 22:38:32 -04:00
parent 1265c23305
commit 7ac6aa73e3
7 changed files with 573 additions and 11 deletions
+90
View File
@@ -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.
+279
View File
@@ -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 },
};
+13 -4
View File
@@ -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);
+170
View File
@@ -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);
});
});
+14 -5
View File
@@ -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();
});
});
+5 -1
View File
@@ -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);
});
+2 -1
View File
@@ -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([]);
});
});