Link 2 at the coarse grain: pen QUALITY proves, archetype does not

The refinement was right. Naming the individual reliever failed; the same
question at the grain the chain needs passes, and it transmits more than
anything else measured in this chain.

WHY IT WAS WORTH RE-ASKING: last session's null (the pen is on average no
softer, +0.0010 on 35,760 PAs) does NOT rule this out, and treating it as
though it did would have been the error. An average washing out is fully
consistent with quality VARIATION mattering. It does -- actual arm quality
moves the hit rate monotonically across quartiles, 0.2244 / 0.2293 /
0.2410 / 0.2501, a 2.57pp spread, larger than the whole times-through-
the-order effect.

CLUSTER UNIT CORRECTED, THEN CHECKED RATHER THAN ARGUED. Last session
refused Link 2 partly as team-borne (30 bullpens, the park ceiling). My
first re-check was that 76% of pen-quality variance is within-team -- but
that is a statement about TREATMENT variance, not about where errors
correlate, and stopping there would have been picking the convenient
answer. Measured the actual thing: ICC of prediction error by team =
0.0261, design effect 1.41, SEs inflated ~19%. So the verdict was run
three ways:

  unclustered            CI [-0.0067,-0.0010]  excludes zero
  team-clustered (30)    CI [-0.0086,-0.0003]  excludes zero (below the
                         40-cluster floor -- indicative, not a pass)
  design-effect adjusted CI [-0.0072,-0.0005]  excludes zero

QUALITY GRAIN PROVES on the concentrated elevated-early-exit subset:
n=501 team-games, 426 clusters, MAE 0.0294 -> 0.0260, delta -0.0034, CI
[-0.0063,-0.0005] at 110 cumulative tests. Pooled also proves, so it is
not a subset artefact.

ARCHETYPE GRAIN DOES NOT: 0.5669 vs a 0.5309 modal-guess baseline,
corrected interval [-0.1073,+0.0268] spans zero. Two grains tested, one
earned a place -- penQuality.js exposes no archetype and a test asserts
it.

WHAT LINK 3 RECEIVES, which is the number that actually matters -- not
the MAE gain but realized outcome separation, prediction strictly
point-in-time:

  predicted BEST pen   167 games  2,044 PAs  hit rate 0.2231 +/-0.0180
  predicted WORST pen  167 games  1,799 PAs  hit rate 0.2501 +/-0.0200

2.70pp separated, intervals non-overlapping, capturing nearly all the
2.57pp available at the quartile grain. Caveat stated not buried: the
tercile cut is chosen in-sample; the prediction driving it is not.

BUILT: penQuality.js + 9 tests. Abstains below 5 prior club games and 40
arm appearances -- a league-average stand-in would assert "this is an
ordinary bullpen", which is a claim, and usually the wrong one for exactly
the clubs whose pens just turned over.

Link 3 is unblocked on a proven Link 2 at the quality grain only. Not run
here; this order scopes to building and gating Link 2.

Parallel track logged unchanged: TB n=948 pooled, BOMBER x TB 340, short
by 160.

Counter and frozen clusters byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-06 02:17:00 -04:00
parent e4dae0e6b0
commit b2e4c6c4fb
5 changed files with 516 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
'use strict';
/**
* LINK 2 (coarse grain) — WHICH BULLPEN, not which arm.
*
* Naming the individual reliever failed on merit: 17.2% accuracy, wrong five
* times in six. This asks the question at the grain the order specifies and Link
* 3 actually needs — pen QUALITY and reliever ARCHETYPE — and it is worth asking
* because the payoff is measured, not assumed: facing a bottom-quartile arm
* rather than a top-quartile one is worth +2.57pp of hit rate, larger than the
* whole times-through-the-order effect.
*
* ── WHY THE CLUSTER UNIT CHANGED FROM LAST SESSION ───────────────────────
* Reliever IDENTITY was refused partly as a team-borne prediction: 30 bullpens,
* 30 readings, the park-geometry ceiling. Measured for QUALITY, that argument
* does not hold — **76% of the variance in a game's pen quality is WITHIN team**,
* not between teams. What is being predicted varies game to game inside the same
* club (who is rested, who is available), so the game is the honest cluster and
* the franchise is not a ceiling. Team-clustered is reported alongside as the
* conservative sensitivity rather than hidden.
*
* ── POINT-IN-TIME ON BOTH SIDES ──────────────────────────────────────────
* Each arm's quality is his allowed-hit-rate over appearances strictly BEFORE
* this game. That holds for the prediction AND for the target: the target is
* "which known-quality arms showed up", never "how they happened to pitch
* tonight", which would be scoring against the answer.
*
* node scripts/link2b-pen-quality.js
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pg = require('../src/services/model/predictionGate');
const tl = require('../src/services/model/testLedger');
const { createClient } = require('@supabase/supabase-js');
const { knownNumber } = require('../src/utils/known');
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
const HIT = new Set(['single', 'double', 'triple', 'home_run']);
const PA = new Set(['single', 'double', 'triple', 'home_run', 'field_out', 'strikeout',
'grounded_into_double_play', 'force_out', 'field_error', 'fielders_choice',
'fielders_choice_out', 'double_play', 'sac_fly', 'pop_out', 'line_out', 'fly_out',
'strikeout_double_play']);
/** Appearances before we will read an arm's quality at all. Below it: abstain. */
const MIN_ARM_PA = 40;
/** Prior starts before Link 1 will read a starter's own workload. */
const MIN_PRIOR_STARTS = 3;
const STABILIZE = 5;
/** Link 1 flags an elevated early exit at or under this predicted batters-faced. */
const EARLY_FLAG_BF = 22;
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
/**
* Reliever archetype at the coarse grain, from strikeout rate — the axis that
* separates a power arm from a contact arm and the one Link 3 would condition on.
*/
function archetypeOf(kRate) {
if (kRate === null) return null;
if (kRate >= 0.28) return 'POWER';
if (kRate <= 0.18) return 'CONTACT';
return 'MIDDLE';
}
function build() {
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
games.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
const arm = new Map(); // pid -> { n, h, k } (all prior PAs)
const penHist = new Map(); // team -> [{ quality, k }] per prior game
const startHist = new Map(); // starter id -> [bf]
const rows = [];
for (const g of games) {
for (const side of ['home', 'away']) {
const team = g[side].abbr || g[side].team;
const st = (g[side].arms || []).find((a) => a.started);
if (!team || !st) continue;
const half = side === 'home' ? 'top' : 'bottom';
const pas = g.pas.filter((p) => p.half === half && PA.has(p.event));
const post = pas.filter((p) => p.pitcher !== st.id);
// ── LINK 1, recomputed point-in-time, to define the concentrated subset ──
const priorStarts = startHist.get(st.id) || [];
let predBf = null;
if (priorStarts.length >= MIN_PRIOR_STARTS) {
const w = priorStarts.length / (priorStarts.length + STABILIZE);
predBf = w * mean(priorStarts) + (1 - w) * 21.56; // league mean
}
// ── TARGET: the known quality of the arms that ACTUALLY appeared ──
const faced = [];
for (const p of post) {
const h = arm.get(p.pitcher);
if (!h || h.n < MIN_ARM_PA) continue; // abstain, never 0
faced.push({ q: h.h / h.n, k: h.k / h.n });
}
// ── PREDICTION: this club's own pen, from prior games only ──
const hist = penHist.get(team) || [];
if (faced.length && hist.length >= 5 && predBf !== null) {
const predQ = mean(hist.map((x) => x.quality));
const predK = mean(hist.map((x) => x.k));
rows.push({
team,
gamePk: g.gamePk,
date: g.date,
pred_bf: predBf,
early_flagged: predBf <= EARLY_FLAG_BF,
pred_quality: predQ,
actual_quality: mean(faced.map((f) => f.q)),
pred_archetype: archetypeOf(predK),
actual_archetype: archetypeOf(mean(faced.map((f) => f.k))),
arms_faced: faced.length,
});
}
// Fold this game into history — never before predicting from it.
if (faced.length) {
penHist.set(team, hist.concat([{ quality: mean(faced.map((f) => f.q)), k: mean(faced.map((f) => f.k)) }]));
}
if (st.bf != null) startHist.set(st.id, priorStarts.concat([st.bf]));
for (const p of pas) {
const cur = arm.get(p.pitcher) || { n: 0, h: 0, k: 0 };
cur.n += 1;
cur.h += HIT.has(p.event) ? 1 : 0;
cur.k += p.event === 'strikeout' ? 1 : 0;
arm.set(p.pitcher, cur);
}
}
}
return rows;
}
function gateQuality(rows, leagueQ, cumulative, clusterKey, label) {
return pg.adjudicate(rows.map((r) => ({
cluster: r[clusterKey],
baseline: leagueQ,
prediction: r.pred_quality,
actual: r.actual_quality,
})), { link: label, loss: 'absolute', cumulativeTests: cumulative });
}
(async () => {
const all = build();
const subset = all.filter((r) => r.early_flagged);
const leagueQ = mean(all.map((r) => r.actual_quality));
let cumulative = 1;
try {
const sb = createClient(process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY,
{ auth: { persistSession: false } });
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
{ sport: 'mlb', stat: 'pen_quality', archetype: null, interaction: 'link2b:pen_quality', target: 'actual_arms' },
{ sport: 'mlb', stat: 'pen_archetype', archetype: null, interaction: 'link2b:pen_archetype', target: 'actual_arms' },
]);
cumulative = mc.cumulative_tests;
} catch { /* offline */ }
// ARCHETYPE grain — misclassification against the arms that actually appeared.
const archRows = subset.filter((r) => r.pred_archetype && r.actual_archetype);
const modal = (() => {
const c = new Map();
for (const r of all) c.set(r.actual_archetype, (c.get(r.actual_archetype) || 0) + 1);
return [...c.entries()].sort((a, b) => b[1] - a[1])[0][0];
})();
const archGate = pg.adjudicate(archRows.map((r) => ({
cluster: r.gamePk,
baseline: r.actual_archetype === modal ? 1 : 0,
prediction: r.actual_archetype === r.pred_archetype ? 1 : 0,
actual: 1,
})), { link: 'link2b_pen_archetype', loss: 'absolute', cumulativeTests: cumulative });
const acc = (rs, k) => (rs.length ? rs.filter((r) => r[k]).length / rs.length : null);
console.log(JSON.stringify({
link: 'LINK 2 (coarse) — pen quality + reliever archetype',
team_games_total: all.length,
concentrated_subset_elevated_early_exit: subset.length,
league_mean_pen_quality: round4(leagueQ),
cumulative_tests: cumulative,
quality_grain: {
on_concentrated_subset: gateQuality(subset, leagueQ, cumulative, 'gamePk', 'link2b_pen_quality_subset'),
sensitivity_team_clustered: gateQuality(subset, leagueQ, cumulative, 'team', 'link2b_pen_quality_teamclust'),
pooled_all_games: gateQuality(all, leagueQ, cumulative, 'gamePk', 'link2b_pen_quality_pooled'),
},
archetype_grain: {
n: archRows.length,
modal_archetype: modal,
baseline_accuracy_guess_modal: round4(acc(archRows.map((r) => ({ x: r.actual_archetype === modal })), 'x')),
model_accuracy: round4(acc(archRows.map((r) => ({ x: r.actual_archetype === r.pred_archetype })), 'x')),
verdict: archGate,
},
cluster_note: '76% of game pen-quality variance is WITHIN team, so the game is the honest cluster; team-clustered reported as the conservative sensitivity',
}, null, 2));
process.exit(0);
})();
const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);
+130
View File
@@ -0,0 +1,130 @@
# Link 2 at the coarse grain — pen QUALITY proves, archetype does not
**The refinement was right.** Naming the individual reliever failed; asking the
same question at the grain the chain actually needs passes, and the payoff it
transmits is larger than anything else measured in this chain.
---
## Why this was worth re-asking
Last session closed Link 2 on the individual grain and separately measured that
the bullpen is, on average, no softer than the starter (+0.0010 on 35,760 plate
appearances). That null does **not** rule this out, and conflating the two would
have been an error: an average washing out is entirely consistent with QUALITY
VARIATION mattering a great deal.
It does. Measured on 18,809 post-starter plate appearances against arms with ≥40
prior appearances:
| arm faced (prior allowed-hit-rate quartile) | n | realized hit rate |
|---|---|---|
| Q1 — best arms | 4,702 | **0.2244** ±0.0119 |
| Q2 | 4,702 | 0.2293 |
| Q3 | 4,702 | 0.2410 |
| Q4 — worst arms | 4,702 | **0.2501** ±0.0124 |
Monotone, spread **+2.57pp** — larger than the whole times-through-the-order
effect (+1.6pp) and far larger than the pen-vs-starter difference (0.7pp).
---
## The cluster unit, corrected — and then checked rather than argued
Last session refused Link 2 partly as a team-borne prediction: 30 bullpens, the
park-geometry ceiling. For QUALITY that argument needed re-testing, and the first
number I reached for was the wrong one.
- **Treatment variance:** 76% of a game's pen-quality variance is WITHIN team.
That says pen quality is not chiefly a club property — but it is a statement
about the treatment, not about where ERRORS correlate, and those are different
claims. Stopping there would have been picking the convenient answer.
- **Measured directly:** ICC of the prediction ERROR by team = **0.0261**. With
16.7 rows per club that is a design effect of 1.41, inflating standard errors
~19% — small, but not nothing on a marginal interval.
So the verdict was checked under all three treatments rather than resting on the
most favourable:
| inference treatment | CI on loss delta | |
|---|---|---|
| unclustered | [0.0067, 0.0010] | excludes zero |
| team-clustered (30 clusters, floor overridden — indicative only) | [0.0086, 0.0003] | excludes zero |
| design-effect adjusted (deff 1.41) | [0.0072, 0.0005] | excludes zero |
It survives all three. Note the team-clustered run sits below this codebase's own
40-cluster floor and is reported as indicative, not as a pass.
---
## The gate
Concentrated subset as instructed — team-games where Link 1's point-in-time
early-exit signal is elevated (predicted ≤22 batters faced), i.e. where the pen
actually enters for the later plate appearances.
### QUALITY grain — **PROVES**
```
n=501 team-games · 426 game clusters · 110 cumulative tests
MAE 0.0294 (league-average baseline) -> 0.0260 delta -0.0034
CI [-0.0063,-0.0005] at 0.9995 VERDICT: PROVES
```
Pooled across all games it also proves (n=1,305, delta 0.0030, CI [0.0049,
0.0015]), so the result is not an artefact of the subset.
### ARCHETYPE grain — **NOT PROVEN**
```
n=501 · modal-guess baseline 0.5309 -> model 0.5669
corrected interval [-0.1073, +0.0268] spans zero
VERDICT: NOT_PROVEN_AT_CORRECTED_BAR
```
Two grains were tested; one earned a place. `penQuality.js` deliberately exposes
no archetype, and a test asserts it.
---
## What Link 3 actually receives
The number that matters is not the MAE gain but how much real outcome separation
the prediction buys — measured on realized outcomes, prediction strictly
point-in-time:
| our prediction | games | PAs | realized hit rate |
|---|---|---|---|
| predicted BEST pen (bottom tercile) | 167 | 2,044 | **0.2231** ±0.0180 |
| predicted WORST pen (top tercile) | 167 | 1,799 | **0.2501** ±0.0200 |
**2.70pp of realized separation**, intervals non-overlapping — capturing nearly
all of the 2.57pp available at the quartile grain. corr(predicted, actual pen
quality) = 0.393.
Caveat stated rather than buried: the tercile split point is chosen in-sample.
The prediction driving the separation is point-in-time, so this is a forward
measurement, but the cut is not.
---
## Built
`src/services/model/penQuality.js` (+ 9 tests) — the proven half, ready for Link
3. `projectPen` abstains below 5 prior club games; `armQuality` abstains below 40
appearances. A league-average stand-in would assert "this is an ordinary
bullpen", which is a claim, and usually the wrong one for exactly the clubs whose
pens have just turned over.
`hitRateShift` carries the measured consequence, bounded — it was measured over a
range and is not extrapolated past one.
**Link 3 is now unblocked** on a proven Link 2 at the quality grain only. It is
not run here; the order scopes this session to building and gating Link 2.
## Parallel track — total_bases per-archetype (logged, not run)
Unchanged from last session: `total_bases` settled n=948 pooled, BOMBER × TB
**340**, short by 160. Sample-readiness only, not a verdict. The second blocker
from `specs/per-archetype-grade-bands.md` still applies — the grade does not yet
separate within any archetype.
Counter and frozen clusters byte-identical.
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/**
* penQuality — the PROVEN half of Link 2.
*
* Link 2 was asked twice. Naming the individual reliever failed on merit (17.2%
* accuracy — wrong five times in six), because managers mix and match and the
* individual genuinely is noise. Asked at the COARSE grain the chain actually
* needs, it proves: predicted pen quality separates a realized 2.70pp hit-rate
* difference between the pens we call best and worst.
*
* The archetype grain did NOT prove (0.567 vs a 0.531 modal-guess baseline,
* corrected interval spanning zero) and is deliberately absent from this module.
* Two grains were tested; one earned a place.
*
* ── POINT-IN-TIME ON BOTH SIDES ──────────────────────────────────────────
* An arm's quality is his allowed-hit-rate over appearances strictly BEFORE the
* game in question, and a club's pen forecast comes only from its prior games.
* The target is which KNOWN-quality arms appeared — never how they happened to
* pitch that night, which would be scoring against the answer.
*
* ── ABSTAIN, NEVER IMPUTE ────────────────────────────────────────────────
* An arm below the appearance floor has no readable quality, and a club without
* enough prior games has no readable pen. Both return null. A league-average
* stand-in would assert "this is an ordinary bullpen", which is a claim, and
* usually the wrong one for exactly the clubs whose pens have just turned over.
*/
const { knownNumber } = require('../../utils/known');
/** Appearances before an arm's quality is readable at all. */
const MIN_ARM_PA = 40;
/** Prior games before a club's pen is readable at all. */
const MIN_PRIOR_GAMES = 5;
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
/**
* One arm's quality from his prior line. Null below the floor — a 12-batter
* sample is not a scouting report.
*/
function armQuality(prior) {
const n = knownNumber(prior && prior.pa);
const h = knownNumber(prior && prior.hits);
if (n === null || h === null || n < MIN_ARM_PA) return null;
return h / n;
}
/**
* The pen a hitter's later plate appearances will face.
*
* @param {Array} priorGames [{ quality }] this club's prior relief outings
* @returns {object|null} null when unreadable — never a league-average guess.
*/
function projectPen(priorGames) {
const qs = (priorGames || []).map((g) => knownNumber(g && g.quality)).filter((v) => v !== null);
if (qs.length < MIN_PRIOR_GAMES) {
return null;
}
return {
readable: true,
quality: round4(mean(qs)),
games_read: qs.length,
// Stated so a consumer cannot mistake this for a reliever-identity claim.
grain: 'pen_quality',
individual_arm_refused: 'naming the specific reliever did not prove (17.2% accuracy) — managers mix and match',
archetype_refused: 'the archetype grain did not prove at the corrected bar',
};
}
/**
* The measured relationship between pen quality and hit rate, for a consumer
* that wants the consequence rather than the input. Anchored on the observed
* league mean; the slope is the measured tercile separation, not a fitted
* parameter, and the effect is bounded because it was measured over a range.
*/
const LEAGUE_PEN_QUALITY = 0.2261;
const HIT_RATE_PER_QUALITY = 0.87; // 2.70pp realized over a 0.031 quality gap
const MAX_SHIFT = 0.03;
function hitRateShift(penQuality) {
const q = knownNumber(penQuality);
if (q === null) return null; // absent stays absent
const raw = (q - LEAGUE_PEN_QUALITY) * HIT_RATE_PER_QUALITY;
return round4(Math.max(-MAX_SHIFT, Math.min(MAX_SHIFT, raw)));
}
/** A checkable sentence, or nothing. */
function explain(pen) {
if (!pen || !pen.readable) return null;
const d = pen.quality - LEAGUE_PEN_QUALITY;
if (Math.abs(d) < 0.005) return `bullpen reads league-average over ${pen.games_read} prior games`;
return `bullpen reads ${d > 0 ? 'weaker' : 'stronger'} than league over ${pen.games_read} prior games`;
}
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
module.exports = {
armQuality, projectPen, hitRateShift, explain,
MIN_ARM_PA, MIN_PRIOR_GAMES, LEAGUE_PEN_QUALITY, MAX_SHIFT,
};
+78
View File
@@ -0,0 +1,78 @@
'use strict';
/**
* The proven half of Link 2.
*
* Two grains were tested. These lock the one that earned a place and the
* refusals that keep the other from creeping back in.
*/
const pq = require('../../src/services/model/penQuality');
const games = (n, q) => Array.from({ length: n }, () => ({ quality: q }));
describe('abstains rather than imputing', () => {
it('an arm below the appearance floor has no readable quality', () => {
expect(pq.armQuality({ pa: 12, hits: 3 })).toBeNull();
expect(pq.armQuality({ pa: 60, hits: 12 })).toBeCloseTo(0.2, 6);
});
it('a club with too few prior games returns null, not a league-average pen', () => {
// A league-average stand-in asserts "this is an ordinary bullpen" — a claim,
// and usually the wrong one for exactly the clubs whose pens just turned over.
expect(pq.projectPen(games(3, 0.22))).toBeNull();
expect(pq.projectPen([])).toBeNull();
expect(pq.projectPen(null)).toBeNull();
expect(pq.projectPen(games(8, 0.22)).readable).toBe(true);
});
it('unreadable quality produces no shift at all', () => {
expect(pq.hitRateShift(null)).toBeNull();
expect(pq.hitRateShift(undefined)).toBeNull();
});
});
describe('the grain that proved, and the two that did not', () => {
it('reports pen QUALITY and names what it refuses', () => {
const pen = pq.projectPen(games(10, 0.24));
expect(pen.grain).toBe('pen_quality');
expect(pen.individual_arm_refused).toMatch(/17.2%/);
expect(pen.archetype_refused).toMatch(/did not prove/);
// The module must not offer an archetype — that grain did not earn one.
expect(pen.archetype).toBeUndefined();
});
});
describe('the shift follows the measured direction', () => {
it('a weaker pen raises the hit rate, a stronger one lowers it', () => {
expect(pq.hitRateShift(0.26)).toBeGreaterThan(0);
expect(pq.hitRateShift(0.19)).toBeLessThan(0);
expect(pq.hitRateShift(pq.LEAGUE_PEN_QUALITY)).toBeCloseTo(0, 6);
});
it('is bounded — it was measured over a range, not extrapolated past one', () => {
expect(pq.hitRateShift(0.9)).toBeLessThanOrEqual(pq.MAX_SHIFT + 1e-9);
expect(pq.hitRateShift(0.01)).toBeGreaterThanOrEqual(-pq.MAX_SHIFT - 1e-9);
});
it('reproduces the measured tercile separation', () => {
// Predicted-best pens averaged 0.2114 actual quality and a 0.2231 realized
// hit rate; predicted-worst 0.2425 and 0.2501 — a 2.70pp gap.
const gap = pq.hitRateShift(0.2425) - pq.hitRateShift(0.2114);
expect(gap).toBeGreaterThan(0.02);
expect(gap).toBeLessThan(0.035);
});
});
describe('reasoning is true or absent', () => {
it('no read means no sentence', () => {
expect(pq.explain(null)).toBeNull();
expect(pq.explain({ readable: false })).toBeNull();
});
it('names the direction and how much was read', () => {
expect(pq.explain(pq.projectPen(games(12, 0.25)))).toMatch(/weaker than league over 12 prior games/);
expect(pq.explain(pq.projectPen(games(12, 0.20)))).toMatch(/stronger than league/);
expect(pq.explain(pq.projectPen(games(12, 0.2261)))).toMatch(/league-average/);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long