Powerwise

Validation guide

Validation guide — process & statistics audit

This is the auditor's manual. Open output/ and output/backtest_2024_25/ next to it; cross-check every stat to its source. Math lives in composite_score.py; pipeline in analysis.py; data in source-data/.

Design contract. Strength is anchored on finish-tier results, not on the same box stats we're scoring. The model adds adjustments on top of last year's finish — it does not re-rank by composite. Composite re-sort is a diagnostic only.


1. Run the pipeline yourself

cd glax-pipeline
python3 fetch_awards.py 2026                  # refresh awards_2026.csv (live data)
python3 run_glax_analysis.py --season both    # forward + backtest
python3 -m unittest discover -s tests -v      # 49 tests
python3 awards_tune.py                        # award weight grid (optional)

Reproducible artifacts:

Path Purpose
output/run_meta.json Every knob the run used (k, baselines, anchors, damping, awards).
output/team_power_2025_26.csv Current-season composite + domain scores per team.
output/player_impact_2025_26.csv Per-player raw + SOS share + power points.
output/projected_power_2027.csv After-graduation projection per team (with award rollups).
output/league_rerank_2027.csv Roster-adjusted rank vs composite re-sort (diagnostic).
output/ranking_recommendations_2027.csv Board packet — recommended_rank_adjusted is the vote line.
output/ranking_outliers_2027.csv Subset that needs review (action flags only).
output/ranking_board_report_2027.md Methodology + flagged-team table for the board.
output/graduation_player_loss_2025_26.csv Per-departing-player off/pos/def loss share.
output/schedule_risk_matrix_2027.csv Pairwise expected margin (both directions).
output/sanity_report_2025_26.md Composite vs finish vs Powerwise reconciliation.
output/benchmark_2027.md Forward outlook vs prior-finish carry-forward.
output/backtest_2024_25/backtest_report.md Holdout: 2024-25 + 2025 grads → predicted 2026 finish.
output/backtest_2024_25/backtest_benchmark_by_tier.csv MAE / ±3 by finish tier per predictor.
output/backtest_2024_25/backtest_team_rank_errors.csv Per-team rank errors across predictors.
output/awards_tune_results.csv Award-weight grid sweep.

2. Pipeline at a glance

finish_2026.tsv ─► tier / smooth SOS  ─┐
finish_2025.tsv ─► legacy prior ──────┐│
results.csv     ─► RPI + AGD ─────────┐││
                                       ▼▼▼
team_game_stats.csv ─► team_power (offense / defense / possession)
                            │
player_game_stats.csv ─► player_impact (SOS share × leverage)
                            │
roster.csv + class year ─► graduation projection (replacement-level)
awards_2026.csv         ─► award departure boost / returning relief
                            ▼
                projected_power_2027  ─► roster_rank_predictor (capped move)
                                       └► secondary_regression (flagged only)
                                       └► board_overrides (manual notes)
                                       └► schedule_risk_matrix (k × Δpower)

3. Domain math (validate every score in team_power_*.csv)

Each game produces three raw 0-100 scores; the season composite is the leverage × SOS-weighted average of those games.

3.1 Per-game raw scores (composite_score.py)

Offense — score_offense

possessions_floor = cal.offense_poss_floor      # 25 in 2025-26
rate = goals_scored / max(possessions_estimate, possessions_floor)
raw  = squash_high_is_better(rate, p50, p90)    # logistic on calibrated anchors

p50 → 55, p90 → 92, soft tail to 100. With the floor, scoring 6 goals on 30 possessions = rate 0.20; scoring 14 goals on 35 possessions = rate 0.40. Audit: open source-data/glax_2025_26_team_game_stats.csv, divide goals by possessions for any game, run domain_calibration.squash_high_is_better(rate, 0.40, 0.64) — the result must equal that game's offense_score in any debug log.

Defense — score_defense

Goalie block (when saves + goals_allowed present):

shots_faced = goals_allowed + saves
goalie = 0.45 * squash_low(shots_faced) + 0.55 * squash_high(save_pct)

Field block (GB + INT + CTO — no double-count in possession):

field_events = 0.70*GB + 0.80*INT + 0.85*CTO
field = squash_high(field_events, defense_field_p50, defense_field_p90)

Blend when both exist: 0.55*goalie + 0.45*field. Either block alone is used if the other is missing.

If goalie minutes are missing and goals_allowed = 0, the goalie block is None (not 100). Covered by tests/test_anchor.py.

Possessionscore_possession

events = 1.00 * draw_controls
raw    = squash_high(events, possession_p50, possession_p90)

Possession baseline is the league p90 of draw-weighted events (run_meta.json → possession_baseline).

3.2 Game-to-season aggregation

context = sos_weight(opponent_rank) × leverage(margin)
weighted_avg = Σ(raw_g · context_g) / Σ(context_g)

3.3 Composite

composite = 0.35 * offense + 0.35 * defense + 0.30 * possession

Domain weights live in DOMAIN_WEIGHTS. Sensitivity sweeps showed they don't move top-20 MAE meaningfully (within ±0.1). Composite is not the rank source — see §6.


4. Player impact (validate every row in player_impact_*.csv)

Per player, per domain, in compute_player_impacts:

share_raw = player_event / team_event                    # unweighted
share_sos = Σ(player_event · context) / Σ(team_event · context)
power_pts = share_sos · team_domain_score · domain_weight

Self-check. Sum offense_share_sos over a team across players matched to roster — should be ≤ 1.0 (some events are unattributed).


5. Graduation projection (validate every row in projected_power_*.csv)

For each departing senior in glax_*_roster.csv:

off_share = senior's SOS-weighted share of team goals (offense + utility positions only)
pos_share = senior's SOS-weighted share of weighted possession events
def_share = senior's SOS-weighted share of goalie minutes (or ga/sv if no minutes)

# Replacement-level subtraction (Opus #1 follow-up)
off_loss  = max(off_share - replacement_baseline[position]['offense'], 0)
pos_loss  = max(pos_share - replacement_baseline[position]['possession'], 0)
def_loss  = max(def_share - replacement_baseline[position]['defense'], 0)

Replacement baselines come from non-senior SOS shares pooled across 2024-25 + 2025-26 (roster_projection.get_replacement_baselines() and saved into run_meta.json → replacement_baselines). A bench attacker who steps in is not zero.

Concentration / depth / continuity multipliers (roster_projection.py):

Adjustment Trigger Effect
Concentration risk departing senior held ≥ 32% of team SOS goals +offense loss
Depth relief strong returning underclass off/pos share -off & -pos up to ~30%
Goalie continuity starter goalie leaves, backup played < 22% minutes +defense loss
Late underclass surge last-third goal share for non-seniors high -offense loss
Feeder pair departing assist–goal partner +offense loss
Returning draw % non-seniors hold most draw controls -possession loss
Award departure boost departing All-Conference / State / American points +off, +def, +pos (capped)
Award returning relief returning honors -off, -pos (capped)

Damping & caps (audit run_meta.json):

GRADUATION_DAMPING = 1.0      # no flat damping (Opus follow-up)
MAX_OFFENSE_LOSS   = 0.38
MAX_POSSESSION_LOSS = 0.42
MAX_DEFENSE_LOSS   = 0.45

After replacement-level subtraction, caps bind for fewer than 3 teams (was ~10 before the rewrite). When a cap binds it is reported in roster_stat_adjustments so the board can spot it.

Legacy prior (legacy_program_priors.csv): multi-year finish_*.tsv rolling strength → legacy_composite_adj (small ±2 nudge, weight = 0.12). MICDS, John Burroughs, Eureka, Cor Jesu sit > 0.80.

Manual sanity check. For any team in projected_power_*.csv:

projected_composite ≈ 0.35*off*(1−off_lost) + 0.35*def*(1−def_lost) + 0.30*pos*(1−pos_lost) + legacy_composite_adj

Reconstruct from team_power_*.csv + the loss percentages on the projected row + legacy_composite_adj. Should match within rounding.


6. Rank prediction (validate league_rerank_*.csv and ranking_recommendations_*.csv)

6.1 Roster-adjusted rank — the vote default

roster_rank_predictor.predict_ranks_from_graduation_rows (PROJECT.md design rule):

turnover_loss_pct_per_rank = 7.0 (% departure loss above median → ~1 rank slot)
proposed_move         = (departure_loss_pct - median_loss) / turnover_loss_pct_per_rank
stability_factor      = legacy + returning_share + low_loss_bonus + top8_bonus
move                  = proposed_move × (1 - stability_factor)
move                  = clamp(move, -MAX_RANK_SHIFT, +MAX_RANK_SHIFT)   # ±4 slots
projected_league_rank = prior_finish + move (then conflict-resolved to unique slots)

This is the primary predictor. Composite re-sort (projected_league_rank_composite) is shown only as a diagnostic — and the backtest below shows why we don't use it.

6.2 Secondary regression — flagged teams only

secondary_regression.apply_secondary_adjustments nudges the proposed slot when a team trips one of:

Flag code What it means
large_move_vs_prior Roster model moves ≥ 4 from last year's finish
low_returning_heavy_loss Returning < 35% AND drop ≥ 17 / loss ≥ 32%
high_returning_still_down Returning ≥ 55% but model still moves down ≥ 3
legacy_downgrade Legacy ≥ 0.82 but model moves down ≥ 2
award_talent_loss Departing award points ≥ 10 (tuned)
roster_composite_split Diagnostic onlycomposite re-sort disagrees by ≥ 8 slots
backtest_large_error Backtest only — projection error ≥ 5

The output is recommended_rank_adjusted (board vote line) plus secondary_slot_delta and human-readable secondary_adjustment_reasons.

6.3 Board overrides

board_overrides_{label}.csv is a hand-edit template: program_notes, board_voted_rank, final_rank. The pipeline merges it back into the recommendations before the markdown brief is rendered.


7. Award point scale (validate awards_*.csv + awards_tune_results.csv)

Source: https://stl-lacrosse-awards.fly.dev/data/season-results-{year}.json (live for 2026; older years 404 until released).

Tuned weights (winner of the holdout grid in awards_tune_results.csv):

Honor Points Notes
All-Conference 1st team 2 parsed from award_detail
All-Conference 2nd team 1
All-Conference HM 1
Conference Player of the Year 3 overrides 1st-team count
All-State HM 4
All-State 1st team 8
All-American 16 2× state 1st team

Graduation effect (per awards.apply_award_graduation_adjustments):

dep_boost  = min(0.14, departing_pts × 0.022)
total_off *= (1 + dep_boost)
total_pos *= (1 + dep_boost × 0.6)
total_def *= (1 + dep_boost × 0.8)

ret_relief = min(0.10, returning_pts × 0.018)
total_off *= (1 - ret_relief)
total_pos *= (1 - ret_relief × 0.5)

Board flag award_talent_loss triggers when departing points ≥ 10.

To re-tune: python3 awards_tune.py. The grid sweeps a dozen weight schemes against the 2025-26 holdout; the winner is recorded back in awards_weights.TUNED_AWARD_WEIGHTS.


8. Backtest validation (output/backtest_2024_25/)

Hold out 2024-25 stats + 2025 grads → predict 2026 finish. The numbers below are the current pipeline with all Opus fixes applied:

Predictor MAE all MAE top-20 Within ±3 (top-20)
Prior finish (2025) — naive 2.7 2.5 14/19
Roster-adjusted (prior + turnover) 3.2 2.1 14/18
Roster-adjusted + secondary (board line) 3.1 1.9 14/18
Composite re-sort (diagnostic only) 6.7 4.9 10/18

Reading guide:

Tier-conditioned MAE is in backtest_benchmark_by_tier.csv and backtest_team_rank_errors.csv (per-team residuals for every predictor).

Award holdout sweep: awards_tune_results.csv reports MAE for high-award-loss teams under each weight scheme.


9. Schedule risk (schedule_risk_matrix_*.csv)

k = least-squares fit of margin ≈ k × (composite_a − composite_b)  on MO-vs-MO games
expected_margin_a_minus_b = k × (projected_a − projected_b)
blowout = abs(expected_margin) ≥ 10

Two k values are written to run_meta.json: full-season (margin_k) and top-16-only (margin_k_top16). Both directions are written for every pair. No HFA yet — open backlog item.


10. Test coverage

49 unit tests across 14 files (tests/):

Test file What it pins
test_anchor.py Francis Howell collision regression; defense GA=0 returns None
test_sos_smooth.py Smooth SOS endpoints; no rank-5 cliff; top-20 boost
test_mo_league.py MO league pool inclusion / exclusion, IL trio in pool
test_rolling_rpi.py Rolling RPI snapshots monotonic with dates
test_domain_calibration.py Squash function calibration
test_roster_projection.py Replacement baselines; legacy prior ordering
test_roster_rank_predictor.py Capped rank shift; legacy shrink; unique slots
test_roster_stats.py Late surge, feeder pair detection, draw %
test_secondary_regression.py Flagged teams adjust; unflagged don't
test_ranking_outliers.py Flag rule coverage; review priority
test_board_override.py Override merge; final-rank league sort
test_game_margin_stats.py Top-16 margin subset; std with single game
test_benchmark.py / test_benchmark_tier.py MAE cohorts; tier breakdown rows
test_awards.py Sample JSON flatten; MICDS alias; departing vs returning split
test_awards_tune.py All-American = 2× state 1st invariant; conference nuance order
python3 -m unittest discover -s tests -v

11. Opus review — items shipped and items still open

Original review surfaced 5 P0/P1 weaknesses + ~30 section findings. Audit:

Opus finding Status Where to verify
#1 resolve_team_rank substring "first-hit" bug Fixed anchor.py:159-181 (exact → alias → mascot strip, no substring iteration); tests/test_anchor.py
#2 score_defense returns 100 for GA=0 Fixed composite_score.py:404-409 (returns None); regression test
#3 Offense / possession saturate near top Fixed domain_calibration.py squash; possession_p90 baseline (~42, was median ~27)
#4 Defense ceiling ~55 Fixed empirical defense_sf_low/high (11, 26) anchors; logistic squash
#5 Graduation damping binding for ~30% league Fixed damping=1.0, replacement-level subtraction, caps loose
§1 P1 logistic squashing Done domain_calibration.squash_*
§1 P1 possession baseline switch from median Done p90 in run_meta.json
§1 P1 fit_margin_coefficient median-of-ratios Done now least-squares (analysis.py:127-161)
§1 P1 k_proj apples-to-oranges Done only current-season k is used for expected_margin
§1 P2 domain weight sweeps Deprioritized sweep showed no top-20 MAE signal (PROJECT.md #4)
§2 P1 tier cliff effect Done smooth_finish_sos_weight
§2 P0/P1 anchor mode incomplete in player path Done ScoringConfig.sos_weight_fn flows through context_weight
§2 P0 OOS opponents default to bottom tier Done resolve_opponent_sos_rank RPI fallback
§2 P2 Wentzville not in pool Done now in finish_2026.tsv
§3 P0 stat_type='combined' filter Done glax_data.py:337-338
§3 P1 match_roster_to_stats_player last-name substring Done strict last-token equality (glax_data.py:521)
§3 P0 replacement-level model Done roster_projection.py baselines + net_departure_share
§3 P1 goalie minutes weighting Done glax_data.py goalie minutes path
§3 P2 position info used Done position_class
§4 P1 schedule symmetric pairs Done expected_margin_b_minus_a written
§4 P1 proj_scores frankenstein Mitigated composite re-sort kept as diagnostic only; vote line is roster-adjusted
§4 P1 blowout threshold 10 Open BLOWOUT_THRESHOLD = 10 (low priority)
§4 P2 HFA on schedule margins Open backlog
§5 P0 re-baseline backtest after P0 fixes Done numbers in §8 above
§5 P1 naive baseline reported Done benchmark.py
§5 P1 tier-conditioned MAE Done backtest_benchmark_by_tier.csv
§5 P1 ablation harness Done ablation.py, ablation_backtest.py, awards grid in awards_tune.py
§6 P1 module-global mutation Done ScoringConfig
§6 P2 sample code.py rename Done now composite_score.py
§6 P2 tests Done 49 tests
§6 P2 _int returns 0 silently Open low risk now that joins are validated
§7 P1 bootstrap rank confidence intervals Open backlog
§7 P1 per-domain attribution per player Done graduation_player_loss_*.csv
§7 P1 forward-looking rolling SOS Done rolling_rpi_*.csv
§7 P2 league rerank biggest movers diff Done benchmark_2027.md upgrades / downgrades

Conversation-level adds beyond Opus:

Open follow-ups (low priority unless you want them now):

  1. Bootstrap rank confidence intervals (proj_rank_lo, proj_rank_hi).
  2. Home-field advantage on schedule margins.
  3. _int to distinguish missing vs zero in raw stat ingestion.
  4. Backlog game-rooted predictors: goals per possession, SOS-weighted GA vs elite, H2H nudge inside tight clusters.

12. Auditor's checklist (one pass per season)

  1. python3 run_glax_analysis.py --season both runs without errors.
  2. python3 -m unittest discover -s tests — 49/49 pass.
  3. output/run_meta.json shows damping=1.0, replacement_baseline=true, legacy_prior=true, oos_games_excluded_from_stats=true.
  4. No team in team_power_*.csv has all three domain scores ≥ 95 (saturation guard). 4b. No team with games played has offense_score, defense_score, or possession_score exactly 0 (zero-floor guard — see sanity_report_*.md § Zero-domain guard).
  5. composite_score == 100 ⇒ at least one game with all three domain raws hitting 100 (rare).
  6. For every senior in roster.csv with grad_year == season_grad_year, a row exists in graduation_player_loss_*.csv or stderr logged the unmatched name.
  7. projected_composite ≈ Σ domain × weight × (1 − loss) reconstructs from CSVs (within 0.05).
  8. recommended_rank_adjusted for the prior #1 team is not the composite re-sort #1 unless they actually align (sanity: composite should never override the vote default by itself).
  9. Backtest top-20 MAE for Roster-adjusted + secondary (board line) ≤ 2.5.
  10. Award departure flagged teams (board award_talent_loss) have departing_awards_points ≥ 10 and a non-empty departing_awards string.

If any of those fail, do not ship the report — open output/sanity_report_*.md and the relevant CSV first.

Flag codes reference

Action flags trigger a board conversation; info flags are diagnostic only.

Code Severity Means Follow-up
award_talent_loss action Departing award points >= 10 Real talent loss vs quality returning class
high_returning_still_down action Returning >= 55% but model still moves down >= 3 Is the assist/feeder structure intact?
large_move_vs_prior action Model wants to move them >= 4 slots from last year Confirm the roster turnover is real
legacy_downgrade action Legacy >= 0.82 but model moves down >= 2 Coach interview, off-season program signals
low_returning_heavy_loss action Returning < 35% AND drop >= 17 / loss >= 32% Underclass video; replacement candidates
backtest_large_error info (Backtest only) projection missed by >= 5 Worth a future audit
roster_composite_split info Composite re-sort disagrees by >= 8 slots Diagnostic only - usually composite is wrong