---
title: Close the prop-market type mapping gaps and fix the ladder set
type: task
status: completed
description: >-
  market_type is null on 63.1 percent of market rows because the per-book mappers drop an
  unrecognised id to null and log it through debug, and ladder_market_types misses four player types
  that post ladders. Three no-DDL code fixes; the reference table and foreign key are withdrawn.
aliases:
  - ██████████████████████████-██████████-████-██████-█████-███-█████-████████
base_uri: user:task/league/normalise-prop-market-type-taxonomy.md
created_at: '2026-09-03T22:46:20.511Z'
entity_id: 05b7f448-9018-4df1-b48d-228d23aa1c5a
finished_at: '2026-09-04T07:54:50.228Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[█████████████████████████-███████-████-████████-█████████████]]
  - precedes [[██████████████████████-█████████-██████-████-█████████]]
  - precedes [[user:task/league/raise-pinnacle-and-betmgm-market-type-coverage.md]]
  - precedes [[user:task/league/model-unmodelled-prop-market-shapes.md]]
started_at: '2026-09-03T23:17:37.363Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-16T04:26:28.996Z'
visibility_analyzed_at: '2026-11-05T23:49:46.000Z'
---

# Implementation Plan: Close the prop-market type mapping gaps and fix the ladder set

## Overview

`market_type` resolves on 36.9 percent of market rows and is null on the other 1,944,097. The per-book mappers return null on an id they do not recognise and log it through `debug`, which nothing reads in production, so a gap is invisible from the moment it opens. Separately, `ladder_market_types` names 45 of the market types that post ladders and misses several the line row axis needs.

This task closes the mapping gaps reachable today, makes a new gap visible when it opens, and corrects the ladder set from measurement. **It adds no table, no column and no DDL.**

**Expected outcome.** DraftKings' period moneyline markets carry the types that already exist for them, `ladder_market_types` reflects what books actually post rather than a naming convention, and a subcategory appearing under an offer category we model raises a signal instead of writing a silent null.

**Impact.**

- **A NEW mapping gap becomes visible, and the standing one becomes declared.** Today the only category with a gap collector is DraftKings offer 1759, added after `SEASON_RECEPTIONS` sat unclassified across 43 published markets. Every other modelled category fails the same way with no collector. Generalising it naively would report the 249 subcategories that are standing-unmapped under modelled categories on every run, so the signal is gated on a checked-in set of ids already ruled out of scope; what reaches a human is the roughly four genuinely new ids a month, plus any wholly new offer category.
- **The line axis stops mis-deduplicating four player market types.** `GAME_FIRST_QUARTER_RECEPTIONS`, `GAME_PASSING_RUSHING_YARDS`, `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS` and `GAME_FIRST_QUARTER_PASSING_ATTEMPTS` post ladders, carry a player pid on essentially every row, and are absent from `ladder_market_types`, so `include_line_in_key` is false for them under a line row axis. Rows that render merged today will split — within a book for three of the four, and across books for `GAME_PASSING_RUSHING_YARDS` on 1,282 of 1,689 player-game keys.

**What this deliberately does not claim.** Earlier drafts of this plan proposed a `prop_market_types` reference table holding definitional primitives, with `prop_markets_index.market_type` as a foreign key to it. That is withdrawn in full — see Background. This plan completes the period axis the taxonomy already has, but does not extend it to market SHAPES it models nowhere — team period totals, odd/even, quarter-plus-full-time doubles and three-way stay out and are a follow-on.

**Ordering.** Follows `████████████████████████████████████████████████████████`, completed 2026-08-30. Precedes the follow-on tasks named in the Plan. Independent of [[█████████████████████████-███████-████-████████-█████████████]], which this task's measurement work discovered.

## Background

Measured against production on 2026-09-03 and 2026-09-04 via `mcp__db__query_league`, and against the modules by loading and executing them rather than by reading them.

### Why the reference table was withdrawn

Three findings, each of which is on its own sufficient.

- **Its stated purpose is already served.** The Impact claim was that storing settlement attributes in SQL "lets a data check ask whether a market type is settleable". A data check is not SQL: `db/checks/registry.mjs` defines each check's `rows` as an async JavaScript function and imports about twenty `#libs-server` modules. `libs-server/settlement-output-checks.mjs:36-45` is a shipped check that filters market types on `mapping.handler === 'NFL_PLAYS' && mapping.player_column && !mapping.team_aggregate`, then interpolates the resulting list and `player_column` into SQL text at lines 192-211. It needs `player_column` and `team_aggregate`, neither of which the proposed table carried, so the table would not have enabled it.
- **The `like`-pattern it promised to replace does not exist.** The only occurrence of `market_type like` in the repository is the comment at `libs-shared/bookmaker-constants.mjs:345` recording a decision not to use one. The single string-surgery site is `team_grain_market_types` at line 170, four lines under a comment explaining why token-splitting is correct there.
- **Two of its five columns restated the primary key and a third was empty.** `metric` and `period` were deterministic parses of `market_type`. `aggregation` was to be taken from `market-type-mappings.mjs`, which carries `aggregation_type` on 6 of 275 declared types, all reading `MAX` — so the plan's own non-degeneracy verify, that `aggregation` carry more than one value, would have failed on a faithful seed. The module holds 338 field-instances across ten fields; the table captured 229 of them and left `player_column` (37), `quarter_filter` (33), `team_aggregate` (14), `half_filter` (8), `special_logic` (7), `calculation_type` (5) and `net_of_sack_yards` (5) behind, so the settlement module could not have become a reader.

The foreign key is withdrawn with it. `market_type` is null on 63.1 percent of `prop_markets_index`; a foreign key permits null, so it would constrain the 1,135,441 rows that are already fully conformant and stay silent on the 1,944,097 that are not. The mappers emit `null` rather than an unrecognised string, so the violation class has no members and no path to acquiring one.

### The taxonomy is not lossy — refuted on 2026-09-04

An earlier reading of this data held that the taxonomy collapsed distinct book products: 34 of 101 DraftKings market types spanned 99 distinct `subcategoryId`s. That reading keyed on `subcategoryId` alone, and the mapper does not. `draftkings-market-types.mjs:632-635` consults `ALTERNATE_MARKET_TYPE_IDS[marketTypeId]` first and it **overrides** subcategory classification; `draftkings-constants.mjs:101-109` documents the reason, that DraftKings uses `marketTypeId` to separate a primary single-line market from an alternate multi-line market inside one subcategory.

Re-measured at the mapper's own key — `(categoryId, subcategoryId, marketTypeId)` — over DraftKings markets observed since 2025-08-01: 283 distinct keys, of which **2** resolve to more than one `market_type`, covering 399 markets of 132,119, or 0.3 percent. The taxonomy is not the problem; keying the measurement below the mapper's grain was.

### The mapping gap, by shape

DraftKings holds 818,506 unmapped markets, 77.1 percent of its total. Bucketed by market name over the whole corpus:

| bucket | markets | distinct subcategories | status |
|---|---|---|---|
| exotic long tail | 628,372 | 1,032 | out of scope, unmapped on purpose |
| `- undefined` in the market name | 92,136 | 10 | **closed**: 90,446 in 2023, 1,624 in 2024, zero from 2025 on |
| period-scoped game lines | 89,950 | 50 | **live**: 11,145 / 24,470 / 27,660 across 2023 / 2024 / 2025 |
| three-way markets | 8,048 | 10 | no type exists |

Only the period-scoped bucket is live and growing, and its cause is sharper than a missing case: **DraftKings offer categories 526 (Halves) and 527 (Quarters) have no handler at all.**

It is the largest gap among shapes the taxonomy already models, which is a narrower claim than "the largest live gap" and is the one the evidence supports. Unmapped volume alone is larger elsewhere: since 2025-08-01, category 1003 carries 54,175 unmapped markets of 56,047, category 1342 carries 10,879 and category 1001 carries 9,694, all of them under handlers that already exist. Sampling those names shows the bulk is Either Player, Combined Totals and Race to X — shapes with no analogue at any period, so they are out of scope by the same ruling that keeps three-way markets out, not gaps this plan leaves unclosed. The exception worth naming is subcategory 15006, Receiving Yards 1Q O/U, 1,764 markets under modelled category 1342: a period-scoped player prop that is modelable today. The sweep step below is what catches it. Neither appears in the dispatcher's switch, which lists 492, 528, 529, 530, 634, 787, 820, 1000, 1001, 1002, 1003, 1076, 1163, 1286, 1342, 1595 and 1759. Every market under Halves or Quarters therefore reaches the dispatcher's `default:` arm, logs `unknown offerCategoryId` through `debug`, and returns null. That is an entire product family unmodelled, not a handful of unmapped ids.

Within the bucket, existing types cover three subcategories — 4631, 4632 and 4641, the 1st Half, 1st Quarter and 2nd Half moneylines, 5,762 markets since 2025-08-01. Most of the remainder is not a new kind of market but a hole in an axis the taxonomy already has:

| period | game lines declared | player prop types |
|---|---|---|
| full game | spread, total, moneyline | — |
| 1st quarter | spread, total, moneyline | 20 |
| 2nd quarter | none | 10 |
| 3rd quarter | none | 10 |
| 4th quarter | none | 10 |
| 1st half | spread, total, moneyline | 10 |
| 2nd half | spread, total, moneyline | 0 |

Player props already run all four quarters; game lines stop at the 1st. And while `GAME_ALT_SPREAD` and `GAME_ALT_TOTAL` exist at full game, no period alternate game line exists at any period. Both gaps are arbitrary — a cross of two axes already present — so filling them coins 14 values without inventing a naming scheme or a concept, and recovers 15,588 markets since 2025-08-01: 11,798 moneylines and 3,786 period alternates.

Note that the live set is moneylines and alternates only. DraftKings' non-alternate 2nd, 3rd and 4th quarter spreads and totals appear in the corpus but not since 2025-08-01, so they are historical and no type is added for them.

**Sixteen subcategories sit under 526 and 527, not eighteen.** Read back from `source_market_name`, which embeds the vendor's own `categoryId` beside the `subcategoryId`, every id and its market count since 2025-08-01:

| category | subcategory | product | markets | disposition |
|---|---|---|---|---|
| 526 | 4631 | Moneyline 1st Half | 1,984 | existing type |
| 526 | 4641 | Moneyline 2nd Half | 1,942 | existing type |
| 527 | 4632 | Moneyline 1st Quarter | 1,836 | existing type |
| 527 | 4642 | Moneyline 2nd Quarter | 1,890 | coined here |
| 527 | 4643 | Moneyline 3rd Quarter | 2,046 | coined here |
| 527 | 4644 | Moneyline 4th Quarter | 2,100 | coined here |
| 526 | 13582 | Alternate Spread 1st Half | 608 | coined here |
| 526 | 13583 | Alternate Spread 2nd Half | 644 | coined here |
| 526 | 13584 | Alternate Total 1st Half | 598 | coined here |
| 526 | 13585 | Alternate Total 2nd Half | 644 | coined here |
| 527 | 13525 | Alternate Spread 1st Quarter | 548 | coined here |
| 527 | 13526 | Alternate Total 1st Quarter | 548 | coined here |
| 527 | 16078 | Alternate Spread 2nd Quarter | 24 | coined here |
| 527 | 16079 | Alternate Spread 3rd Quarter | 74 | coined here |
| 527 | 15707 | Alternate Total 2nd Quarter | 24 | coined here |
| 527 | 15708 | Alternate Total 3rd Quarter | 74 | coined here |

The two remaining subcategories are **not under 526 or 527** and an earlier draft of this plan wrongly routed them there. Both arrive under offer categories that already have handlers:

| category | subcategory | product | markets | disposition |
|---|---|---|---|---|
| 1000 (Passing Props) | 19111 | Total 1Q Pass Completions | 2 | coined here, in the existing offer-1000 handler |
| 1001 (Rushing Props) | 19117 | Total 1Q Rush Attempts | 2 | **already has a type** — `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS` |

So 19117 needs a mapping line, not a coined value, and it maps to one of the four types the ladder group above is adding to `ladder_market_types`. Only 19111 needs a new player type. Wiring either into a 526 or 527 handler would be unreachable code, and a verify that walks "all 18 under 526 and 527" would either fail or pass vacuously against an implementation written to match it.

The 16 rows above sum to 15,588 minus the 4 straggler markets, and 1,984 + 1,836 + 1,942 = 5,762 already carry a type. Both totals reproduce.

What genuinely has no analogue at any period, and stays out: team period totals, odd/even, quarter-plus-full-time doubles, and three-way markets. Those are market *shapes* the taxonomy does not model anywhere, which is a different question from which period a modelled shape is scoped to.

The unmapped rate across books is not uniform and not all of it is a defect: BetRivers is 100 percent unmapped across 104,004 markets and has no mapper at all, Pinnacle 94.2 percent, BetMGM 87.1 percent, Caesars 23.2 percent, PrizePicks 10.7 percent.

### The ladder set

`ladder_market_types` is the union of three player-only constant groups, 45 members, every one of which carries an `ALT` token. Its header at `libs-shared/bookmaker-constants.mjs:345-348` records that it is derived from groups rather than from a `market_type like 'GAME_ALT_%'` match specifically so it does "not silently miss a future ladder that is not named ALT". Measurement says that concern was correct.

Ladder depth measured per book, joining on `(source_id, source_market_id, time_type)` and counting distinct non-null `selection_metric_line` per market, minimum 25 markets per cell:

| type | book | markets | median lines | pct 3+ | pid % |
|---|---|---|---|---|---|
| `GAME_FIRST_QUARTER_RECEPTIONS` | DraftKings | 4,436 | 3 | 55.2 | 100 |
| `GAME_PASSING_RUSHING_YARDS` | DraftKings | 3,144 | 4 | 58.0 | 98.3 |
| `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS` | DraftKings | 1,348 | 3 | 61.2 | 100 |
| `GAME_FIRST_QUARTER_PASSING_ATTEMPTS` | DraftKings | 998 | 3 | 81.2 | 100 |

`GAME_PASSING_RUSHING_YARDS` is a ladder **at DraftKings only** — Caesars (1,816 markets), FanDuel (1,115) and PrizePicks (2,076) all read median 1 line and 0.0 percent at three or more. Adding it is correct, because `ladder_market_types` answers "can this type post a ladder" and this one demonstrably does. It is **not** a no-op for the other books, and an earlier draft of this plan claimed it was.

That claim measured lines per *market*. The dedup key operates at the (player, game) grain, and there the books disagree with each other. Measured 2026-09-04 over the four types, joining on `(source_id, source_market_id, time_type)` and excluding null pid, esbid and line:

| type | player-game keys | multi-line, all books | keys quoted by a non-DraftKings book | of those, multi-line |
|---|---|---|---|---|
| `GAME_PASSING_RUSHING_YARDS` | 1,744 | 84.7% | 1,689 | **1,282 (75.9%)** |
| `GAME_FIRST_QUARTER_RECEPTIONS` | 2,218 | 97.9% | 0 | — |
| `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS` | 674 | 88.6% | 0 | — |
| `GAME_FIRST_QUARTER_PASSING_ATTEMPTS` | 499 | 100.0% | 0 | — |

So widening splits 1,282 player-game keys that render as one row today into several. That is a real behaviour change and the plan claims it as one. It is also the *right* change: when FanDuel quotes 245.5 and Caesars quotes 250.5 for the same player in the same game, those are different lines and merging them is the defect. The other three types have **zero** non-DraftKings player-game keys, so no other book contradicts them and their split is purely the intended within-book rung separation.

All four are `player_prop_types` members, carry a pid on essentially every row, carry no `ALT` token, and are absent from the set. They are the ladders the line row axis can actually reach, because `validate-line-axis-columns.mjs:70` gates on `is_player_game_prop` and the betting join keys on `pms.selection_pid`.

The 20 declared `ALT`-token types absent from the set are game, team and season markets. `GAME_ALT_TOTAL` and both `GAME_LEADER_*` types read 0 percent pid, so the player-scoped axis returns no row for them regardless of membership. One `ALT`-token type is not a ladder at all: `GAME_ALT_TEAM_TOTAL`, 240 DraftKings markets, median 1 line, 14.2 percent at three or more.

`GAME_ALT_SPREAD` and `GAME_ALT_TOTAL` are genuinely the deepest ladders in the corpus at the book level — DraftKings and FanDuel run median 38 to 70 lines. Corpus-wide they read median 2 and 1 because Pinnacle posts one rung per `source_market_id` across 26,700 markets and dominates the count. Depth is not the criterion for set membership; axis reachability is.

### Assumptions

- **A data check may import application modules.** Checked: `db/checks/registry.mjs` declares `rows` as an async function and carries about twenty `#libs-server` imports; `libs-server/settlement-output-checks.mjs` builds its market-type scope from `market_type_mappings` and inlines it into SQL.
- **`market_type` is last-write-wins on re-observation.** Checked: it is listed in `MARKET_INDEX_MERGE_COLUMNS` at `libs-server/insert-prop-markets.mjs:71-78`, under the comment "Columns a re-observation may freely rewrite." A market that stops being observed keeps the mapper answer current at its last observation, which is why historical rows may carry a superseded classification.
- **Every `market_type` in production is declared in `bookmaker-constants`.** Checked: 275 declared, 214 distinct in production, zero production values undeclared.
- **The four measured non-ALT ladders are player prop types.** Checked by loading `player_prop_types` and testing membership; all four are members and none carries an `ALT` token.
- **Widening `ladder_market_types` cannot drop a member.** Checked by loading the module: all 45 current members carry an `ALT` token and all are player game types, so any superset construction retains them. The verify asserts the superset property rather than a size.
- **Widening `ladder_market_types` breaks an existing test ON PURPOSE, and that test is a tripwire rather than an obstacle.** Checked: `test/data-views.line-row-axis.spec.mjs:251-261` asserts that filtering `player_game_prop_types` by `is_ladder_market_type` equals filtering it by an `ALT` substring match. Loading the module gives 102 members of `player_game_prop_types`, exactly 45 containing `ALT`, and `ladder_market_types.size === 45` — the two filters coincide today. All four added types are members and carry no `ALT` token, so the predicate side grows to 49 and the substring side stays 45.

  The comment above the assertion says why it exists: the agreement is "pinned so a future ladder that is not named ALT fails here rather than silently collapsing". This plan IS that future ladder. The failure is the test doing its job and demanding the change be deliberate, so the step below rewrites the assertion and records the ruling; it does not relax it.
- **The 14 new market types cannot leak into the ladder set.** Checked by loading the module: `GAME_ALT_SPREAD`, `GAME_ALT_TOTAL`, `GAME_ALT_TEAM_TOTAL`, `GAME_MONEYLINE`, `GAME_FIRST_QUARTER_MONEYLINE` and `GAME_FIRST_HALF_MONEYLINE` all live in `team_game_market_types`, which is not one of the three groups `ladder_market_types` unions. Placing each new value beside its siblings therefore keeps all 14 out of the ladder set without a further guard.
- **`include_line_in_key` has ONE call site, not two.** Checked by grep: the only occurrence is `libs-server/data-views-column-definitions/player-betting-market-column-definitions.mjs:494`. An earlier draft of this plan asserted a second at line 599 and a matching client gate at `validate-line-axis-columns.mjs:79`; line 599 is a separate ON-clause predicate and line 79 is a ladder check, not the `is_player_game_prop` gate, which is at line 70.
- **A global unmapped-subcategory collector would be noise, and so would one scoped to modelled categories.** Checked: the comment at `draftkings-market-types.mjs:309-317` records the reasoning, ending "Deliberately scoped to this one category: most other categories carry exotic markets that are unmapped on purpose, so a global collector would be pure noise." Measured 2026-09-04, that reasoning holds at the modelled-category scope too: since 2025-08-01 DraftKings produced unmapped markets under **249 distinct subcategories** spread across the 17 modelled offer categories, against **6** under category 1759 which has the collector today. Categories 1000 (44), 1001 (31), 528 (29), 1342 (28), 1003 (26) and 530 (20) each dwarf it. Sampling the names confirms the bulk is genuinely exotic — Either Player, Combined Totals, Race to X, Win with Shutout. Scoping to modelled categories does NOT keep the long tail out; the tail lives inside them.
- **Novelty, not volume, separates a real gap from the tail.** Checked: volume does not discriminate — subcategory 18514 (Either Player 1st Touchdown) is 15,266 markets and permanently out of scope, while 15006 (Receiving Yards 1Q O/U) is 1,764 markets and genuinely modelable. First-appearance date does discriminate. Counting subcategories by the month they were first seen unmapped under a modelled category gives a median of about 4 per month since 2025-01, typical months between 1 and 8, with season-boundary spikes of 54 (2026-01), 40 (2025-01) and 33 (2025-09). That is the alert volume a novelty-scoped collector produces, against 249 standing every run for a set-scoped one.
- **The category-level arm is already bounded and needs no allowlist.** Checked: DraftKings has published 12, 38, 14 and 3 previously-unseen unmodelled offer categories in 2023, 2024, 2025 and 2026 to date. A handful a year, each of them a whole product family — which is exactly what 526 and 527 were.
- **DraftKings offer categories 526 and 527 have no handler.** Checked: neither appears in the dispatcher's switch, no `get_market_type_offer_526` or `_527` is exported, and `grep` for subcategories 4631, 4632, 4641, 4642, 4643 and 4644 across the mapper returns nothing. An earlier draft of this plan said to "add three cases to the offer 526 and 527 switches"; those switches do not exist.
- **UNCHECKED — the user-visible effect of the ladder widening.** Whether any live data view changes its rendered rows is asserted nowhere and is measured by a plan step below rather than assumed.

## Design

### Shape

Three independent groups, no shared migration, no DDL, no ordering constraint between them.

- **The ladder set** is corrected in `libs-shared/bookmaker-constants.mjs` by widening the existing union, keeping the group-derived construction its header defends. Membership becomes a recorded ruling backed by the measurement above, not a token match. A token match is rejected on evidence: it would add 20 types the axis cannot reach, add one type that is not a ladder, and miss all four that matter.
- **The reachable mapping gap** is closed in two files. `libs-shared/bookmaker-constants.mjs` gains 14 values completing the period axis it already has, and `libs-server/draftkings/draftkings-market-types.mjs` gains the handlers offer categories 526 and 527 never had, mapping the 16 subcategories that arrive within them, plus two cases in the existing offer-1000 and offer-1001 handlers for the two subcategories that arrive elsewhere. The two new handlers matter beyond their mappings: they convert an unknown *category* into an unknown *subcategory*, which is the granularity the collector can report and act on.
- **The gap collector** is generalised from the one offer category that has it to every offer category the mapper models, and its output is raised as a signal by the importer rather than logged through `debug`. It gains a second, category-level arm, because an entire unmodelled category never reaches a switch at all.

### The collector needs a novelty filter, not a scope

Generalising the collector as-is reintroduces exactly the noise its comment was written to avoid. Measured above: 249 standing unmapped subcategories under modelled categories, against 6 under the one category that has the collector today. A signal carrying 249 ids on every import run is a signal nobody reads, and the plan's own Design principle — that the collector's value is naming "the exact ids that fell through on this run" — dies the moment the run's output is the same 249 ids it was yesterday.

The discriminator is novelty. A subcategory the mapper has never seen is worth a human's attention; one it has declined to map for a year is not, and no volume threshold separates them (the largest exotic subcategory is 15,266 markets, a genuinely modelable one is 1,764).

So the collector keeps its two arms and the signal gains a gate:

- **Subcategory arm.** Populated from every modelled category's `default:` branch, as the plan already says. The signal fires only for ids absent from a declared `known_unmapped_subcategory_ids` set, checked into `draftkings-constants.mjs` and seeded from the 2026-09-04 measurement. Adding an id to that set is the explicit act of ruling a product out of scope, which today is folklore in a comment.
- **Category arm.** Populated from the dispatcher's unknown-`offerCategoryId` branch. No allowlist — the historical rate is 12, 38, 14 and 3 new unmodelled categories per year, and each one is a whole product family. Categories 526 and 527 are what this arm exists to have caught.

The two arms are recorded and emitted distinctly, because an unknown category and an unknown subcategory inside a known one are different actions.

The seeded set is transitional-looking but is not scaffolding: it is the durable, reviewable statement of which vendor products this taxonomy declines to model, and the clean end state has it in code rather than in a comment that says "most other categories carry exotic markets".

### Why a collector rather than a data check

A `db/checks` entry grades stored rows against an invariant. The gap here is that nothing was stored: an unrecognised subcategory writes a null `market_type` and the selections land underneath it. A check over the null rate would be graded against a population that is 63 percent null by legitimate design across eight books with different coverage, and would need a per-book, per-category threshold that has to be retuned whenever a book publishes a new category. The importer-side collector reports the exact ids that fell through on this run, which is both the actionable unit and the one that needs no threshold. This mirrors the mechanism already proven for offer 1759.

### Naming

No schema identifier is coined, so the naming rules apply to the two exported constants and the 14 market-type values.

`unmapped_subcategories_by_offer_category` replaces `unmapped_season_player_prop_subcategories`: it names the key the collector is now grouped by, keeps the full-word rule, and drops a scope qualifier that stops being true when the collector generalises.

`known_unmapped_subcategory_ids` names the distinguishing property — these are *known* to be unmapped, as against merely observed to be — and carries the `_ids` suffix the surrounding DraftKings constants already use. It is deliberately not `ignored_` or `excluded_`, which would describe the mechanism rather than the ruling, and not `exotic_`, which is a judgement the set does not always make: subcategory 15006 is modelable and simply not modelled yet.

The 14 market-type values introduce no new vocabulary token. Every segment — `GAME`, the period words, `ALT`, `SPREAD`, `TOTAL`, `MONEYLINE`, `PASSING`, `COMPLETIONS` — already appears in declared values, so the names follow the existing convention rather than coining a scheme.

Two misnamings are recorded and left alone, both out of scope: `selection_type` holds OVER/UNDER/YES/NO, which are sides rather than types of selection; and `selection_result` is typed `wager_status`, an enum whose `OPEN` and `CASHED_OUT` values are unreachable for a market selection.

### Knowledge Base Impact

- `user:text/league/database-architecture.md` needs no change: no table, column or constraint is added.
- Observation candidates already recorded on this task: the reference-table withdrawal, the taxonomy-lossiness refutation, the measured ladder set, and the mapping-gap bucketing.
- No new tag. No ABOUT.md change. No orchestrator — this is one bounded task. No scratch slug; the measurement scripts are throwaways under `tmp/`.

## Notes

- The single `include_line_in_key` call site is `libs-server/data-views-column-definitions/player-betting-market-column-definitions.mjs:494`, reading `include_line_in_key: line_split && is_ladder_market_type(market_type)`. The flag is consumed in `libs-server/data-views/market-row-dedup.mjs`, where it selects the key columns at line 118 and drops the line from the non-key set at line 130 — that is where the row split actually happens and is the file to read when reasoning about the rendered change. The related predicate at line 599 and the ladder check at `libs-server/data-views/validate-line-axis-columns.mjs:79` call the same `is_ladder_market_type`, so all three move together when the set widens and there is no agreement hazard between them. The file header at `bookmaker-constants.mjs:337-343` describes a real failure mode — a disagreement renders as one generic banner with the message dropped — but it does not apply to a change that edits the shared set rather than a call site.
- Two fixtures fail at league HEAD for unrelated reasons and are owned elsewhere: `filter-by-active-rosters` and `player-pass-touchdown-percentage-zero-numerator-result-equivalence`. A full suite run looks red because of them.
- Do NOT rebuild a rung-duplication check keyed on `count(distinct source_market_id)`. That shape measures postings rather than defects, fires on DraftKings permanently, and was shipped and reverted once already in league `2bc76a800`.
- **Every count in this plan was re-derived on the OVH host after the 2026-09-04T03:35Z migration and reproduces exactly.** 3,079,538 markets, 1,944,097 null `market_type` at 63.1 percent, 214 distinct types, 818,506 DraftKings unmapped, 5,762 under the three mappable subcategories, the four ladder rows unchanged, and 2 of 283 ambiguous mapper keys over 399 of 132,119 markets. Timing numbers are the exception and are NOT reproducible: `pg_stat_statements` was reset at promotion and the tuning changed, so re-measure any latency claim rather than carrying one across the cutover.
- Do NOT reintroduce a lexical `ALT` rule for the ladder set. It was proposed, measured and refuted; the reason is in Background and the file header already warned against it.

## Plan

### Fix the ladder set

This group is independent of the other two and ships on its own.

- [ ] **Widen `ladder_market_types` to the measured set**
  - File: `libs-shared/bookmaker-constants.mjs`
  - Purpose: four player market types post ladders, carry a pid on essentially every row, and are absent from the set, so `include_line_in_key` is false for them under a line row axis.
  - Changes: extend the existing union with the four measured types — `GAME_FIRST_QUARTER_RECEPTIONS`, `GAME_PASSING_RUSHING_YARDS`, `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS`, `GAME_FIRST_QUARTER_PASSING_ATTEMPTS` — keeping the group-union construction. Replace the header's derivation note with one recording that membership is measured from posted line counts, and cite the per-book figures.
  - Verify: the rebuilt set is a strict superset of the 45 current members and has size 49; `is_ladder_market_type('GAME_FIRST_QUARTER_RECEPTIONS')` returns true where it returns false today. Assert the superset property explicitly — a size check alone passes on a set that swapped members.

- [ ] **Rewrite the offered-versus-predicate assertion, which this change necessarily breaks**
  - File: `test/data-views.line-row-axis.spec.mjs`
  - Purpose: lines 251-261 assert that filtering `player_game_prop_types` by `is_ladder_market_type` equals filtering it by an `ALT` substring match, and the comment above says it is "pinned so a future ladder that is not named ALT fails here rather than silently collapsing". This plan is that future ladder. Leaving the assertion turns master red; "confirming it still passes" is not available.
  - Changes: replace the substring comparison with an explicit expected membership — the 45 `ALT`-token members plus the four measured types — and rewrite the comment so it records that the set is measured from posted line counts rather than lexical, keeping the tripwire's intent. Do not weaken it to a size check, and do not delete the test: its purpose is to force exactly this conversation, and the next non-ALT ladder deserves the same stop.
  - Verify: the rewritten assertion fails when any one of the four is removed from `ladder_market_types` and fails when `GAME_ALT_TEAM_TOTAL` is added, so it discriminates in both directions rather than restating the implementation.

- [ ] **Establish whether the widening changes a rendered view**
  - Purpose: the user-visible consequence is asserted nowhere, and the plan must not claim a repair it has not observed.
  - Changes: run the comparison against `GAME_PASSING_RUSHING_YARDS`, not `GAME_FIRST_QUARTER_RECEPTIONS`. The three first-quarter types have zero non-DraftKings player-game keys, so they exercise only the within-book rung split; `GAME_PASSING_RUSHING_YARDS` is the one type where books disagree on the line, on 1,282 of 1,689 player-game keys, and is therefore the case most likely to surprise a user.
  - Verify: build a line-row-axis data view over `GAME_PASSING_RUSHING_YARDS` against production data and compare rendered rows before and after. Expect MORE rows after, and report the count both ways; a reading of "no change" on this type means the change did not reach the render path and is a failure, not a latent gap.

- [ ] **Confirm the remaining line-axis assertions still pass**
  - Verify: `test/data-views.line-row-axis.spec.mjs:238-244` passes unchanged — those five assertions name `GAME_ALT_PASSING_YARDS`, `GAME_FIRST_QUARTER_ALT_RUSHING_YARDS`, `GAME_PASSING_YARDS`, `GAME_RECEIVING_YARDS` and `undefined`, none of which is among the four added, so the widening does not touch them.

### Close the reachable mapping gap

- [ ] **Complete the period axis in `bookmaker-constants`**
  - File: `libs-shared/bookmaker-constants.mjs`
  - Purpose: the taxonomy already commits to period-scoped game lines — it carries the spread, total and moneyline triple at full game, 1st quarter, 1st half and 2nd half, and carries player props at all four quarters. It stops at 1st quarter for game-line moneylines and has no period ALT game line at all. That is an arbitrary gap in an existing pattern, not a boundary.
  - Changes: add 14 values, each a mechanical cross of two axes already present. Three quarter moneylines — `GAME_SECOND_QUARTER_MONEYLINE`, `GAME_THIRD_QUARTER_MONEYLINE`, `GAME_FOURTH_QUARTER_MONEYLINE` — crossing the existing `GAME_MONEYLINE` with the existing quarter periods. Ten period alternate game lines — `GAME_{FIRST,SECOND,THIRD}_QUARTER_ALT_{SPREAD,TOTAL}` and `GAME_{FIRST,SECOND}_HALF_ALT_{SPREAD,TOTAL}` — crossing the existing `GAME_ALT_SPREAD` and `GAME_ALT_TOTAL` with those periods. One player prop, `GAME_FIRST_QUARTER_PASSING_COMPLETIONS`, completing the 1st-quarter player set. Place each in the constant group its siblings already occupy.
  - Naming: no new vocabulary token is introduced — every segment (`GAME`, the period words, `ALT`, `SPREAD`, `TOTAL`, `MONEYLINE`, `PASSING`, `COMPLETIONS`) already appears in declared values, so the names follow the existing convention exactly rather than coining a scheme.
  - Verify: the declared count rises from 275 to 289; the spread/total/moneyline triple is present at every one of full game, 1st through 4th quarter, 1st half and 2nd half; and `GAME_<period>_ALT_<SPREAD|TOTAL>` resolves for all five periods. Assert against the loaded module, not the file text.

- [ ] **Add offer-category handlers for Halves and Quarters**
  - File: `libs-server/draftkings/draftkings-market-types.mjs`
  - Purpose: offer categories 526 (Halves) and 527 (Quarters) have NO handler — they are absent from the dispatcher's case list, so every market under them falls to its `default:` arm, logs through `debug` and returns null. This is the single largest live gap, and it is a missing category rather than missing cases.
  - Changes: add `get_market_type_offer_526` and `get_market_type_offer_527` in the shape of the existing per-category functions, each a switch on `subcategoryId` with a `default:` that logs and records into the collector, and wire both into the dispatcher's switch. Map the **16** subcategories that actually arrive under those two categories, per the Background table: moneylines 4631, 4632 and 4641 to the three types that already exist, and 4642, 4643, 4644 to the three coined above; alternate spreads and totals 13582, 13583, 13584, 13585, 13525, 13526, 16078, 16079, 15707 and 15708 to the ten coined above. Coin nothing further — any other subcategory falls to `default:` and is reported by the collector.
  - Verify: call the dispatcher with offer category 526 and 527 for each of the 16 ids and assert the expected type; assert an unmapped subcategory under those categories now returns null from the new switch and lands under the subcategory arm of the collector rather than the category arm; and confirm the dispatcher's case list gained exactly two entries.

- [ ] **Map the two player-prop subcategories in the handlers they actually reach**
  - File: `libs-server/draftkings/draftkings-market-types.mjs`
  - Purpose: subcategory 19111 arrives under offer category 1000 and 19117 under 1001, both of which already have handlers. An earlier draft of this plan routed both into 526 and 527, where no market ever reaches them.
  - Changes: add a case for 19111 in the existing offer-1000 switch, mapping to the coined `GAME_FIRST_QUARTER_PASSING_COMPLETIONS`; add a case for 19117 in the existing offer-1001 switch, mapping to the **already declared** `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS`. Coin no value for 19117.
  - Verify: call the dispatcher with `(1000, 19111)` and `(1001, 19117)` and assert both resolve; assert `GAME_FIRST_QUARTER_RUSHING_ATTEMPTS` was already a declared member before this change, so the second case adds a mapping and not a value; and assert neither id resolves under category 526 or 527, which is the routing this step corrects.

- [ ] **Confirm no other unmapped subcategory has an existing type**
  - Purpose: the three above were found by inspection, and a fourth would otherwise be missed.
  - Verify: for every subcategory id unmapped since 2025-08-01, print the market name beside the declared type list and confirm by inspection that the remainder need types that do not exist. Record the count reviewed, so a later reader can tell this was a sweep rather than a sample.

### Make a new gap visible

- [ ] **Generalise the unmapped-subcategory collector**
  - File: `libs-server/draftkings/draftkings-market-types.mjs`
  - Purpose: only offer 1759 collects what falls through, added after `SEASON_RECEPTIONS` sat unclassified across 43 published markets; every other modelled category fails silently through `debug`.
  - Changes: replace `unmapped_season_player_prop_subcategories` with `unmapped_subcategories_by_offer_category`, a map from offer category id to a set of subcategory ids, populated from each switch's `default` arm AND from the dispatcher's own unknown-`offerCategoryId` arm. Both levels are required: an entire unmodelled category never reaches a switch at all, which is exactly how categories 526 and 527 stayed dark across 89,950 markets — a collector wired only to the switches would have missed them. Record the two levels distinctly, since an unknown category is a different action from an unknown subcategory inside a known one. Keep the `debug` lines.
  - Verify: call the mapper with a known-unmapped subcategory under two different modelled categories and assert both land under their own key; call it with an entirely unknown offer category and assert it lands under the category arm and NOT the subcategory arm; call it with a mapped subcategory and assert the collector stays empty.

- [ ] **Declare the known-unmapped subcategory set and gate the signal on it**
  - Files: `libs-server/draftkings/draftkings-constants.mjs`, `libs-server/draftkings/draftkings-market-types.mjs`
  - Purpose: the collector reports the standing 249 unmapped subcategories under modelled categories, not the handful the earlier draft assumed, and a signal carrying the same 249 ids every run is one nobody reads. Novelty is the discriminator and volume is not — the largest exotic subcategory is 15,266 markets and a genuinely modelable one is 1,764.
  - Changes: add `known_unmapped_subcategory_ids`, seeded from the 2026-09-04 measurement, with a header stating that membership means "ruled out of scope", not "not yet reached". Generate the seed by query rather than by hand. The collector still records every id; only the signal is gated.
  - Verify: the seeded set makes the signal silent against a replay of a recent import — assert zero emissions over a run whose unmapped ids are all seeded, then remove one id from the set and assert exactly one emission naming it. Run the removal case FIRST, so silence is a finding rather than a gate that cannot fire. Record the seed size beside the query that produced it.

- [ ] **Raise the collector as a signal from the importer**
  - File: `scripts/import-draftkings-odds.mjs`
  - Purpose: the run-level report is what makes a gap visible; `debug` output is not read in production.
  - Changes: extend the existing block at lines 359-372 to walk every offer category in the collector rather than the single season-prop set, filtering the subcategory arm through `known_unmapped_subcategory_ids` and passing the category arm through ungated. Name the category ids and subcategory ids in the message, keep the existing `signal_log.error` emitter and its `severity` and `context` shape, and clear the collector after emission as it does today. Follow [[user:guideline/surface-pipeline-failures.md]] for the emitter contract. Emit the two arms as separate signals — an unknown category is a different action from an unknown subcategory.
  - Verify: run the importer against a fixture carrying one unseeded unmapped subcategory and confirm exactly one signal naming that id; against a fixture whose unmapped ids are all seeded, confirm zero; against a fixture under an unknown offer category, confirm the category signal fires and the subcategory signal does not. Run the emitting cases before the silent one, so a zero is a finding rather than an emitter that cannot fire. Confirm the collector is cleared, so a second run over clean data does not re-emit the first run's ids.

### Knowledge Base Updates

- [ ] **File the follow-on tasks and relate them**
  - Purpose: this plan withdraws a table and scopes out three bodies of real work, each of which the relation graph should carry rather than prose.
  - Changes: create tasks for modelling the market shapes the taxonomy has no analogue for at any period (team period totals, odd/even, quarter-plus-full-time doubles, three-way — the ruling on whether these are in scope at all belongs there); writing a BetRivers market-type mapper, which maps zero of 104,004 markets; and raising Pinnacle and BetMGM coverage from 5.8 and 12.9 percent. Add `precedes` relations from this task to each, and a `relates` edge to [[█████████████████████████-███████-████-████████-█████████████]].
  - Verify: `base entity get` on this task lists three `precedes` relations and one `relates`, and each target resolves.

### Testing and Validation

- [ ] **Confirm the ladder set is non-degenerate and correctly bounded**
  - Purpose: a membership change can pass a size check while having swapped members or having pulled in the game and team types this plan explicitly rejects.
  - Verify: the widened set contains all 45 prior members; contains the four measured types; contains no type whose production pid rate is zero; and does not contain `GAME_ALT_SPREAD`, `GAME_ALT_TOTAL` or `GAME_ALT_TEAM_TOTAL`. Report the final size and the four added names rather than asserting a bare count.

- [ ] **Confirm the mapping change moves production rows in the expected direction**
  - Purpose: a mapper edit that compiles and passes a unit test can still fail to reach the importer path.
  - Verify: pre-register the count of null-`market_type` DraftKings markets under subcategories 4631, 4632 and 4641 with the query that produces it, run the importer against live data, and confirm the count falls and that no market outside those three subcategories changes type. The reproducing query is `select substring(source_market_name from 'subcategoryId: ([0-9]+)') as subcat, count(*) filter (where market_type is null) as unmapped, count(*) as total from prop_markets_index where source_id='DRAFTKINGS' and source_market_name ~ 'subcategoryId: (4631|4632|4641)' and observed_at >= '2025-08-01' group by 1 order by 1`.

- [ ] **Run the suite and the gates**
  - Verify: `yarn test:isolated` passes except the two known-failing fixtures named in Notes; `yarn lint`, `yarn check:types` and `node db/gates/check-data-view-sql-validity.mjs` all exit 0.

- [ ] **Run `yarn verify` before committing**
  - Purpose: the dev-fixture scrub gate and the conformance ratchet test the COMMITTED sha in a worktree, so fixing them in the working tree changes nothing until the commit exists.
  - Verify: `yarn verify` exits 0.

- [ ] **Commit the three groups separately**
  - Purpose: the ladder widening is a behaviour change to a shared set, the mapper routing moves production data, and the collector is additive; bisecting a regression across all three at once is avoidable.
  - Verify: `git log --oneline -3` shows one commit per group, each with an explicit pathspec, and the ladder commit's message records the four added types and the per-book measurement that justifies them.
