---
title: Add a Defender Gamelog Hit-Rate Path
type: task
status: completed
description: >-
  Settlement grades five defensive market types through PLAYER_DEFENDER_GAMELOG and the hit-rate
  calculator matched neither of its two handler predicates, so the whole defensive family carried a
  null stored rate. A defender predicate, grading sibling, null-column guard and
  player_defender_gamelogs sample landed in league c98d7cffc; the recompute populated 122,654 of
  166,487 selections across 2023-2026.
base_uri: user:task/league/add-defender-gamelog-hit-rate-path.md
created_at: '2026-09-15T16:20:30.926Z'
entity_id: 00b6f303-4e62-4d49-bacf-820d7ae9f46d
finished_at: '2026-09-15T19:18:14.641Z'
owner_identity_uri: █████████████████████████
priority: High
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-15T19:18:14.641Z'
visibility_analyzed_at: '2026-11-05T23:48:10.608Z'
---

# Implementation Plan: Add a Defender Gamelog Hit-Rate Path

## Overview

Settlement grades five defensive market types through `PLAYER_DEFENDER_GAMELOG` (league `d8f4afe12`), and the 2026-09-15 re-grade settled 108,074 selections through it. The hit-rate calculator cannot see any of them.

`scripts/calculate-historical-hit-rates.mjs` routes through two predicates in `libs-server/prop-hit-rate.mjs`, and each tests for exactly one handler:

- `is_player_gamelog_market` — `mapping.handler === PLAYER_GAMELOG`
- `is_nfl_plays_market` — `handler === NFL_PLAYS` and not `team_aggregate`

`PLAYER_DEFENDER_GAMELOG` matches neither, so the five types are skipped.

**CORRECTION (2026-09-15, implementing session).** The symptom above was already stale when this task was written, and re-measuring is what caught it. The zero-selections shape was true before league `2ad608498` (2026-09-11), which made the script LOAD these selections and refuse each with a named reason instead of skipping them at the query level. The reading at the time this task was authored, and at implementation:

- `GAME_PLAYER_TACKLES_ASSISTS` — 4,018 selections loaded, every one refused with `market type has no hit-rate grading path`, exit 0
- `GAME_PLAYER_RECEIVING_YARDS` — 19,166 selections, matching the control figure below exactly

The defect was identical either way: no grading path, no stored rate for the whole family. Only the diagnostic changed, and for the better. The verification signal is therefore the `Ungradable` count dropping to zero for these types, not a non-zero selection count — the selections already load.

## Background

This is the third time a downstream consumer has failed to learn about a settlement handler change, and the first two both wrote wrong data. This one writes nothing, which is better, but it is the same root shape: settlement dispatches on `mapping.handler` through a registry, while consumers re-derive scope from hand-named handler constants.

`test/prop-market-settlement.hit-rate-handler-reachability.spec.mjs` now pins the invariant and carries these five as a named baseline. **Deleting them from `KNOWN_UNREACHABLE` is the acceptance test for this task** — the spec's second arm already fails if they are listed once they become reachable, so the baseline cannot be left stale.

## Plan

- [x] **Add `is_player_defender_gamelog_market`** beside the two existing predicates, testing `mapping.handler === PLAYER_DEFENDER_GAMELOG`.
- [x] **Added a defender sibling** (`grade_player_defender_gamelog_selection`), not a generalisation — the null-column guard is correct only for this handler. The grading body is already handler-agnostic — it calls `calculate_metric_value(row, mapping)` and `determine_selection_result` — so the work is the guard at the top and the row it is handed, not the arithmetic.
- [x] **Carry the null-metric-column guard across.** A present defender row with a null mapped column settles at zero through `calculate_metric_value`, exactly as it did in settlement before league `0d50bf399`. The hit-rate path must refuse it the same way, or the same defect reappears in the derived columns rather than the graded ones. 8 rows of 61,344 carry a null `tackles`.
- [x] **Load the sample from `player_defender_gamelogs`** in `calculate-historical-hit-rates.mjs`. This is the real work: the script currently loads `player_gamelogs` for the player set, and the defender family needs its own load keyed the same `(esbid, pid)` way.
- [x] **Delete the five entries from `KNOWN_UNREACHABLE`** and prove the reachability gate green with them gone.
- [x] **Run the recompute** scoped with `--market_types` over the five types, reading the stored-versus-computed transition matrix under `--dry_run` first.

## Verification — as performed 2026-09-15

- **The `Ungradable` count for the five types, not the selection count.** The selections already loaded before this work; what changed is that they are now graded. `GAME_PLAYER_TACKLES_ASSISTS` went from 958,216 refusals, every one `market type has no hit-rate grading path`, to 172, every one the null-`tackles` guard. Zero `no grading path` refusals remain across all five.
- **Control, unchanged in the same run:** `GAME_PLAYER_RECEIVING_YARDS` reads 19,166 selections and zero class changes before and after, and the defender load correctly does not fire for it.
- **The reachability spec passes with an empty `KNOWN_UNREACHABLE`**, plus a new arm requiring each graded type to match exactly one predicate.
- **Both gate arms and the null guard were proven to fail** by injection, each with its controls staying green.

## Outcome

- 122,654 of 167,268 defensive selections carry a rate. `GAME_PLAYER_TACKLES_ASSISTS` averages 0.4995 and `GAME_PLAYER_DEFENSE_SACKS` 0.4967 — where correctly-priced two-sided markets sit.
- 41,075 remain null because they carry no posted line, owned by [[███████████████████████-██████████-████-███████-███-██████-██████-█████████]].
- 773 sit under market rows with no esbid on either `time_type` side, so no game is linked and there is nothing to grade. Not the half-linked class the data-check registry owns.
- The second consumer, `scripts/calculate-weekly-market-selections-analysis.mjs`, had the same class of hole latent — an unreachable type scored every game as a non-hit rather than refusing. Guarded and gated in league `c609fdc01`.

## Assumptions

- **The hit-rate sample for a defensive market means the same thing as for an offensive one** — the player's own history on that metric. Untested but low-risk: the columns are per-player per-game counts exactly like receptions or rushing yards.
- **Coverage is good enough for a meaningful sample.** 2023-2025 REG and POST carry a defender row for every completed game at 38.7 rows per game, but PRE has none in any season and roughly 278 player-games with real snaps are missing. A window that lands mostly on those reports a thin sample rather than a wrong one.
