Files
vyndr/tests/unit/playerIntelService.test.js
builtbykev 7ac6aa73e3 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
2026-07-20 22:38:32 -04:00

153 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Session 42 — player intelligence aggregation. cacheGet is injected so these
// run pure (no Redis, no HTTP, no rate limiter).
const svc = require('../../src/services/playerIntelService');
const cacheWith = (envelope) => async (key) => (key.startsWith('grades:') ? envelope : null);
describe('sanitizePlayerName', () => {
it('decodes URL encoding and keeps name punctuation', () => {
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr'); // S46: suffix de-dotted
});
it('strips injection / control characters', () => {
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('aetcpasswd'); // S46: periods stripped
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
});
it('handles malformed percent-encoding without throwing', () => {
expect(() => svc.sanitizePlayerName('%E0%A4%A')).not.toThrow();
});
});
describe('getPlayerIntel', () => {
it('returns archetype + intelligence + props, found=true when player has grades', async () => {
const envelope = {
grades: [
{ player: 'Austin Riley', team: 'ATL', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'B+', confidence: 71 },
{ player: 'Austin Riley', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', confidence: 60 },
{ player: 'Someone Else', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 80 },
],
};
const r = await svc.getPlayerIntel('Austin Riley', 'mlb', {
cacheGet: cacheWith(envelope),
stats: { avg: 0.282, hr: 18, rbi: 54, ops: 0.845, k_rate: 26 },
});
expect(r.player).toBe('Austin Riley');
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+' });
expect(r.propDNA.reliable.length + r.propDNA.volatile.length).toBeGreaterThan(0);
expect(r.education.length).toBeGreaterThan(0);
expect(r.intel.find((m) => m.label === 'FORM')).toBeTruthy();
});
it('degrades gracefully when the grades cache is cold (found=false, no crash)', async () => {
const r = await svc.getPlayerIntel('Nobody Special', 'nba', { cacheGet: async () => null });
expect(r.found).toBe(false);
expect(r.activeProps).toEqual([]);
// 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);
});
it('survives a throwing cache (returns a valid payload)', async () => {
const r = await svc.getPlayerIntel('X', 'nba', { cacheGet: async () => { throw new Error('redis down'); } });
expect(r.activeProps).toEqual([]);
expect(r.player).toBe('X');
});
});
describe('getLeaders', () => {
const envelope = {
grades: [
{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 88 },
{ player: 'B', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 72 },
{ player: 'C', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 95 },
],
};
it('returns top props by confidence', async () => {
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), limit: 2 });
expect(r).toHaveLength(2);
expect(r[0].player).toBe('C'); // highest confidence
expect(r[0].confidence).toBe(95);
});
it('filters to a single stat when given', async () => {
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), stat: 'hits' });
expect(r).toHaveLength(2);
expect(r.every((x) => x.stat === 'hits')).toBe(true);
});
it('returns [] on a cold cache', async () => {
expect(await svc.getLeaders('nba', { cacheGet: async () => null })).toEqual([]);
});
});
// ── Session 65 — UN-FABRICATE (Truth Law) ────────────────────────────────
// FORM used to be `70 + 4 × (count of tonight's graded props)` whenever
// `stats.form` was absent — and nothing on the HTTP path ever sets it, so that
// fallback WAS the live number (Josh Bell's "74" = 70 + 4×1 prop). MATCHUP was
// `gradeFromForm(that number)`, with a hardcoded 'B' when no archetype
// resolved: both branches fabricated a letter with zero opponent input.
describe('buildIntel — absent renders absent, never a manufactured number', () => {
const { buildIntel } = svc._internals;
const arch = { primary: { name: 'BOMBER' } };
it('renders FORM as "—" with no bar when no real form value exists', () => {
const form = buildIntel({}, arch).find((m) => m.label === 'FORM');
expect(form.value).toBe('—');
expect(form.kind).toBe('plain'); // 'plain' draws no progress bar → no width math on a null
expect(form.score).toBeUndefined();
});
it('renders MATCHUP as "—", never a grade letter, with no opponent input', () => {
const m = buildIntel({}, arch).find((x) => x.label === 'MATCHUP');
expect(m.value).toBe('—');
expect(m.kind).toBe('plain');
// and not merely because the archetype was missing — the old code
// hardcoded 'B' on that branch too.
const noArch = buildIntel({}, { primary: null }).find((x) => x.label === 'MATCHUP');
expect(noArch.value).toBe('—');
});
it('the prop count can NEVER move FORM (the fabrication that shipped)', async () => {
const mk = (n) => ({
grades: Array.from({ length: n }, (_, i) => ({
player: 'Josh Bell', stat_type: `stat_${i}`, line: 0.5, direction: 'over', grade: 'B', confidence: 57,
})),
});
const formFor = async (n) => {
const r = await svc.getPlayerIntel('Josh Bell', 'mlb', { cacheGet: cacheWith(mk(n)), stats: {} });
return r.intel.find((m) => m.label === 'FORM').value;
};
expect(await formFor(1)).toBe('—'); // was '74'
expect(await formFor(5)).toBe('—'); // was '90'
expect(await formFor(0)).toBe('—'); // was '70'
});
it('still renders a REAL form value when one is supplied (un-claim, not un-build)', () => {
const form = buildIntel({ form: 63 }, arch).find((m) => m.label === 'FORM');
expect(form.value).toBe('63');
expect(form.kind).toBe('form');
expect(form.score).toBe('63%');
});
it('usage and rest keep their honest absent states', () => {
const rows = buildIntel({}, arch);
expect(rows.find((m) => m.label === 'USAGE').value).toBe('—');
expect(rows.find((m) => m.label === 'REST').value).toBe('—');
expect(buildIntel({ usage: '3.6 AB/G', rest: 'B2B' }, arch).find((m) => m.label === 'USAGE').value).toBe('3.6 AB/G');
});
});