---
title: Repair the stored hit rates that read zero
type: task
status: completed
description: >-
  Ten PLAYER_GAMELOG market types have no case in selection-result.mjs and fall through to null,
  which is_hit reads as a loss, so 31,245 stored rows report a hit rate of exactly zero on two
  user-facing surfaces.
base_uri: user:task/league/repair-stored-hit-rates-reading-zero.md
created_at: '2026-09-04T08:48:13.544Z'
entity_id: 068a1c3f-89c8-4cd0-9eb8-2b29b1329612
finished_at: '2026-09-08T18:07:47.794Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - relates [[████████████████████████-████-███-█████-████-████████-██████████]]
  - relates [[█████████████████████████-████-██████-█████-████████]]
started_at: '2026-09-04T18:58:27.228Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-08T18:07:59.019Z'
visibility_analyzed_at: '2026-11-05T23:50:09.467Z'
---

# Repair the Stored Hit Rates That Read Zero

## Overview

- Four prop market types carry a stored historical hit rate of exactly zero on every row, because the grader used to compute them has no rule for those types and scores every game a loss.
- Impact: 31,245 rows tell every reader the prop has never once hit. They render on the selected-player tab and feed the data-view `player_game_prop_historical_hit_rate` column, so the number is wrong on two user-facing surfaces.
- Ordering: found while planning `[[████████████████████████-████-███-█████-████-████████-██████████]]`, whose end-state analysis proposes the structural fix. This task exists because the defect is live now and worth correcting whether or not that redesign proceeds.

## Background

`libs-server/selection-result.mjs` grades a game against a line with a 35-case `switch (market_type)`. Ten of the 33 `PLAYER_GAMELOG` market types have no case and reach `default` at `:279`, which returns `null`. `is_hit` at `:285` is `get_selection_result(params) === 'WON'`, so `null` becomes `false` — a loss rather than a refusal to grade.

The unhandled types: `GAME_DEFENSE_SACKS`, `GAME_FIELD_GOALS_MADE`, `GAME_TWO_PLUS_TOUCHDOWNS`, `GAME_ALT_PASSING_INTERCEPTIONS`, `GAME_ALT_PASSING_ATTEMPTS`, `GAME_ALT_RUSHING_TOUCHDOWNS`, `GAME_ALT_RECEIVING_TOUCHDOWNS`, `GAME_ALT_PASSING_RUSHING_YARDS`, `GAME_ALT_DEFENSE_SACKS`, `GAME_ALT_FIELD_GOALS_MADE`.

Reproduce the mechanism directly — a gamelog with five field goals, graded OVER 0.5, returns `false`:

```bash
NODE_ENV=test node --input-type=module -e "
const sr = await import('./libs-server/selection-result.mjs')
console.log(sr.is_hit({ line: 0.5, market_type: 'GAME_FIELD_GOALS_MADE',
  player_gamelog: { field_goals_made: 5 }, strict: true, selection_type: 'OVER',
  unsupported_market_types: new Set() }))"
```

The stored consequence, measured 2026-09-04:

```sql
SELECT m.market_type, count(*) AS rows_with_hit_rate,
       max(s.overall_hit_rate_hard) AS max_rate,
       count(*) FILTER (WHERE s.overall_hit_rate_hard = 0) AS zero_rows
FROM prop_markets_index m
JOIN prop_market_selections_index s
  ON m.source_id=s.source_id AND m.source_market_id=s.source_market_id AND m.time_type=s.time_type
WHERE m.market_type IN ('GAME_TWO_PLUS_TOUCHDOWNS','GAME_ALT_PASSING_RUSHING_YARDS',
                        'GAME_DEFENSE_SACKS','GAME_FIELD_GOALS_MADE')
  AND s.overall_hit_rate_hard IS NOT NULL
GROUP BY 1;
```

`GAME_TWO_PLUS_TOUCHDOWNS` 18,331 rows / 18,328 zero; `GAME_ALT_PASSING_RUSHING_YARDS` 8,956 / all zero, max 0.0000; `GAME_DEFENSE_SACKS` 2,202 / all zero; `GAME_FIELD_GOALS_MADE` 1,756 / all zero. The other six unhandled types have no stored rows today and would be wrong the moment a book posts them.

A related defect in the same function: `ANYTIME_TOUCHDOWN` sums two of its five declared `metric_columns` at `:123-126`, dropping return touchdowns, and has no `strict` branch so hard equals soft.

### Assumptions

- **The zero values come from this mechanism and not from genuinely-never-hit props.** Checked: `GAME_FIELD_GOALS_MADE` OVER 0.5 is a routine hit, and the reproduction above shows the grader returning `false` for a five-field-goal game.
- **Settlement is unaffected.** Checked: nothing in `libs-server/prop-market-settlement/` imports `selection-result.mjs`; settlement grades through `determine_selection_result` and the three handlers. Only the hit-rate columns are wrong, not `selection_result`.

## Design

Two routes, and the choice belongs to whoever owns the hit-rate columns.

**Structural.** Retire `selection-result.mjs` and derive hit rates from `calculate_metric_value` plus `determine_selection_result`, which settlement already uses and which cover all 90 supported types. This is the end state recorded in `[[████████████████████████-████-███-█████-████-████████-██████████]]`, and it fixes this defect as a side effect.

**Local.** Add the ten missing cases to the switch and correct the `ANYTIME_TOUCHDOWN` column set, then recompute. Smaller and faster, but it deepens the duplication the structural route removes.

Either way the stored rows need recomputing; the values are derived, so no backfill script of their own is needed beyond running the existing cron over the affected types.

### Knowledge Base Impact

- Observation candidate on this task recording the mechanism, since a future reader seeing a zero hit rate will otherwise assume the market never hits.
- No new tag, no scratch slug. Not an orchestrator.

## Notes

- **A zero here is indistinguishable from a real zero at the point of use**, which is why this went unnoticed. Any fix should make an ungradable market return no rate rather than a rate of zero.
- `unsupported_market_types` is a mutable accumulator that no caller reads, so it is not currently a refusal channel even though it reads like one.

## Plan

- [x] **Decide structural versus local with the column owner** — structural, and shipped
  - The derivation moved to settlement's own pair in league `986634fe6`. Recorded as `[decision]` observations on [[████████████████████████-████-███-█████-████-████████-██████████]].

- [x] **Make an ungradable market return no rate rather than zero** — done, league `badf70ad3` then `78e20b32e`
  - `badf70ad3` covered the gamelog branch. `78e20b32e` covered the larger half — the 47 market types with no case in `selection-result.mjs`, 84,843 rows — plus the empty-sample branch that returned 0 when the window held no games at all.
  - Verified by control: each of the three changes reverted individually turns exactly two specs red, and the restored tree is green.

- [x] **Close the coverage gap for the ten `PLAYER_GAMELOG` types** — done, league `986634fe6`
  - The remaining coverage work is not this task's. The `NFL_PLAYS` conversion and the 23 types absent from `market_type_mappings` are increments on the structural task, which owns the end state.

- [x] **Recompute all four seasons and confirm the distribution moves** — done 2026-09-08
  - The fix was already deployed when the lane ran: host `league` HEAD `7f297e00b` contains `78e20b32e`, so `yarn deploy:all` was deliberately not run.
  - Recomputed unscoped, exit 0 each: 2023 142,426 / 2024 1,007,528 / 2025 1,134,203 / 2026 70,242 selections.
  - Verified: the post-recompute dry-run reports **0 class changes on all four seasons**, so stored equals computed under the fixed code everywhere the script can reach. Logs in `scratch/league/hit-rate-recompute/`.

- [x] **Repair the markets that know their game on only one of their two rows** — done 2026-09-08
  - Supersedes the "134 rows no invocation can touch" item, which named the wrong cause and the wrong population. `--year` is not the only gate: `calculate-historical-hit-rates.mjs:330` also filters `whereNotNull('prop_markets_index.esbid')`, and the team-market sibling at `:110-112` carries the identical pair. A row whose market has no esbid is unreachable by any invocation regardless of season.
  - The live population is **21,545 half-linked markets** — one time_type row names a game, the other names nothing — carrying **5,745 frozen hit rates, 917 of them exactly zero**. All 21,545 carry exactly one distinct esbid across their two rows, so the repair cannot pick the wrong game, and the class is disjoint from the 9,160 drifted markets owned by [[user:task/league/adjudicate-drifted-prop-market-index-stamps.md]].
  - Both causes are closed in code: the OPEN row is write-once and was never repaired until `343c0020b` added `propagate-prop-market-identity.mjs` (2026-09-02), which reaches only markets still being observed; and the stamp merge let a null re-observation erase a known esbid until the coalesce guard in `ec941cf3e`.
  - Apply `db/adhoc/2026-09-08-backfill-half-linked-prop-market-esbid.sql`, then re-run the precompute over the affected seasons — repairing the esbid makes a row reachable without making it correct.
  - Applied on the host: `UPDATE 21545`, matching the dry run exactly. Frozen hit rates went 5,745 → 0 and reachable rates rose by exactly 5,745.
  - Verified: `prop-market-half-linked-esbid` reads **0** after the backfill and still 0 after the recompute. All four seasons recomputed on the host, exit 0 each, and the post-recompute dry run reports **0 class changes on 2023, 2024, 2025 and 2026** — stored equals computed under the fixed code. Logs in `scratch/league/hit-rate-recompute/post-backfill-*`.
  - 2026 needed adjudication before it read 0: the first dry run showed 224 changes, all originating at a stored null. The count held at exactly 148 across a dry run repeated with no write between, so it was a stable population rather than noise; a further recompute then cleared it to 0. The script materializes its selection list once at start, so the live in-season ingest inserts rows mid-run that the run cannot write but the next read sees. Ordinary in-season movement, not a defect.
  - Run the recompute on the HOST, never through the workstation tunnel — a 23.74ms round trip turns 2.2s per batch into 23.3s, an 80-minute run into 15 hours. Written up in `docs/guides/scripts.md`.

## Testing and Validation

- [x] **Population check for the same defect class across every market type** — done 2026-09-04
  - Ran as a corpus-wide sweep joining every stored `market_type` against its handler in `market_type_mappings` and whether `selection-result.mjs` cases it, rather than against the four known names. That is what found the 47-type bucket and the second writer.
  - Two findings the name-scoped version could not have reached: `calculate-team-historical-hit-rates.mjs` is a SECOND writer owning 14 `GAME_TEAM_*` types with its own grader, and a maximum of exactly zero is not the only symptom — `GAME_FIRST_TOUCHDOWN_SCORER` carries 7,453 stale rows with plausible anytime-touchdown-shaped rates near 0.41 that no current code path can produce.
