---
title: Add a population percentile output to data-view measures
type: task
status: completed
description: >-
  Add a percentile output to the measure contract computed over a declared population (partition
  plus qualifier) rather than over the displayed result set, which is all the current client-side
  shading does. No window-function surface exists in the column machinery today.
base_uri: user:task/league/add-population-percentile-to-data-view-measures.md
created_at: '2026-09-23T19:42:36.085Z'
entity_id: d6b797b8-4f39-4710-82ee-42845dd65864
finished_at: '2026-09-24T14:59:47.011Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[██████████████████████-███████████-████████]]
  - relates [[user:text/league/data-views/measure-contract.md]]
  - relates [[user:task/league/add-percentile-shading-to-selected-player-games-and-seasons.md]]
started_at: '2026-09-24T02:14:03.997Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-11-06T02:27:57.537Z'
visibility_analyzed_at: '2026-11-06T00:17:39.316Z'
---

## Overview

Add a percentile output to the data-view measure contract, computed over a DECLARED population rather than over whatever rows the table happens to be showing.

**Impact.** Every existing measure gains a companion percentile — a share, a rate, a counting stat. "This back is in the 92nd percentile of rushing-opportunity share among qualified running backs in 2025" becomes a column rather than a thing a reader estimates by eye. It is a prerequisite for any composite that wants to weight components measured in different units.

**Ordering.** Unblocks the percentile half of [[██████████████████████-███████████-████████]]. Independent of the model work in [[██████████████████████-██████-███████████-████████]] and of everything else; it can land first and stands on its own.

## Background

Measured 2026-09-23 against the codebase.

**There is no window-function surface anywhere in the column machinery.** Grepped `libs-server/data-views-column-definitions/` and `libs-server/data-views/` for `percentile`, `_rank_`, `rank_over`, `PERCENT_RANK`, `NTILE`, `normalized_score`, `scaled_score`. Everything that came back is one of three things and none of them is this:

- **Passthroughs of externally-computed ranks** — `player_fantasy_points_rank_from_seasonlogs` (`player-scoring-format-logs-column-definitions.mjs:181-187`), `player_points_added_*_rank_from_seasonlogs` (`player-league-format-logs-column-definitions.mjs:182-214`). These read a precomputed column.
- **Passthroughs of externally-produced 0-100 grades** — the grades supplier's grades, ESPN scores.
- **Client-side percentile SHADING** — `reverse_percentiles` / `disable_percentiles` in `libs-server/data-views/derive-view-from-query-result.mjs:47,112-149`. These are display annotations on a column descriptor, not computed values; the comment at `run-query-backed-view.mjs:115` confirms adding `reverse_percentiles` changes nothing in a viewer's data.

### The distinction this task exists to make

The shading that exists today is a percentile **over the result set**. It changes when the user filters the table, so the same player shows a different percentile depending on what else is displayed. That is fine as a visual cue and wrong as a value — it cannot be sorted on meaningfully, compared across saved views, or used as an input to anything.

What is wanted is a percentile **over a declared population**: a partition key (position, season) plus a qualifier (a minimum snap or opportunity floor). Stable, comparable, and meaningful in isolation.

**The qualifier is the part that will be got wrong, and it is load-bearing rather than a refinement.** Measured 2026-09-23 with no qualifier, 2024 REG opportunity share partitioned by position group over the full `player` table: 26,631 of 28,886 players have no usage at all, and because they tie as one enormous block the real players are crushed against the floor — the BEST real player lands at the 11.6th percentile and the average real player at the 1.0th. The column is not merely noisy without a floor, it is inverted and unusable.

Note the direction, because it is the opposite of the intuition: an unqualified population does not compress starters into the TOP decile, it compresses them into the BOTTOM one, since the unmeasured mass ties at a single rank rather than spreading. Write the assertion against the measured direction. The floor is a modelling decision and belongs in the declaration, not in a default.

**The qualifier cannot be ONE scalar shared across partitions, which is what "a qualifier" in this plan implied.** Positions have different natural squad sizes — roughly 3 RB, 5 WR and 2 TE per team carry a real role, so about 96 / 160 / 64 league-wide — and no single threshold lands near all three. Measured on 2025 REG opportunity counts and shares:

| Floor | RB qualified | WR qualified | TE qualified |
| --- | --- | --- | --- |
| target population | ~96 | ~160 | ~64 |
| 25+ opportunities | 92 | 121 | 56 |
| 5 percent share | 98 | 106 | 40 |
| 2 percent share | 118 | 173 | 75 |

A 5 percent share floor fits RB almost exactly and cuts WR and TE by a third; a 2 percent floor fits WR and TE and lets in a third too many RB. The declaration therefore needs a qualifier PER PARTITION KEY, not a scalar beside it.

**The per-partition floors, calibrated on the distribution they are applied to.** Derived by reading the share of the Nth-ranked player at each position, where N is that position's target squad size, then validated across seasons.

**A floor is a property of a DISTRIBUTION, not of a position**, and this plan learned that the expensive way. The first set — RB 3.0% / WR 1.5% / TE 2.0% — was calibrated on OPPORTUNITY share, carries plus targets each counted as 1, and shipped applied to EXPECTED FANTASY POINTS share. Re-measured on the column's own values it overshot WR by up to 20% and TE by up to 34%. The floors below are calibrated on expected-points share and are the live ones, at league `71c774538`:

| Position | Floor | 2023 qualified | 2024 | 2025 | Target |
| --- | --- | --- | --- | --- | --- |
| RB | 3.5% | 95 | 97 | 96 | ~96 |
| WR | 3.0% | 160 | 153 | 166 | ~160 |
| TE | 3.1% | 65 | 62 | 66 | ~64 |

Every cell lands within 4.4% of target. Note what changed and what did not: one scalar still cannot serve all three, but the three floors are now CLOSE TOGETHER rather than far apart, because valuing an opportunity in expected points instead of counting it as 1 pulls the positions toward each other. The "RB needs double WR's" reading was a fact about opportunity share and does not survive the change of unit. They remain SHARE floors, so they carry no window dependence, which is the other constraint below.

The year-to-year spread in the underlying Nth-player share means a fixed floor yields a squad size that moves by season — RB reaches 108 in 2020 and 112 in 2021 on these floors. That is correct and intended: the floor declares a bar, not a quota. Do not resolve it per season to hit an exact N, which would make the percentile population depend on the season being viewed.

**Express the floor in a window-independent unit.** A raw count floor silently changes meaning with the window: 25 opportunities is a reasonable season bar and an impossible single-week one, so the same declaration would qualify nobody at week grain. Either express it as a rate or a share, or resolve it against the window the query asks for — and say which, because a count floor that reads sensibly on the season view is exactly the shape that returns an empty column on the weekly one.

### Assumptions

- The measure contract renders at three points — season, period CTE, and offset-range recombination — and a new output must be correct at all three. Checked: `libs-server/data-views/measure/measure-contract.mjs:68,123,152`.
- Recombination sums ACCUMULATORS and applies the combine after, so a percentile must be computed AFTER the combine, never carried through it. Checked: `measure-contract.mjs:141-160`.
- Capability advertisement is DERIVED by probing the combine rather than declared, so a new output family must extend that derivation or it will not be advertised. Checked: `combine_divides` in `libs-server/data-views/measure/combine-accumulators.mjs:88-105`, with `data-views.capability-parity.spec.mjs` asserting the hand-held set equals the derived one.
- Postgres window functions can be applied over the final aggregated projection without a second pass. CONFIRMED 2026-09-23 by EXPLAIN ANALYZE on production: `PERCENT_RANK() OVER (PARTITION BY ... ORDER BY MAX(...))` in the same query level as the `GROUP BY` plans as a single `WindowAgg` directly above the `GroupAggregate`, with no subquery wrapper and no second scan. The window adds only a sort of the already-aggregated projection, 28,886 rows at 2.1 MB, against a 913 ms total. The shape is safe.
- `libs-shared/ratio-column-ids.mjs` is a client-side restatement of a server-derived set, guarded by a parity spec, so a new output family needs the client list regenerated in step with the server. Checked: header comment lines 1-25.

## Design

A percentile is an OUTPUT of an existing measure, not a new column family. A measure declares it wants one, names its partition and its qualifier, and the contract renders `PERCENT_RANK()` (or `CUME_DIST()` — rule on which, they differ at ties) over the combined value partitioned by the declared key, applied after the combine at each of the three render points.

Two properties must hold and both are silent when broken:

- **The percentile population must inherit the same filtered play set as the value.** If the user excludes garbage time, the percentile must be recomputed among qualified players on that same filter — otherwise the column mixes a filtered numerator with an unfiltered reference and reads plausibly while being nonsense.
- **A row below the qualifier gets NULL, not a percentile.** It must not silently join the population and compress everyone else. NULL is the existing convention for not-measured (`guarded_divide` uses `NULLIF`).

**Naming.** `percentile` as the output name; the declaration carries `percentile_partition` and `percentile_qualifier`. Full words, no abbreviation, and `percentile` rather than `rank` because rank is already taken by the seasonlog passthroughs and means an integer position there.

### The request surface — RESOLVED 2026-09-23

**The percentile ships as a companion COLUMN ID sharing the base measure's declaration and its CTE**, not as an `output.aggregation` on the existing column. The plan did not say which, and the two are materially different builds. Four mechanical facts settle it, none of them a judgement call:

- **`params.output` routes to `apply_output_aggregator`, which SKIPS the legacy `with_select` path** (`get-data-view-results.mjs:1203`, and the FROM-optimization rejection at `:798`). Under an output-aggregation reading the season render point would be dead code — which contradicts this plan's own requirement that the percentile be correct at all THREE render points.
- **A golden fixture entry is keyed per COLUMN ID.** `data-views.season-parity.spec.mjs:108` asserts `Object.keys(all)` — the column-definitions registry — equals the golden's key set. This plan's golden-entry step is only meaningful for a new column id; an output param needs no entry.
- **The wiring step requires a client field**, on the stated ground that a from-plays column without one ships server-only and unreachable. An output param needs no client field; a column id does.
- **The sharing mechanism already exists.** `select_strings` is deduped into a `Set` and each member projected into ONE shared CTE (`add-player-stats-play-by-play-with-statement.mjs:162-164`), so a companion column carrying the same params lands in the same scan and costs one extra select rather than a second materialization.

This keeps the Design's claim intact rather than contradicting it: **it is not a new column FAMILY.** Same factory, same fact source, same measure declaration, same CTE, one extra projected select. What it is not is a second `(period, aggregation)` tuple — that registry is keyed on a period, and a percentile has no denominator unit to name.

**The capability step resolves to `RATIO_COLUMN_IDS`, and that is the client list the parity spec already guards.** The percentile column's combine is the same DIVIDING combine as its base, so `combine_divides` is true, so it withholds `rate` and the whole denominator-unit vocabulary — which means its id must join `libs-shared/ratio-column-ids.mjs`. `data-views.capability-parity.spec.mjs` asserts that set as EQUALITY against the server-derived one, in both directions, so the server change cannot land without the client list moving in step. That is exactly the behaviour this plan's capability step asks for, and it needs no new derivation.

### The identifier arithmetic is at the limit, with zero headroom

Measured rather than estimated. `expected_fantasy_points_share_percentile_from_plays` is **51** characters, and the period CTE projects `${stat_name}_${accumulator}`:

| Alias | Bytes |
| --- | --- |
| `..._share_percentile_from_plays` | 51 |
| `..._numerator` | 61 |
| `..._denominator` | **63 — exactly the Postgres limit** |

Legal today and fragile forever. `_percentile` costs exactly the eleven characters of headroom the parent plan measured and set aside, so any future accumulator name longer than `denominator`, or any lengthening of the atom's name, truncates SILENTLY and collides two accumulator columns into one with no error.

**Nothing in the column pipeline guards this.** The only 63-byte check in the tree lives in the SANDBOXED generation tier — `data-views/generation/validate-generated-sql.mjs:46`, `NAMEDATALEN_BYTES`, with its collision test at `data-view-sql-guard.spec.mjs:200` — and grades agent-generated SQL, not the hand-declared factory's aliases. Add the assertion to the measure contract at module load and reuse that module's existing `truncate_identifier` rather than writing a second copy: a load-time throw is the difference between this class being impossible and being one rename away.

**Knowledge Base Impact.** A new output family is a measure-contract change by that subsystem's own split rule, so [[user:text/league/data-views/measure-contract.md]] must document the output, both invariants above, and the qualifier decision. No new tag, no ABOUT.md change.

**Scratch.** None needed; this produces no multi-file artifact.

## Plan

### Engine

- [x] **Rule on the percentile function and the tie convention** — RULED 2026-09-23: **`PERCENT_RANK`**, and the qualifier below is what made it a low-stakes choice.
  - The qualifier was ruled first, as this step required, and the disagreement then collapsed exactly as predicted. Re-measured on the QUALIFIED population, 2023-2025 REG, partitioned by position:

    | Season | RB max disagreement | TE max | WR max |
    | --- | --- | --- | --- |
    | 2023 | 1.1 | 2.7 | 1.1 |
    | 2024 | 1.0 | 1.7 | 1.0 |
    | 2025 | 1.8 | 2.1 | 1.3 |

  - From 18.2 percentile points to at most 2.7. The zero-share tie block WAS the entire disagreement, and the floor removes it. TE stays the widest because it has the most near-floor clustering, which is the same reason TE was hardest to fit a floor to.
  - `PERCENT_RANK` chosen because it is the conventional meaning of "percentile rank" — `(rank - 1) / (n - 1)`, spanning a full 0 to 100 — and the tie asymmetry that was the only real argument against it is now bounded under 3 points. Note the consequence to state in the doc: the LOWEST qualified player reads exactly 0, which is distinguishable from below-the-floor only because that case is NULL rather than 0.
  - `PERCENT_RANK` and `CUME_DIST` differ at ties, and ties are common at the bottom of any opportunity distribution where many players share zero.
  - Measured 2026-09-23 on 2024 REG opportunity share, partitioned by position, so the ruling can be made from data rather than from first principles. The choice is NOT cosmetic — max disagreement runs to 18 percentile points:

    | Position | Players | Zero-share ties | Rows disagreeing by over 1 point | Max disagreement |
    | --- | --- | --- | --- | --- |
    | RB | 161 | 20 | 25 | 12.4 |
    | TE | 143 | 26 | 36 | 18.2 |
    | WR | 256 | 29 | 35 | 11.3 |

  - The tie block IS the zero-share population, which is the same population the qualifier exists to exclude — so rule on the qualifier first and re-measure after, since a floor that removes the zero block shrinks this disagreement substantially.
  - Verify: run both over one real column's population and show the rows where they disagree; state which was chosen and why.

- [x] **Add the percentile output to the measure contract** — DONE 2026-09-23 at league `3e9f4d801` and `6b877baa6`, all three render points.
  - Files: `libs-server/data-views/measure/percentile.mjs` (new), `libs-server/data-views/measure/apply-joins.mjs` (new), `libs-server/data-views/postgres-identifier.mjs` (new), `libs-server/data-views/measure/measure-contract.mjs`, `libs-server/data-views/output-aggregator/build-period-cte.mjs`
  - Applied after the combine at the season render, the period-CTE render and the recombination.
  - **The shape changed once during the build, and the final one is simpler than the plan implied.** A measure DECLARING a percentile renders the percentile AS ITS VALUE, rather than carrying it beside the value as a second field. That is what makes all three renderings need no percentile-specific branch: every consumer already asks for "this measure's value at my grain". Carrying it beside the value would have made each of the three emitters responsible for remembering to project it, and the one that forgot would have been silent — the exact failure mode this chain already paid for once with `apply_joins`.
  - **The period render needs a parameter only the emitter can supply, and omitting it is silent.** The declaration names the non-time part of the population (position); the TIME part belongs to the grain. At period grain the scan groups by (subject, period), so a window partitioned by position alone ranks week 3 against week 14 in one pooled population — well-formed, plausible, and wrong. `percentile_extra_partition` carries the period key and the year; the season render passes none because its rows are already one per subject.
  - **A second real bug, caught by reading rather than by a gate:** those keys must be the underlying EXPRESSIONS, not the select aliases. Postgres exposes an output column name to `GROUP BY` and `ORDER BY` but NOT inside a window's `PARTITION BY`, so `year` and `period_key` would have failed to resolve.
  - **Two properties are structural rather than asserted, and both were silent when broken.** The qualifier sits in the `PARTITION BY` as well as the `CASE` — a window cannot drop rows, so gating only the output leaves sub-floor players inside the rank denominator, compressing everyone above them while reading as excluded. And the percentile ranks the UNROUNDED combine, because ranking `with_select` manufactures ties: at `decimals: 2` every share inside a hundredth of a point collapses to one rank.
  - **`apply_joins` had to become composable, which the plan did not carry.** The percentile's partition key can reference a non-fact relation exactly as an accumulator expression can, and both parts routinely need the SAME relation — where applying it twice is a duplicate-alias error rather than a no-op. `compose_apply_joins` unions them, deduplicating by function reference because a join applier is a singleton property of the relation it joins.
  - All three render points verified by direct render: the season combine, the recombination over an offset window (which ranks the RECOMBINED value, never an average of per-period percentiles), and the period CTE. The negative control fired — omitting the grain keys changes the emitted SQL.
  - Verified byte-neutral for every existing column: full suite **12,138 passing, 0 failing, exit 0**, all 143 goldens byte-identical.
  - Verify: generate SQL for one column at all three render points and confirm the window function appears after the combine in each; show the check fail by removing it from one path.

- [x] **Extend capability derivation so the output is advertised** — DONE 2026-09-23; it needed no new derivation, see the request-surface section above.
  - The four registration gates failed first, naming exactly this column and the 49-vs-50 ratio-list mismatch, so they served as their own negative controls before the entries were added. Green after: 46 passing, exit 0.
  - File: `libs-shared/ratio-column-ids.mjs`, not `combine-accumulators.mjs`.
  - The percentile column's combine is the same DIVIDING combine as its base, so `combine_divides` already returns true and the column already withholds `rate` and the denominator-unit vocabulary. What that obliges is membership in `RATIO_COLUMN_IDS`, the client's restatement of a server-derived set.
  - Verify: `data-views.capability-parity.spec.mjs` passes, and fails when the new output is added server-side without regenerating the client list. The spec already asserts that set as EQUALITY in both directions, so the negative control is to add the column server-side and leave the client list alone.

- [x] **Guard the identifier limit at module load** — DONE 2026-09-23, and the FIRST VERSION OF THIS GUARD WAS WRONG, which is the durable lesson.
  - Files: `libs-server/data-views/postgres-identifier.mjs` (new, the primitive extracted from the generation tier so core does not import from it), `libs-server/data-views/measure/measure-contract.mjs`
  - **Length is not the rule. COLLISION is.** The first draft asserted on byte length and immediately rejected `successful_rushing_and_receiving_play_pct_from_plays`, which is live and correct: its `_denominator` alias is 64 bytes, but the recombination builds its reference from the same two parts, so the projection and the reference truncate IDENTICALLY and still match. Consistent truncation is harmless; only two distinct aliases collapsing to one name changes an answer, and that is what the guard now checks — the same invariant the generation tier had already settled on.
  - That miss is worth keeping because it failed in the safe direction only by luck. It blocked working code loudly at module load; had the assertion been one step weaker it would have passed over the real hazard.
  - Verify: the guard throws on two accumulator names sharing a 63-byte prefix (negative control fired, and the aliases were asserted distinct first so the control is not vacuous), and does NOT throw on mere over-length.

- [x] **Honor the qualifier as NULL rather than inclusion** — VERIFIED AGAINST DATA 2026-09-23, both clauses, on the rendered statement rather than by inspection.
  - Sub-floor players return NULL: on 2024 REG, 65 RB / 104 WR / 81 TE have a share but no percentile, and every position with no declared floor — QB 91, OL 375, P 39 — is NULL throughout, which is correct, since only players competing for touches have a role to rank.
  - **The discriminating clause passes: ZERO qualified players move.** Rendered the statement twice, once over the full player population and once over a population the sub-floor rows were never in (a `HAVING` on the qualifier, built from the same fragment the contract renders). Every qualified player's percentile is byte-identical between the two — 0 differing of 97 RB, 152 WR and 62 TE.
  - **The negative control fires hard, so the zero is evidence rather than a check that cannot report.** Stripping the qualifier out of the window's `PARTITION BY` — exactly the build where sub-floor rows stay in the rank denominator while still reading NULL — turns the same comparison to 96 RB, 151 WR and 61 TE differing. Essentially every qualified player moves. The two readings differ by the whole population, which is what distinguishes a control that fired from a comparison that was already flat.
  - The lowest qualified player reads exactly 0 at every position under both renderings, as `PERCENT_RANK` requires, and is distinguishable from below-the-floor only because that case is NULL.

### Wiring

- [x] **Declare the percentile on one existing column end to end** — DONE 2026-09-23 at league `2129f44e8` as `player_expected_fantasy_points_share_percentile_from_plays`.
  - A companion column id sharing the base column's measure declaration, params and CTE, per the request-surface section. `select_strings` are deduped into one scan, so it costs one extra projected select rather than a second materialization.
  - The floors live in `opportunity-atoms.mjs` as `QUALIFIED_ROLE_SHARE_FLOORS` with the partition and qualifier fragments beside them, so the column definition stays declarative and a second percentile column cannot restate them differently.
  - All four registration sites done and gated: the description, `RATIO_COLUMN_IDS`, the client field, and the golden.
  - **Most of the UI half is now verified without a browser, and what remains is narrower than "needs a browser" implied.** Reachability is gated by `data-view-player-field-parity.spec.mjs`, which names this column and whose negative control fired. The 0-100 range is measured on the rendered statement against production — min 0, max 100 at every position, NULL below the floor. Server-side SORT is exercised too: the measurement harness renders with `sort: [{ column_id: <percentile>, desc: true }]` and the statement executes and orders correctly, and ordering is an `ORDER BY` in the emitted SQL rather than client behaviour.
  - **What is genuinely unverified is the client-side DISPLAY of the column — its visual render and formatting — and it is environmentally blocked on this machine**, not skipped. Both local routes fail for reasons unrelated to this column: the development database has no NFL tables, so a `yarn dev` UI renders the column empty and verifies nothing, and `start:api:prod` aborts with `ENOENT` on the API's TLS key because production mode expects server TLS certs. A `dev:live` frontend points at the DEPLOYED API, which does not yet carry the recalibrated floors. Unblocking it needs a seeded development database or a deploy, either of which is a larger change than this step.
  - Residual risk is low and worth stating rather than implying it is zero: the value, its range, its NULL convention and its ordering are all verified at the query layer, and the render path is the generic table machinery shared by 140-plus columns.
  - Verify: the column renders in the UI with values in 0-100 and sorts correctly; confirm the client field exists, since a from-plays column without one ships server-only and unreachable, as `player_quarterback_epa_from_plays` did.

- [x] **Add the golden fixture entry** — DONE 2026-09-23. 142 to 143 entries, the new key confirmed BY NAME.
  - Checked the way the parent plan prescribes: diffed against `HEAD` rather than trusting the suite, and asserted programmatically that every pre-existing key's value is byte-identical. Exactly 3 lines added, 0 removed.
  - A first attempt rewrote the file through `JSON.stringify` and reformatted 8 unrelated single-line entries. Reverted and inserted textually instead — the fixture is hand-written by convention, and needless churn in it is what makes a real change hard to review.

### Testing and validation

- [x] **Population-level non-degeneracy** — PASSES 2026-09-23, after the floors were RE-DERIVED. The inherited ones failed this step, which is what it existed to catch.
  - **The floors did miss, at the two receiving positions, and by a lot.** Measured on this column's own distribution, 2023/2024/2025 REG: WR qualified 188/181/192 against a ~160 target and TE 75/84/86 against ~64. RB alone survived the transfer at 101/101/99. Shipped at league `71c774538` as RB 3.5 / WR 3.0 / TE 3.1.
  - **The two distributions differ in SHAPE, not merely in scale**, which is why no rescaling of the old floors would have worked and is the durable part of this finding. Valuing an opportunity in expected points rather than counting it as 1 pulls the positions TOGETHER: the Nth-ranked player's share lands at 3.47/3.75/3.56 for RB, 3.02/2.62/3.22 for WR and 3.20/3.05/3.80 for TE — a spread of about half a point, against opportunity share's 3.0-vs-1.5 gap of double. A receiving target is worth more expected points than a carry, so WR and TE rise relative to their opportunity counts.
  - Re-derived by the same method the original used — the Nth-ranked player's share, N being target squad size. Qualified counts against target:

    | Position | Floor | 2023 | 2024 | 2025 | Target | Worst cell |
    | --- | --- | --- | --- | --- | --- | --- |
    | RB | 3.5% | 95 | 97 | 96 | ~96 | 1.0% |
    | WR | 3.0% | 160 | 153 | 166 | ~160 | 4.4% |
    | TE | 3.1% | 65 | 62 | 66 | ~64 | 3.1% |

  - Validated back to 2019 rather than only forward: WR holds 160-170 and TE 62-72 throughout, while RB drifts to 108 in 2020 and 112 in 2021. That is a real era effect — more backs cleared a real role — and exactly the season-to-season movement a bar is supposed to show rather than a quota.
  - **Decile histogram per position, 2024 REG, on the rendered statement** — flat at all three, which is what `PERCENT_RANK` over a population with no mass ties produces:

    | Position | d1 | d2 | d3 | d4 | d5 | d6 | d7 | d8 | d9 | d10 |
    | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
    | RB | 10 | 10 | 9 | 10 | 10 | 9 | 10 | 9 | 10 | 10 |
    | WR | 16 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 16 |
    | TE | 7 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 7 |

  - **Each position being uniform over its OWN n is the positive evidence the partition resolves per position**, which is the failure this step was written to catch. One pooled group would leave WR concentrated high and RB low rather than all three flat. Min 0 and max 100 at every position confirms the full span.
  - **The percentile recomputes under a play filter.** Same column with and without the competitive win-probability bound, 2024 REG: 85 of 86 RB, 142 of 143 WR and 59 of 60 TE qualified under both move, by up to 24.1 percentile points. Identical output under both would have meant the population ignored the filter; the single non-mover per position is the rank pinned at an endpoint.

- [x] **Latency against the service objective** — PASSES 2026-09-23, warm, three runs discarding the first, on production.
  - **Season grain 679 ms mean** (680 / 671 / 686), against a 596 ms base share column. The window costs roughly 80 ms.
  - **Period grain 3.25 s mean** (3571 / 3092 / 3092), against the base column's 2.97 s under a position-uniform format. Roughly 0.3 s for the window.
  - Both inside the 5 s advisory objective in [[user:text/league/data-views/data-view-service-objectives.md]], and far inside the 40 s enforced cutoff. The period-grain reading carries the same caveat the sibling plan ruled on: a period request carrying this column AND another will cross 5 s and emit `slow_view`, which is the objective's feedback loop firing rather than a defect.

### Knowledge base updates

- [x] **Document the output and both invariants** — DONE 2026-09-23, section "The percentile output" in [[user:text/league/data-views/measure-contract.md]].
  - All three verify items are named: the filter-inheritance invariant, the NULL-below-qualifier rule, and the `PERCENT_RANK` tie ruling with why the choice stopped mattering once the qualifier removed the zero-share block.
  - It documents THREE invariants rather than two — the third being that the qualifier sits in the window's `PARTITION BY` and not only in its `CASE`, which is the one this build discovered and which the data check above then confirmed.
  - Updated 2026-09-23 after the floor recalibration: the floor paragraph is now the measured finding rather than a caution. A floor is a property of the DISTRIBUTION, not of the partition, and the two distributions differ in shape rather than scale.
