7ac6aa73e3
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
153 lines
7.0 KiB
JavaScript
153 lines
7.0 KiB
JavaScript
// 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');
|
||
});
|
||
});
|