---
title: Grade player season totals as hit rates, and declare the rank and playoff families
type: task
status: completed
description: >-
  Season-scope player markets refuse a hit rate because every settlement handler grades one game
  keyed on an esbid a season market does not carry. Make all twelve per-player season totals
  gradable at season grain, and declare the twenty-three rank and playoff types that should not be.
base_uri: user:task/league/grade-season-scope-prop-markets-as-hit-rates.md
created_at: '2026-09-16T00:58:44.069Z'
entity_id: 4c19ed46-1f03-4dfb-b33a-4f5d7fcca5f8
finished_at: '2026-09-26T21:38:30.232Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - follows [[████████████████████████-████-███-█████-████-████████-██████████]]
  - follows [[█████████████████████-███-█████-██████████-█████████-████████]]
  - relates [[███████████████████████-███████-███████-███-███████████]]
  - relates [[███████████████████████-██████-██████████-████████-████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-26T21:38:30.232Z'
visibility_analyzed_at: '2026-11-05T23:49:34.234Z'
---

## Overview

Make every per-player season total gradable at season grain, and give the rank and playoff families a sentence written for a reader instead of for a developer.

Two halves, and the refusal half is the larger one. **4,729 pid-carrying selections across twelve market types become gradable.** **4,934 pid-carrying selections across twenty-three market types stop rendering `market type X is absent from market_type_mappings`.**

What concretely changes, and for whom: a reader opening the Betting Markets tab on a player profile currently gets either a blank refusal or a developer sentence on every season-long market. Season props are the ones a reader is least able to price from memory, so the rate carries more per selection here than on a game prop — against a much shallower sample, which is what the denominator rendering exists to manage.

The refusal half ships first and alone. It needs no new module, no route change and no client change, it retires the exact sentence the operator saw, and it carries none of the wire risk in the grading half.

Ordering. This follows [[████████████████████████-████-███-█████-████-████████-██████████]], which built the per-game sample machinery this reuses, and [[█████████████████████-███-█████-██████████-█████████-████████]], whose `load_player_defender_gamelogs` is what makes season sacks reachable. It sits beside [[███████████████████████-███████-███████-███-███████████]], which works the untyped-market display — a different population in the same directory.

## Background

The Betting Markets tab on a player profile refuses a hit rate for every season-scope market, and the operator hit it on season passing yards 2026-09-15. Two different refusals sit behind that one message, and only one of them is fixed.

`GET /players/:pid/hit-rate` answers 200 carrying `is_gradable: false` and an `unsupported_reason`, which `hit-rate-panel.js` renders verbatim. Gradability is decided by `describe_hit_rate_path` in `libs-server/prop-hit-rate-sample.mjs`.

**The absence is structural, not a missing table entry.** Every handler in `market-type-mappings.mjs` grades one GAME keyed on the market's esbid, and no `SEASON_PLAYER` market row carries an esbid. A season total sums a whole season of games. That is a different grain, so it needs a different sample unit — not a new row in the settlement mappings, which would make the settlement worker start attempting markets with no esbid.

Landed already, commit `444728fcb`: each season-scope family declares its own `unsupported_reason`, so the panel no longer renders a blank after the colon.

Landed since, commit `59e97f2d7`: `describe_hit_rate_path` gained a defender branch and the route now picks the sample TABLE off the returned path, through a new `load_player_defender_gamelogs({ pids })`. That commit is what moves season sacks from refused to reachable, and its table-by-path rule is the precedent this plan follows rather than inventing a second mechanism.

### Assumptions

Each is a single falsifiable claim with the check that settles it. Measurements are against `league_production`, 2026-09-16, markets collapsed before joining selections.

- No `SEASON_PLAYER` market row carries an esbid — checked: 5,609 market rows, 0 with an esbid. Singly derived.
- The in-scope population is 4,729 pid-carrying selections across 12 types — checked, and independently re-derived by a second method that reproduced the per-type table row for row.
- The 23 rank and playoff types are absent from `market_type_mappings` — checked, 23 of 23, and the type counts independently confirmed from `bookmaker-constants.mjs` rather than from the database.
- `player_defender_gamelogs` can carry a season sack total for 2020 forward — checked against the team control per season: 2020-2025 run 98.5 to 99.5 percent of the team total with 256/256 to 272/272 game coverage. **2019 and earlier hold no defender rows at all**, which is why the defender source declares a coverage floor.
- `defensive_sacks` on `player_gamelogs` is team-only, so the defender table is required rather than preferred — checked as a control pair with both arms present: 666 team rows hold all 1,550 in 2025, 41,177 player rows hold 0. Re-derived by a second method via the per-season team control above.
- `calculate_metric_value` never returns null for a `metric_columns`-only mapping — checked at `prop-market-utils.mjs:159-172`: it skips null columns against a total starting at 0. This is why the season aggregator guards the RAW column per row, as `grade_player_defender_gamelog_selection` does, rather than guarding the return value.
- The soft reading carries no information at season grain — checked by counting the keys INSIDE the object, with a positive control: `market_type_line_cushions` holds 19 keys, all `GAME_PLAYER_*`, and zero `SEASON_*`. So `calculate_line_cushion` returns 0 and the soft reading equals the hard one for every season selection.
- No in-scope market claims to include the postseason, which is what REG-only actually rests on — checked over all 21 declared types: of 5,148 market rows, **zero** mention playoffs or postseason, 5,090 say "Regular Season" and 58 are silent on scope (54 Caesars, 4 DraftKings). An earlier draft cited 5,090 of 5,094 and called the 4 exceptions DraftKings-only; that denominator was scoped to the 12 types with rows rather than to the 21 now declared, and it understated the exceptions by the 54 Caesars alt markets. The naming margin is the weaker evidence and the zero-postseason count is the stronger one.
- The four core stat columns carry no nulls — checked: 0 nulls across 211,184 rows for 2021-2025. Singly derived.
- A null `season_year` on 188 in-scope selections does not reach the computation, because the request carries market type, selection type and line and never the market's season — checked against the route's query schema.
- No in-scope player hits the route's zero-gamelog 404 — checked: all 17 players with no COMPLETED season still have gamelog rows, so the empty-season-sample rendering is reachable and the 404 is not on this path.
- `current_season.year` is a getter reading 2026 that flips at `nfl_season_end`, so `current_season.year - 1` is exactly the last completed season year-round — checked at `libs-shared/season.mjs:460-465`. An earlier draft called this UNCHECKED and conservative; it is neither.

## The affected class, enumerated from the vocabulary

Derived from the constant groups in `libs-shared/bookmaker-constants.mjs`, not from names. A second method scoping by name prefix instead pulled in seven award types worth 1,189 selections that the group correctly excludes, which is the trap stated as a measurement rather than as a caution.

**Should have a hit rate** — `player_season_prop_types`. Counts are pid-carrying selections at selection grain:

| Market type | Selections | Sample source |
| --- | --- | --- |
| SEASON_PLAYER_RECEIVING_YARDS | 1,360 | player gamelogs |
| SEASON_PLAYER_RUSHING_YARDS | 689 | player gamelogs |
| SEASON_PLAYER_RECEIVING_TOUCHDOWNS | 644 | player gamelogs |
| SEASON_PLAYER_PASSING_YARDS | 618 | player gamelogs |
| SEASON_PLAYER_RUSHING_TOUCHDOWNS | 436 | player gamelogs |
| SEASON_PLAYER_PASSING_TOUCHDOWNS | 422 | player gamelogs |
| SEASON_PLAYER_RECEPTIONS | 380 | player gamelogs |
| SEASON_PLAYER_DEFENSE_SACKS | 156 | defender gamelogs, 2020 floor |
| SEASON_PLAYER_RUSHING_RECEIVING_YARDS | 8 | player gamelogs |
| SEASON_PLAYER_RUSHING_RECEIVING_TOUCHDOWNS | 6 | player gamelogs, no game twin |
| SEASON_PLAYER_PASSING_RUSHING_YARDS | 6 | player gamelogs |
| SEASON_PLAYER_PASSING_RUSHING_TOUCHDOWNS | 4 | player gamelogs, no game twin |

**4,729 selections across all 12 types with rows.** An earlier draft scoped this to nine types and 4,563 selections on the belief that three were unreachable. Two of those three have no game-grain twin, which is a reason not to DERIVE their columns and never was a reason they could not be declared; the third is sacks, which `59e97f2d7` reached.

`SEASON_PLAYER_PASSING_COMPLETIONS` is declared and gradable but has no rows. The eight `SEASON_PLAYER_ALT_*` types are declared and gradable but none carries a `selection_pid`, so none reaches a player profile today; a pid-attribution fix upstream moves them into scope with no further work here.

**Should NOT have a per-player hit rate, and should say so.** These are ABSENT from `market_type_mappings` entirely, so they produce `market type X is absent from market_type_mappings` — the exact message the operator saw:

| Family | Types | Pid selections |
| --- | --- | --- |
| `GAME_PLAYER_LEADER_*` | 3 | 3,536 |
| `SEASON_PLAYER_LEADER_*` | 9 | 1,342 |
| `PLAYOFF_PLAYER_*` | 8 (3 with rows) | 56 |
| `SEASON_PLAYER_SUNDAY_LEADER_*` | 3 | 0 |

`GAME_PLAYER_LEADER_*` is 72 percent of this population and was named nowhere before 2026-09-16. It is game-scope carrying a rank question, so neither the season sweep nor the player-game coverage gate reached it. It stays in scope here: it is the same defect, closed by the same mechanism, and splitting it out would re-create the by-name scoping that hid it.

## Design

### The substrate is aggregated gamelogs

`player_seasonlogs` exists and covers 12,274 of 12,274 gamelog player-seasons for 2021-2025 REG, and is still the wrong source: the per-game rate on the same panel is computed from gamelogs, and two readings of one player drawn from different substrates that disagree is a defect with no diagnostic on screen. The two already disagree on 11 player-seasons, maximum absolute difference 524 receiving yards.

### One declared mapping table, with a source and a gate against drift

New module `libs-server/prop-hit-rate-season-mappings.mjs` — inside the `prop-hit-rate*` file family it sits beside, rather than infixed out of it. It exports ONE object, `season_hit_rate_mappings`, whose values are mapping-SHAPED so they compose with the 107 existing `mapping.metric_columns` readers:

```
SEASON_PLAYER_PASSING_YARDS: {
  metric_columns: ['passing_yards'],
  sample_source: 'PLAYER_GAMELOG',
  game_market_type: 'GAME_PLAYER_PASSING_YARDS'
}
SEASON_PLAYER_ALT_SACKS: {
  metric_columns: ['defensive_sacks'],
  sample_source: 'PLAYER_DEFENDER_GAMELOG',
  coverage_floor: 2020,
  game_market_type: 'GAME_PLAYER_ALT_DEFENSE_SACKS'
}
SEASON_PLAYER_RUSHING_RECEIVING_TOUCHDOWNS: {
  metric_columns: ['rushing_touchdowns', 'receiving_touchdowns'],
  sample_source: 'PLAYER_GAMELOG',
  game_market_type: null
}
```

**Three entries are defender-sourced, not one:** `SEASON_PLAYER_DEFENSE_SACKS`, `SEASON_PLAYER_ALT_SACKS` and `SEASON_PLAYER_ALT_INTERCEPTIONS`. All three carry `coverage_floor: 2020`.

There is no companion refusals object. Every member of `player_season_prop_types` is now declared gradable, and a type absent from this table falls through to the existing `HANDLER_TYPES.UNSUPPORTED` branch, which already forwards a per-family reason — the `!mapping` check runs first, so the fall-through reaches the family reason rather than `absent from market_type_mappings`. This holds only while the season branch is inserted AHEAD of that check.

Columns are declared literally rather than referenced through the game twin, and the drift that invites is closed by a gate rather than by indirection: a spec asserts that for every season type WITH a game-grain twin, the declared columns equal the twin's. A column rename then turns that spec red instead of silently pointing the season rate at a dead column.

**A column-existence check cannot police `sample_source`, and that is the dangerous direction.** `defensive_sacks` and `defensive_interceptions` exist on `player_gamelogs` too — populated, `NOT NULL`, and team-only. So declaring a defensive type `sample_source: 'PLAYER_GAMELOG'` passes an `information_schema` verify green and grades every defensive season at zero for every player, which is the reverted 2026-09-14 defect at a new grain.

The source gets two gates, and neither reasons from a market NAME. An earlier draft of this section proposed asserting that every type named `*_SACKS` or `*_INTERCEPTIONS` declares the defender source; that is a name-based market test, which this codebase rules out, and it is also unnecessary.

**Structural gate, in the suite.** Each entry declares its `game_market_type` explicitly, and the gate asserts the season entry's `metric_columns` equal the twin's and its `sample_source` equals the twin's handler. Both facts are read from `market_type_mappings`, so a twin's handler or columns moving turns the gate red. Nineteen of the twenty-one members have a twin. Two of those twins cannot be reached by rewriting the name and are declared explicitly: `SEASON_PLAYER_ALT_SACKS` maps to `GAME_PLAYER_ALT_DEFENSE_SACKS` and `SEASON_PLAYER_ALT_INTERCEPTIONS` to `GAME_PLAYER_ALT_DEFENSE_INTERCEPTIONS` — the `DEFENSE_` infix appears on the game side only.

**Data check, against production.** The two combined-touchdown types have no twin at all, so the structural gate cannot reach them, and the suite cannot close the gap either: there is no gamelog fixture, `player_defender_gamelogs` is not in `db/fixtures/reset-league-tables.mjs`, and the hit-rate specs insert their own gamelog rows — so a spec asserting a column is usable would be asserting it about a row the spec author wrote, which is the hand-built-row failure. The property belongs in a registered data check per [[user:guideline/software/design-data-checks.md]]: every declared column must carry at least one NONZERO value at player grain on its declared source table.

That check discriminates, measured 2021-2025: `player_gamelogs.defensive_sacks` has 0 nonzero player-grain rows out of 207,852 and fails, while `player_gamelogs.passing_yards` (4,459), `player_defender_gamelogs.defensive_sacks` (6,206) and `player_defender_gamelogs.defensive_interceptions` (1,962) all pass. It tests the property that actually matters — is this column populated at the grain being joined — rather than the property that is easy to test.

### Two paths, and the route's table pick must become a lookup

`describe_hit_rate_path` returns `PLAYER_GAMELOG_SEASON_TOTAL` or `PLAYER_DEFENDER_GAMELOG_SEASON_TOTAL`, so the path keeps deciding the table rather than a source field inside the mapping becoming a second mechanism for the same decision.

But the route does NOT already accommodate this. `api/routes/players.mjs:1663-1666` is a binary ternary testing `path === 'PLAYER_DEFENDER_GAMELOG'`, so `PLAYER_DEFENDER_GAMELOG_SEASON_TOTAL` silently takes the else arm and loads `player_gamelogs` — reproducing exactly the team-only-column defect the comment three lines above that ternary warns about, as a confident zero sack rate with no diagnostic. The ternary becomes an explicit path-to-loader map. An earlier draft of this plan asserted the route needed no change here; that was wrong.

`calculate_selection_hit_rate`'s terminal `return calculate_nfl_plays_hit_rate(...)` is an unguarded default — a new path with no branch would grade season totals as plays over undefined esbids and return plausible zeros. It becomes an explicit dispatch that throws on an unrecognized path. To make that throw reachable from a test, the function takes the resolved path as an optional parameter defaulting to `describe_hit_rate_path(market_type)`; injection is the only way to drive a branch the dispatcher itself can never produce.

### The season sample

`build_completed_season_totals` filters to `season_type === 'REG'`, drops seasons above `current_season.year - 1` and below the mapping's `coverage_floor`, groups by `season_year`, and sums `calculate_metric_value` per row. Nulls are guarded on the RAW column per row, matching the defender handler — guarding `calculate_metric_value`'s return would be dead code, since it skips nulls against a zero total.

**An ungradable season does not enter the SAMPLE at all**, so `graded` and `total` coincide on this path and `rate` keeps the one meaning it has on the per-game path — hits over `total`, exactly as `summarize_sample` already computes it. An earlier draft redefined `rate` as hits-over-graded for the season path only, which would have put two meanings behind one field name on one endpoint; excluding the season upstream reaches the same number without the overload. The reason it matters here and not at game grain: at a median of five seasons one ungradable season is a fifth of the denominator, against roughly a fiftieth at game grain.

A season excluded by the defender coverage floor still appears in `season_events` carrying its reason, so a pre-2020 gap reads as a gap rather than vanishing — it is visible without being counted.

### Wire shape

The season path serves `season_events`. `hit-rate-panel.js` already renders a distinct branch when `events` is absent, so an older SPA degrades to a truthful-enough message with no client change.

A season event carries `season_year`, `sampled_games`, `metric_value`, `hard_result`, `ungradable_reason`. It does NOT carry `soft_result`: the cushion table holds no season keys, so the soft reading is identical to the hard one across the whole population, and shipping both would render one number twice as though they were independent evidence. `sampled_games` rather than `games_played`, which already names a seasonlogs-derived quantity this plan documents as disagreeing with the gamelog sum.

New swagger component `HitRateSeasonEvent`, split from `HitRateEvent` per the api guide's split-not-nullable rule. **No response-validation hold-out entry.** That budget applies to a spec reaching a previously-unexercised operation; this 200 pair is already exercised and clean, and an entry that stops failing fails the run. The new 400s DO need `$ref: BadRequestError`, or each becomes a 500.

### Filters: report, do not refuse

An earlier draft refused `weeks`, `opponent_nfl_teams`, `is_active`, `is_starter` and `last_n` with a 400. A 400 is the one failure this panel cannot render: it produces no FULFILLED action, so the sample stays undefined and the panel sticks on its loading branch forever. The preset controls send `last_n` and the starters checkbox sends `is_starter`, so a stale client would hang on a click.

Instead the season path answers 200 and carries `ignored_filters`, naming every param the season grain cannot apply. That is honest rather than silent, renderable, and directly assertable. The client suppresses the window presets AND the starters checkbox on the season path, so the array is empty in normal operation and non-empty only for a deploy-skewed or direct caller.

`as_of_season_year` is honored as an upper bound on seasons. `as_of_week` and `as_of_season_type` are sub-season and go into `ignored_filters` — otherwise the as-of filter truncates a season's rows before grouping and a partial season is summed against a full-season line, which is the in-progress defect arriving by a different door.

### The stored precompute stays game-grain, deliberately

`scripts/calculate-historical-hit-rates.mjs` dispatches on the three existing predicates and drops every season type to a terminal branch. `libs-server/prop-hit-rate.mjs` states as an invariant that the script and the library must agree. Extending the precompute writes stored columns consumed by the markets routes and is its own body of work, so this plan records the exception and GATES it — a spec asserting season types are absent from the precompute's dispatch, so the absence stays deliberate rather than becoming drift.

### Knowledge Base Impact

Two query-trap sheets extended, two observations on sibling tasks, one follow-up task for the precompute. No new tag, no domain entry document, no ABOUT.md change.

### Working tier and orchestrator shape

**No scratch slug** — every artifact is source, spec or entity text. **No orchestrator** — bounded, single-domain, days not weeks.

## Plan

### Refusal Declaration — ships first, alone

- [x] **Mint the rank and playoff constant groups**
  - File: `libs-shared/bookmaker-constants.mjs`
  - Purpose: declare by family rather than one type at a time.
  - Changes: add `player_rank_prop_types` (the 9 season leader, 3 game leader and 3 Sunday leader types — one group, because all fifteen ask the same rank question at different grains) and `player_playoff_prop_types` (8).
  - Verify: assert each group's length (15, 8) and that the two sets do not intersect; control by asserting a known member of each resolves.

- [x] **Declare both families unsupported with their own reasons**
  - File: `libs-server/prop-market-settlement/market-type-mappings.mjs`
  - Purpose: retire the developer sentence on 4,934 pid-carrying selections.
  - Changes: two `declare_family_unsupported` calls — a rank reason naming that the question needs a leaderboard across every player and carries no line, and a playoff reason naming the grain.
  - Verify: assert all 23 types resolve to `UNSUPPORTED` with a non-empty reason and the two reasons are distinct strings; invert by blanking one reason and confirm THAT assertion reddens, not merely the suite.

- [x] **Gate the whole forwarding population, not just what this plan adds**
  - File: `test/prop-market-settlement.player-market-type-coverage.spec.mjs`
  - Purpose: the forwarding branch serves every `UNSUPPORTED` entry in `market_type_mappings`, which is the population that shipped the blank. A gate covering only the new groups leaves a family declared tomorrow ungated.
  - Changes: assert EVERY `UNSUPPORTED` entry carries a non-empty string reason, then add membership checks for the two new groups on top. Carry a vacuity case asserting the enumerated set is non-empty.
  - Verify: empty the enumeration and confirm the vacuity case reddens; blank one unrelated family's reason and confirm the population gate catches it.

- [x] **Assert the reason survives serialization, at the route**
  - File: `test/players.hit-rate-sample.spec.mjs`
  - Purpose: the original defect was `undefined` surviving the object and being dropped by JSON. A gate over the source object cannot see that.
  - Changes: one case per family asserting `unsupported_reason` is a non-empty string in the parsed JSON body, for one rank type and one playoff type.
  - Verify: the assertion must fail when the reason is set to `undefined` rather than to `''`.

### Season Grading

- [x] **Declare the season mappings**
  - File: `libs-server/prop-hit-rate-season-mappings.mjs` (new)
  - Purpose: one declared table giving every `player_season_prop_types` member its columns and its sample source.
  - Changes: export `season_hit_rate_mappings`; mapping-shaped values carrying `metric_columns`, `sample_source`, an explicit `game_market_type` (null for the two with no twin), and `coverage_floor` on the three defender entries — `SEASON_PLAYER_DEFENSE_SACKS`, `SEASON_PLAYER_ALT_SACKS`, `SEASON_PLAYER_ALT_INTERCEPTIONS`.
  - Verify: assert every member of `player_season_prop_types` is present; that every declared column exists on its DECLARED source table via `information_schema`; and that for each of the 19 entries with a `game_market_type`, BOTH the columns and the `sample_source` equal the twin's `metric_columns` and `handler` read from `market_type_mappings`. Invert one `sample_source` to `PLAYER_GAMELOG` and confirm the source half reddens while the column half stays green — that divergence is the whole reason the source is gated separately. Assert the twinned count is 19 so the gate cannot go vacuous if `game_market_type` is dropped. Land with its spec in the same commit.

- [x] **Register a data check that the declared columns are usable at player grain**
  - Purpose: the structural gate above cannot reach the two types with no twin, and the suite cannot close it — there is no gamelog fixture, `player_defender_gamelogs` is absent from `db/fixtures/reset-league-tables.mjs`, and the hit-rate specs insert their own rows, so a spec would be asserting about a row its own author wrote. This is the check that would have caught the 2026-09-14 team-only-column defect.
  - Changes: a registered check per [[user:guideline/software/design-data-checks.md]] asserting that every column in `season_hit_rate_mappings` carries at least one NONZERO value at player grain on its DECLARED source table.
  - Verify: run it against production and confirm all 21 entries pass; then point one defensive entry at `PLAYER_GAMELOG` and confirm it fails naming that entry. The two readings must differ. Baseline for the control, measured 2021-2025: `player_gamelogs.defensive_sacks` 0 nonzero of 207,852 player-grain rows, against `player_gamelogs.passing_yards` 4,459, `player_defender_gamelogs.defensive_sacks` 6,206 and `player_defender_gamelogs.defensive_interceptions` 1,962.
  - Note: the team-row exclusion is load-bearing on `player_gamelogs`, which carries 16,368 team rows, and a NO-OP on `player_defender_gamelogs`, which carries none and is player-grain by construction. The check must not read as though the filter is protecting the defender side.

- [x] **Make the path dispatch explicit and injectable**
  - File: `libs-server/prop-hit-rate-sample.mjs`
  - Purpose: the terminal default would grade a new path as plays over undefined esbids.
  - Changes: explicit per-path dispatch throwing on an unrecognized path; accept the resolved path as an optional parameter defaulting to `describe_hit_rate_path(market_type)`.
  - Verify: inject an unrecognized path and assert it throws naming that value; pair against an injected `NFL_PLAYS` that still returns a reading, so the two differ.

- [x] **Teach `describe_hit_rate_path` the two season paths**
  - File: `libs-server/prop-hit-rate-sample.mjs`
  - Changes: a branch returning `PLAYER_GAMELOG_SEASON_TOTAL` or `PLAYER_DEFENDER_GAMELOG_SEASON_TOTAL` per the mapping's `sample_source`, ahead of the absent-from-mappings branch.
  - Verify: assert the three existing gradable paths are unchanged, that sacks return the defender season path, and that a type removed from the season table falls back to its family reason rather than to `absent from market_type_mappings`.

- [x] **Build the completed-season sample**
  - File: `libs-server/prop-hit-rate-sample.mjs`
  - Changes: `build_completed_season_totals({ player_gamelogs, mapping })` — REG only, at or below `current_season.year - 1`, at or above `coverage_floor`, grouped by `season_year`, summing `calculate_metric_value` per row, guarding the RAW column, carrying `sampled_games`.
  - Verify: over fixtures spanning a complete and an in-progress season, assert the in-progress year is absent AND that the reading differs from the same call with the anchor removed. Pin the clock in `beforeEach` at four instants — REG, POST, offseason, PRE — and restore it in `after`.

- [x] **Grade a season sample**
  - File: `libs-server/prop-hit-rate.mjs`
  - Changes: `grade_player_season_selection` and `calculate_player_season_hit_rate`; hard reading only; an ungradable season is excluded from the sample so `graded` equals `total` and `rate` keeps its existing hits-over-`total` meaning; per-season try/catch writing `ungradable_reason` so a throw from `determine_selection_result` cannot 500 the route.
  - Verify: assert an exact rate over fixtures straddling the line, plus a mixed case with one ungradable season asserting the NUMERIC rate excludes it and that `graded === total`; add the excluded season to the sample and confirm the numeric rate changes, so the two readings differ.

- [x] **Serve the season reading**
  - Files: `api/routes/players.mjs`, `api/swagger/config.mjs`
  - Changes: replace the binary table ternary at `api/routes/players.mjs:1663-1666` with an explicit path-to-loader map, so a season path cannot fall to the else arm and load the wrong table; emit `season_events` and `ignored_filters`; declare `HitRateSeasonEvent`; `$ref: BadRequestError` on the 400s.
  - Verify: `node db/gates/check-api-response-shapes.mjs --gate 1` passes; a spec asserts a season request carries `season_events` and omits `events`, PAIRED with a game-grain request in the same spec that DOES carry `events`, so the absence assertion cannot pass vacuously. Separately assert a defender SEASON request for a known sack-carrying pid returns a NON-ZERO metric — a zero is what the wrong table produces, and it is the only reading that distinguishes the two. Add no hold-out entry.

- [x] **Report ignored filters rather than refusing them**
  - Files: `api/routes/players.mjs`, `app/views/components/selected-player-markets/hit-rate-panel.js`
  - Changes: season path names inapplicable params in `ignored_filters`; the panel suppresses the window presets AND the starters checkbox on the season path.
  - Verify: one case per reported param asserting it is named and that the reading is still 200 with a rate; assert `ignored_filters` is strictly an array of length 0 for a request carrying none — `Array.isArray` plus length, never a falsy check, which an absent field satisfies on an unmodified tree.

- [x] **Render the season reading**
  - Files: `app/views/components/selected-player-markets/market-season-event-list.js` (new), `hit-rate-panel.js`, `selected-player-markets.styl`
  - Purpose: show the rate with its season denominator and degrade honestly.
  - Changes: `MarketSeasonEventList` — named to avoid the existing `market-season-filter.js`, where "season" means the year picker. A flat list importing `RESULT_LABELS` and `result_of` from the existing `market-event-presentation.js` rather than minting a second presentation module; no chart, no accordion, no MUI import. Branch the panel on `season_events` present, absent and empty; the 17 players with no completed season get their own sentence. Style block named `market-season-event-list`.
  - Verify: unit-test the presentation reuse on a season event, an empty list and a coverage-floor season. Run the MUI ratchet spec AFTER the component exists and confirm it is still green — a ratchet run against an unwritten file passes vacuously. The rendered browser pass is the operator's.

### Knowledge Base Updates

- [x] **Extend the two query-trap sheets**
  - Files: `████████████████████████████████████████████████` (renamed from `prop-markets-index.md` in `787a03e291` when the two index tables were dropped), `███████████████████████████████████████████████████`
  - Changes: record that no `SEASON_PLAYER` row carries an esbid and that `market_type_mappings` is not the gradability oracle for season scope; on the gamelogs sheet, that the defender table is the player-grain defensive source and holds nothing before 2020.
  - Verify: grep each sheet for the exact literal `no SEASON_PLAYER market row carries an esbid` and `no defender rows before 2020`, and require at least one hit AFTER the edit; run the same grep before the edit and require zero, so the pair of readings differs.

- [x] **Record the cross-lane findings**
  - Changes: on [[█████████████████████-███-█████-██████████-█████████-████████]], that its loader made season sacks reachable and that the defender source holds nothing before 2020. On [[███████████████████████-██████-██████████-████████-████████]], that `player_seasonlogs` disagrees with the gamelog sum on 11 player-seasons for 2021-2025 REG, maximum absolute difference 524 receiving yards.
  - Verify: `base entity get` on each shows the new observation.

- [x] **File the precompute follow-up and gate the exception**
  - Changes: create a task for extending `calculate-historical-hit-rates.mjs` to season grain; add a spec asserting season types are absent from its dispatch so the gap stays deliberate.
  - Verify: invert by adding a season type to the precompute dispatch and confirm the gate reddens.

### Testing and Validation

- [x] **Cover each refusal branch with an input that can only take it**
  - File: `test/players.hit-rate-sample.spec.mjs`
  - Purpose: `describe_hit_rate_path` refuses several ways and the suite has historically exercised whichever input was handy, which is how the blank shipped.
  - Changes: one case per branch naming its branch in the title — absent-from-mappings, declared-unsupported family, wrong-handler, and each new season path as a gradable control.
  - Verify: invert each branch in turn and record WHICH assertion reddened, not that the suite went red.

- [x] **Run the suite and read the summary line**
  - Purpose: a run that aborts during module load executes nothing and emits no summary, and every grep for failures reports clean.
  - Verify: `yarn test:isolated` produces an `N passing` line, the new spec names appear in the log, and `yarn verify` is green. Zero passing and zero failing together is a load abort. Capture a full-suite baseline BEFORE the first change and diff against it; do not rely on a remembered red-job name, because the private suite is deliberately not run in this repo's CI and reports from the cluster instead.

## Impact

Twelve market types and 4,729 pid-carrying selections gain a season-grain hit rate. Twenty-three types and 4,934 pid-carrying selections stop showing a reader a sentence written for a developer.

The risk this design spends most of its care on is the denominator. A rate of 67 percent over 3 seasons is not the same object as 67 percent over 46 games: among the 239 players in scope, the median is 5 completed seasons among the 222 who have any, 22 have exactly one, and 17 have none at all.
