---
title: Unify Data View Output Aggregation (Rate-Type plus Threshold-Count)
type: task
status: cancelled
description: >-
  Introduce a unified output column param on numeric play-by-play measure columns, replacing the
  rate_type plugin system with a broader period-aggregator registry dispatched on (period,
  aggregation). Ships rate (retrofit) and count-with-threshold (new) in v1, migrates saved views in
  place, retains rate_type as a deprecated API alias for one release cycle.
base_uri: user:task/league/unify-data-view-output-aggregation.md
created_at: '2026-04-23T15:30:44.323Z'
entity_id: e8a66b0f-497d-4da2-865e-54b3675ec73f
finished_at: '2026-05-12T19:16:54.785Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - follows [[user:guideline/write/write-software-implementation-plan.md]]
  - uses [[user:guideline/write/write-javascript.md]]
  - uses [[user:guideline/write/write-software.md]]
  - uses [[user:guideline/software/simplify-software-plan.md]]
  - uses [[user:guideline/knowledge-base/persist-knowledge.md]]
  - succeeded_by [[█████████████████████-█████████████-██-████████-████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-06-01T17:19:06.283Z'
visibility_analyzed_at: '2026-11-05T23:50:34.142Z'
---

# Implementation Plan: Unify Data View Output Aggregation (Rate-Type plus Threshold-Count)

## Overview

- Introduce a single `output` column param on numeric play-by-play measure columns that parameterizes how the measure is aggregated to player rows. Replace the existing `rate_type` plugin system with a broader period-aggregator registry dispatched on `(period, aggregation)`.
- Ship two initial aggregations: `rate` (retrofit of today's rate-type behavior) and `count` with a threshold predicate (new — enables columns like "count of games with receiving yards at or above 100" without introducing new column IDs).
- Unify the CTE contract so any numeric measure column opts in via a single flag and automatically supports the new output modes. Retrofit the existing rate-type consumers in one cutover.
- Migrate saved views from `rate_type` to `output` in place using the dual-site pattern established by the `nfl_week_id` migration. Retain `rate_type` as a deprecated API alias for one release cycle.
- Ship the frontend param editor, params-aware formatter resolution, browser-storage migration hook, and documentation updates alongside the server work.

## Background

### Existing primitives that are preserved

- **Rate-type plugin registry** at `libs-server/data-views/rate-type/index.mjs` dispatches on `rate_type` string. The architectural template for the unified registry.
- **HAVING threading** is wired: `use_having: true` routes filter predicates into `with_having_clause_strings` inside `add_clauses_for_table` in `libs-server/get-data-view-results.mjs`, applied via `havingRaw` inside each CTE handler. `add_clauses_for_table` is a local function in that file, not a separate module.
- **Year pushdown contract** (`get_effective_years`, `data_view_options.year_range`) and `withMaterialized` policy documented at `docs/data-views-system.md:1516-1535`.
- **Centralized references** (`pid_reference`, `year_reference`, `week_reference`) adapt to from-table optimization and are consumed directly by all CTE joins.
- **Saved-view migration pattern**: `libs-shared/data-views-nfl-week-migration.mjs` (pure migrator), `scripts/migrate-data-views-single-nfl-week.mjs` (server script with `--apply` flag, touches `user_data_views.table_state` and `user_plays_views.table_state`), and `app/core/data-views/browser-storage.mjs` (localStorage restore hook).
- **Param UI registry** in the column-controls component switches on `data_type`, supports custom `component` overrides for nested param shapes.

### Friction that the plan addresses

- **Param schema is dual-layered**: JSON schemas under `docs/data-view-specs/parameters/` plus runtime `libs-server/validators.mjs`. New param keys require additions in both locations plus rules in `docs/data-view-specs/validation/parameter-compatibility.json`.
- **Outer aggregation hardcodes SUM and numerator-or-denominator** at `libs-server/data-views/select-string.mjs:94, 103`. A registry dispatch must replace these expression emitters.
- **CTE handler signature is fixed** and period grain is implicit in each handler's GROUP BY. A shared period-CTE helper is required so that aggregator plugins differ only in outer-expression emission.
- **Column registration is static** (monolithic index merge). Acceptable. Column-level opt-in handles extension.
- **Frontend formatters are static per column ID**: `fixed: 2` on most numeric columns. With `aggregation: 'count'` the return shape is an integer count — formatter must become params-aware.
- **Rate-type consumers outside the registry**: `select-string.mjs` reads `params.rate_type`; `param_override_config` on the rate-type param enables cross-column param linking. Both must migrate onto `output` cleanly.
- **Saved-view `table_state`** is JSONB, shape `{ columns: [{ column_id, params: { rate_type: ['per_game'] } }] }`. Migration is a key rewrite plus wrap into `output: { period, aggregation: 'rate', threshold: null }`.

## Design

### Param contract

Single nested param `output` on opted-in columns:

```
output: {
  period: <one of the period tokens enumerated below> | null,
  aggregation: 'sum' | 'rate' | 'count',
  threshold: { op, value } | null
}
```

- `aggregation: 'sum'` with `period: null` is the default and is today's behavior. No registry entry; handled in the existing `main_select` code path. `period` is permitted to be `null` only when `aggregation` is `'sum'`.
- `aggregation: 'rate'` requires `period`. Replaces every current `rate_type` value.
- `aggregation: 'count'` requires `period` and `threshold`. `period` must be a time-grain value (`game` or `season`); denominator-unit periods are not valid targets for `count` in v1.
- `threshold.op` is one of `>=`, `>`, `<=`, `<`, `=`. `threshold.value` is numeric.

`avg` aggregation and additional time-grain periods are intentionally out of v1 per the simplify-plan guideline. The registry accepts additional entries without structural change when a second consumer appears.

### Legacy rate-type token mapping

The current `rate-type` registry exposes 17 tokens. Each maps onto the new `output` param as a `(period, aggregation: 'rate')` tuple. The `period` axis therefore spans both time grains (`game`, `season`) and denominator-unit tokens carried forward verbatim from the current system. The canonical mapping table lives once in `libs-shared/data-views-output-migration.mjs` and is consumed by both the request-time normalizer and the saved-view migrator.

Time-grain periods:

- `per_game` → `{ period: 'game', aggregation: 'rate' }`

Team-level denominator-unit periods:

- `per_team_play` → `{ period: 'team_play', aggregation: 'rate' }`
- `per_team_pass_play` → `{ period: 'team_pass_play', aggregation: 'rate' }`
- `per_team_rush_play` → `{ period: 'team_rush_play', aggregation: 'rate' }`
- `per_team_half` → `{ period: 'team_half', aggregation: 'rate' }`
- `per_team_quarter` → `{ period: 'team_quarter', aggregation: 'rate' }`
- `per_team_drive` → `{ period: 'team_drive', aggregation: 'rate' }`
- `per_team_series` → `{ period: 'team_series', aggregation: 'rate' }`

Player-level denominator-unit periods:

- `per_player_rush_attempt` → `{ period: 'player_rush_attempt', aggregation: 'rate' }`
- `per_player_pass_attempt` → `{ period: 'player_pass_attempt', aggregation: 'rate' }`
- `per_player_target` → `{ period: 'player_target', aggregation: 'rate' }`
- `per_player_catchable_target` → `{ period: 'player_catchable_target', aggregation: 'rate' }`
- `per_player_deep_target` → `{ period: 'player_deep_target', aggregation: 'rate' }`
- `per_player_catchable_deep_target` → `{ period: 'player_catchable_deep_target', aggregation: 'rate' }`
- `per_player_reception` → `{ period: 'player_reception', aggregation: 'rate' }`
- `per_player_play` → `{ period: 'player_play', aggregation: 'rate' }`
- `per_player_pass_play` → `{ period: 'player_pass_play', aggregation: 'rate' }`
- `per_player_rush_play` → `{ period: 'player_rush_play', aggregation: 'rate' }`
- `per_player_route` → `{ period: 'player_route', aggregation: 'rate' }`

Count aggregation in v1 is only supported on time-grain periods (`game`, `season`). The registry structure accepts denominator-unit count entries in future releases without structural change.

### Validation rules

- `aggregation: 'sum'` permits `period: null` and bypasses the registry entirely.
- `aggregation: 'rate'` or `'count'` requires non-null `period`.
- `aggregation: 'count'` requires non-null `threshold` with a valid `op` and numeric `value`, and `period` must be a time-grain value (`game` or `season`). Denominator-unit periods are rejected for `count` in v1.
- `threshold` is rejected when `aggregation !== 'count'`.
- `period` must be in the column definition's declared `supports_output.periods`.
- `splits: ['week']` combined with `aggregation: 'count'` and `period: 'season'` is rejected at validation time as a hard error.
- `splits: ['week']` combined with `aggregation: 'rate'` and `period: 'game'` preserves the current silent-drop behavior for backward compatibility: the normalizer strips the `output` param from the column rather than rejecting the request. This matches today's behavior in `libs-server/get-data-view-results.mjs` around line 1451 where `rate_type: 'per_game'` is silently deleted under week splits. The normalizer logs the drop at debug level.
- `output` param is rejected on columns without `supports_output_aggregation: true`.

### Output aggregator registry

New directory `libs-server/data-views/output-aggregator/`.

- **`libs-server/data-views/output-aggregator/index.mjs`** — registry dispatch. Maps `(period, aggregation)` tuple to a plugin module. Entries shipped in v1 cover every combination needed to retrofit the 17 legacy rate-type tokens plus the new count cases: `game:rate`, `season:rate`, `team_play:rate`, `team_pass_play:rate`, `team_rush_play:rate`, `team_half:rate`, `team_quarter:rate`, `team_drive:rate`, `team_series:rate`, `player_rush_attempt:rate`, `player_pass_attempt:rate`, `player_target:rate`, `player_catchable_target:rate`, `player_deep_target:rate`, `player_catchable_deep_target:rate`, `player_reception:rate`, `player_play:rate`, `player_pass_play:rate`, `player_rush_play:rate`, `player_route:rate`, `game:count`, `season:count`. Each existing rate-type plugin module (`rate-type-per-game.mjs`, `rate-type-per-team-play.mjs`, etc.) is ported onto the new plugin interface and registered against its corresponding tuple.
- **`libs-server/data-views/output-aggregator/build-period-cte.mjs`** — shared period-grain CTE builder for time-grain periods (`game`, `season`). Accepts `{ measure_source, measure_expr, measure_predicate, period, effective_years, splits, data_view_options, having_clauses }`, returns a Knex subquery that emits `(pid, period_key, measure_total)` at the requested period grain. `game` groups by `(pid, esbid)` plus splits; `season` groups by `(pid, year)`. Honors year pushdown and materialization policy. Called by the time-grain aggregator plugins. Denominator-unit periods delegate to their ported rate-type CTE builders rather than `build_period_cte`.
- **`libs-server/data-views/output-aggregator/aggregator-rate.mjs`** — time-grain rate plugin. `add_cte` delegates to `build_period_cte` then wraps with a rate-denominator aggregate. `emit_outer_select` emits `CAST(SUM(measure_total) AS DECIMAL) / NULLIF(COUNT(*), 0)` against the CTE join.
- **`libs-server/data-views/output-aggregator/aggregator-count.mjs`** — time-grain count plugin. `add_cte` delegates to `build_period_cte` without the threshold applied pre-aggregation, then `emit_outer_select` emits `COUNT(DISTINCT cte.period_key) FILTER (WHERE cte.measure_total <op> <threshold>)`. The threshold is applied at the outer SELECT against the per-period totals materialized by the CTE; pre-filtering measure rows inside the CTE would lose rows and produce wrong totals when the threshold test should apply to the aggregated per-period measure.

Plugin interface (shared across aggregators):

- `get_cte_table_name({ column_def, params })` returns a deterministic hash-based name enabling CTE reuse across columns with identical inputs. The hash MUST include every field that affects CTE content: the subset of `params` that the plugin consumes, and column-definition-level fields `team_unit`, `is_team`, `measure_source`. Two columns with identical `output` params but different `team_unit` or `is_team` values must produce different CTE names.
- `add_cte({ players_query, column_def, params, cte_name, splits, data_view_options, having_clauses, team_unit, is_team })` registers the CTE via `withMaterialized`. `team_unit` and `is_team` are carried verbatim from today's rate-type CTE handler signatures and are required by team-level denominator plugins to emit correct SQL.
- `join_cte({ players_query, cte_name, splits, data_view_options, team_unit, is_team })` joins using centralized references.
- `emit_outer_select({ column_def, cte_name, column_index, params, team_unit, is_team })` returns `{ sql, bindings }` for the outer SELECT expression.

### Request normalization

- **`libs-server/data-views/normalize-output-param.mjs`** — request-time compatibility and validation. Translates legacy `params.rate_type` into canonical `params.output` for every column by delegating to the canonical token-mapping table exported from `libs-shared/data-views-output-migration.mjs` (single source of truth; no duplicated mapping). Also handles the week-split sanitization rules documented under Validation rules: drops the `output` param silently for the `rate`+`game` case, hard-rejects for the `count`+`season` case. Runs before dynamic param resolution. Produces a single normalized shape so downstream code reads only `params.output`.

### Pipeline integration

- **`libs-server/get-data-view-results.mjs`** — new stage after `process_dynamic_params` calls `normalize_output_param` for every column. The rate-type CTE discovery and creation loop is replaced by an output-aggregator discovery and creation loop that populates `data_view_options.output_ctes` (replacing `data_view_options.rate_type_tables`). CTE join phase calls registry `join_cte`. The local `add_clauses_for_table` function in this file is updated to read `data_view_options.output_ctes` instead of `rate_type_tables`. The existing week-split sanitization block around line 1451 is reworked to operate on the new `output` param and preserves the silent-drop behavior for the `rate`+`game` case.
- **`libs-server/data-views/select-string.mjs`** — when a column's params contain `output`, outer SELECT emission delegates to the aggregator's `emit_outer_select` rather than hardcoded SUM or numerator-or-denominator division.

### Column definition retrofit

New column-definition fields on every column that today declares `supported_rate_types`:

- `supports_output_aggregation: true`
- `supports_output: { periods: [...], aggregations: [...] }`
- `measure_source: 'gamelogs' | 'plays'`
- `measure_expr({ table_name, params })` returning the measure SQL expression
- `measure_predicate({ params })` returning optional filter SQL

`supported_rate_types` is kept as a read-only alias during the deprecation window. A load-time translation emits a warning if a column declares `supported_rate_types` but not `supports_output`.

### Frontend

- **`libs-shared/output-column-param.mjs`** — new shared param definition replacing the semantic surface of `libs-shared/rate-type-column-param.mjs`. The `param_override_config` mechanism is inherited with its key names renamed: `rate_type_match_column_params` → `output_match_column_params` and `rate_type_column_params` → `output_column_params`. The saved-view migrator rewrites the legacy key names wherever they appear in `table_state`.
- **`app/views/components/column-param-output/column-param-output.js`** plus stylus — new param editor. Dropdowns for period and aggregation, conditional threshold sub-form (operator plus numeric input) rendered when `aggregation === 'count'`. The component reference is injected via `app/core/data-views-fields/index.js` at field-index build time, matching the established pattern used by `ColumnParamNflWeekSelector` for `nfl_week_id`. The shared `libs-shared/output-column-param.mjs` does not carry a direct component import to avoid pulling React into the shared library.
- **`app/core/data-views/resolve-column-formatter.mjs`** — params-aware formatter helper. For `aggregation: 'count'` returns integer format; for `aggregation: 'rate'` returns `fixed: 2`; otherwise returns the column's default. Wired into column build at configuration time rather than per-render.

### Saved-view migration

- **`libs-shared/data-views-output-migration.mjs`** — pure migrator exporting `migrate_rate_type_to_output(table_state)`. Walks `columns`, `prefix_columns`, `where`, and `sort`; rewrites `params.rate_type` into `params.output`. Idempotent when `params.output` is already present. Maps legacy rate-type tokens onto the `(period, aggregation)` tuple.
- **`scripts/migrate-data-views-output.mjs`** — server migration script following the `scripts/migrate-data-views-single-nfl-week.mjs` shape. Supports `--apply` and dry-run default. Touches `user_data_views.table_state` and `user_plays_views.table_state`.
- **`app/core/data-views/browser-storage.mjs`** — extend the existing migration pipeline to call the new pure migrator during localStorage snapshot restoration.

### Deprecation strategy

- Server accepts `params.rate_type` indefinitely at the API boundary via `normalize_output_param` translation. Logged at debug level for observability.
- Frontend emits only `params.output` after cutover.
- `libs-shared/rate-type-column-param.mjs` and `libs-server/data-views/rate-type/` are kept as compat re-exports for one release cycle, then retired in a follow-up task entity.

### Files added or removed

Added:

- `libs-server/data-views/output-aggregator/index.mjs`
- `libs-server/data-views/output-aggregator/build-period-cte.mjs`
- `libs-server/data-views/output-aggregator/aggregator-rate.mjs`
- `libs-server/data-views/output-aggregator/aggregator-count.mjs`
- `libs-server/data-views/normalize-output-param.mjs`
- `libs-shared/output-column-param.mjs`
- `libs-shared/data-views-output-migration.mjs`
- `app/views/components/column-param-output/column-param-output.js`
- `app/views/components/column-param-output/column-param-output.styl`
- `app/views/components/column-param-output/index.js`
- `app/core/data-views/resolve-column-formatter.mjs`
- `scripts/migrate-data-views-output.mjs`
- `docs/data-view-specs/parameters/schemas/output-parameter.json`
- `docs/data-view-specs/parameters/values/output-periods.json`
- `docs/data-view-specs/parameters/values/output-aggregations.json`
- `test/data-views-output.spec.mjs`
- `test/data-views-output-migration.spec.mjs`

Modified:

- `libs-server/get-data-view-results.mjs` (main orchestrator; `add_clauses_for_table` is a local function in this file and is updated here)
- `libs-server/data-views/select-string.mjs`
- `libs-server/validators.mjs`
- `libs-shared/rate-type-column-param.mjs` (compat re-export)
- `libs-server/data-views/rate-type/index.mjs` (compat re-export)
- Every rate-type plugin module in `libs-server/data-views/rate-type/` is ported onto the new plugin interface: `rate-type-per-game.mjs`, `rate-type-per-team-play.mjs`, `rate-type-per-team-pass-play.mjs`, `rate-type-per-team-rush-play.mjs`, `rate-type-per-team-half.mjs`, `rate-type-per-team-quarter.mjs`, `rate-type-per-team-drive.mjs`, `rate-type-per-team-series.mjs`, `rate-type-per-player-rush-attempt.mjs`, `rate-type-per-player-pass-attempt.mjs`, `rate-type-per-player-target.mjs`, `rate-type-per-player-catchable-target.mjs`, `rate-type-per-player-deep-target.mjs`, `rate-type-per-player-catchable-deep-target.mjs`, `rate-type-per-player-reception.mjs`, `rate-type-per-player-play.mjs`, `rate-type-per-player-pass-play.mjs`, `rate-type-per-player-rush-play.mjs`, `rate-type-per-player-route.mjs`
- Every column definition in `libs-server/data-views-column-definitions/` that today declares `supported_rate_types` (covers 6 files; 47 `supported_rate_types` occurrences, 35 of them in `player-stats-from-plays-column-definitions.mjs`)
- `libs-server/data-views-column-definitions/player-games-played-column-definitions.mjs` — reads `data_view_options.rate_type_tables` directly; must be updated to read `data_view_options.output_ctes`
- `libs-server/data-views-column-definitions/player-routes-column-definitions.mjs` — reads `data_view_options.rate_type_tables` directly; must be updated to read `data_view_options.output_ctes`
- `libs-server/data-views-column-definitions/player-snaps-column-definitions.mjs` — reads `data_view_options.rate_type_tables` directly; must be updated to read `data_view_options.output_ctes`
- `libs-server/data-views-column-definitions/player-team-column-definition.mjs` — reads `data_view_options.rate_type_tables` directly; must be updated to read `data_view_options.output_ctes`
- `app/core/data-views/browser-storage.mjs`
- `app/core/data-views-fields/index.js` (inject output param component onto column definitions)
- Any `app/` file that imports `rate-type-column-param.mjs` for direct UI rendering or dropdown wiring
- `docs/data-views-system.md`
- `docs/data-view-specs/validation/parameter-compatibility.json`
- `docs/data-view-specs/index.json`
- Existing `test/data-views*.spec.mjs` files that assert on `rate_type`

### Knowledge Base Impact

- **Domain entry document**: not applicable. `repository/active/league` is a project-scoped repository. Project-internal docs under `repository/active/league/docs/` cover the domain surface.
- **ABOUT.md files**: none in the user-base workspace require updates. No scratch artifacts planned for this task.
- **Tag implications**: task entity carries `user:tag/league-xo-football.md`. No new tag required.
- **Observation candidates** on the task entity on completion:
  - `[design-decision] Unified rate-type and threshold-count aggregations under a single output param keyed on (period, aggregation).`
  - `[deprecation] rate_type param deprecated at the API alias level for one release cycle in favor of output.`
  - `[migration-pattern] Saved-view rate-type to output migration follows the dual-site shape established by nfl_week_id migration.`
- **Guideline candidates**: none in the user-base workspace. A future "adding a new period aggregator" guide under `repository/active/league/docs/` is a candidate for a follow-up task after a second aggregation lands.
- **Relations**: task entity relations include `follows [[user:guideline/write/write-software-implementation-plan.md]]`, `uses [[user:guideline/write/write-javascript.md]]`, `uses [[user:guideline/write/write-software.md]]`, `uses [[user:guideline/software/simplify-software-plan.md]]`.

## Notes

- **Simplifications applied**: `avg` aggregation, `season_seas_type` period, a per-period-file subdirectory, and a no-op `sum` plugin were considered in an earlier draft and removed. Each was speculative abstraction ahead of a concrete consumer and can be added incrementally against the registry without structural change.
- **Architectural bet**: `rate_type` and threshold-count are the same shape — "how does this numeric measure aggregate across periods?" — and the existing rate-type registry already proves the plugin model works. Unification removes a parallel code path rather than introducing a new one.
- **Cutover strategy**: single cutover inside this plan. Parallel APIs (`rate_type` and `output` coexisting in every column definition) would double the surface area permanently. Compatibility is handled at two narrow points: request-time normalization for API consumers and the saved-view migrator for persisted state.
- **Measure source selection**: column definitions choose `gamelogs` versus `plays` via `measure_source`. Box-score measures (rec_yds, rush_yds, pass_yds, receptions, carries, fantasy_points) read pre-aggregated `player_gamelogs` with `player_gamelogs_year_YYYY` partitioning where available. Play-derived measures (EPA, air_yards, situational-filtered aggregates) scan `nfl_plays`. The period-CTE helper handles both uniformly.
- **Split interaction**: outer GROUP BY already tracks splits via centralized references. Threshold-count with `splits: ['year']` produces a count per year; threshold-count with no splits produces a single count. Week-split plus season-period is rejected at validation.
- **CTE reuse**: the registry dedupes identical `(column_def, params)` tuples so multiple columns sharing output configuration share a single materialized CTE.
- **Cache key churn**: new CTE names shift cache identities once. Acceptable — TTLs are short (1 hour current year, 24 hours historical).
- **Test coverage**: migration correctness is covered by a pure-function unit spec on the shared migrator. End-to-end CTE correctness is covered by a small number of seeded fixture queries that exercise each aggregator plus the split interactions.
- **Parity audit**: every column definition declaring `supported_rate_types` today gets an explicit test in the output spec that the migrated `output` configuration produces equivalent SQL to the legacy `rate_type` configuration.

## Plan

### Core Implementation

- [ ] **Param schema and validation**
  - File: `docs/data-view-specs/parameters/schemas/output-parameter.json`
  - File: `docs/data-view-specs/parameters/values/output-periods.json`
  - File: `docs/data-view-specs/parameters/values/output-aggregations.json`
  - File: `docs/data-view-specs/validation/parameter-compatibility.json`
  - File: `libs-server/validators.mjs`
  - File: `docs/data-view-specs/index.json`
  - Purpose: Declare the `output` param shape, enumerate valid periods and aggregations, and encode compatibility rules covering aggregation-requires-period, count-requires-threshold, threshold-only-with-count, split-times-period restrictions, and the column opt-in requirement.
  - Changes:
    - Create JSON schema for the `output` object with nested `threshold` subschema.
    - Create period and aggregation enum value files.
    - Extend `parameter-compatibility.json` with rules listed above.
    - Add runtime validation rules for `output` shape to `validators.mjs` alongside existing param validators.
    - Register the new schema and value files in the specs index.

- [ ] **Shared output param definition for client and server**
  - File: `libs-shared/output-column-param.mjs`
  - Purpose: Provide the single param definition consumed by the frontend param editor and any server code that introspects param metadata. Replace the semantic surface of `libs-shared/rate-type-column-param.mjs`.
  - Changes:
    - Export a param definition with nested `period`, `aggregation`, `threshold` fields.
    - Preserve the `param_override_config` mechanism for cross-column param linking.
    - Attach the frontend custom component reference for rendering.

- [ ] **Period-grain CTE helper**
  - File: `libs-server/data-views/output-aggregator/build-period-cte.mjs`
  - Purpose: Central authoritative builder for per-period aggregation subqueries used by every aggregator plugin.
  - Changes:
    - Accept named parameters covering measure source, measure expression, optional measure predicate, period, effective years, splits, data view options, and having clauses.
    - Emit Knex subquery at the requested period grain: for `game` group by pid and esbid plus splits; for `season` group by pid and year.
    - Apply `effective_years` WHERE IN predicate against the source table to satisfy year pushdown.
    - Return the subquery without calling `with` or `withMaterialized` — aggregator plugins register the final CTE so they can decorate the outer SELECT.

- [ ] **Output aggregator registry**
  - File: `libs-server/data-views/output-aggregator/index.mjs`
  - Purpose: Registry dispatching on `(period, aggregation)` tuple to plugin modules.
  - Changes:
    - Export a `resolve_output_aggregator({ period, aggregation })` function returning the plugin module.
    - Register entries for `game:rate`, `game:count`, `season:rate`, `season:count`.
    - Export helper `get_output_aggregator_cte_name({ column_def, params })` that delegates to the plugin.

- [ ] **Rate aggregator plugin**
  - File: `libs-server/data-views/output-aggregator/aggregator-rate.mjs`
  - Purpose: Retrofit the behavior currently in `libs-server/data-views/rate-type/rate-type-per-game.mjs` and related files onto the new plugin interface.
  - Changes:
    - Implement `get_cte_table_name`, `add_cte`, `join_cte`, `emit_outer_select`.
    - `add_cte` calls `build_period_cte` then wraps with denominator aggregation and registers via `withMaterialized`.
    - `emit_outer_select` emits the division expression against the joined CTE using centralized references.

- [ ] **Count aggregator plugin with threshold**
  - File: `libs-server/data-views/output-aggregator/aggregator-count.mjs`
  - Purpose: New plugin emitting a count of periods satisfying the threshold predicate.
  - Changes:
    - Implement `get_cte_table_name`, `add_cte`, `join_cte`, `emit_outer_select`.
    - `add_cte` calls `build_period_cte` and registers via `withMaterialized`.
    - `emit_outer_select` emits `COUNT(*) FILTER (WHERE cte.period_total op threshold)` with operator and value bound from params.

- [ ] **Request-time normalization and compat translation**
  - File: `libs-server/data-views/normalize-output-param.mjs`
  - Purpose: Single shim that translates legacy `params.rate_type` to canonical `params.output` on every column for every request and enforces the week-split sanitization rules. Allows downstream code to read only `params.output`.
  - Changes:
    - Export `normalize_output_param(column, { splits })` returning the column with canonicalized params.
    - Import the canonical token-mapping table from `libs-shared/data-views-output-migration.mjs` and delegate translation to it so no mapping logic is duplicated.
    - No-op when `params.output` is already present.
    - When `splits` includes `'week'`: drop `output` silently when `aggregation === 'rate'` and `period === 'game'` (preserving today's silent-drop behavior around `libs-server/get-data-view-results.mjs:1451`); throw a validation error when `aggregation === 'count'` and `period === 'season'`.
    - Emit debug-level log when translation or silent-drop occurs.

- [ ] **Pipeline integration in the main orchestrator**
  - File: `libs-server/get-data-view-results.mjs`
  - Purpose: Route every column through output-param normalization and the new aggregator registry. Update the local `add_clauses_for_table` function (defined in this same file) to read the new options key.
  - Changes:
    - Call `normalize_output_param` for every column after dynamic param resolution, passing the current `splits` so week-split sanitization runs in one place.
    - Replace rate-type CTE discovery and creation loop with an output-aggregator discovery and creation loop populating `data_view_options.output_ctes`.
    - Replace rate-type CTE join loop with output-aggregator join loop.
    - Update the `add_clauses_for_table` local function to read `data_view_options.output_ctes` in place of `rate_type_tables`.
    - Rework the week-split sanitization block around line 1451 to call `normalize_output_param` rather than duplicating the rate-type delete logic.
    - Remove references to `data_view_options.rate_type_tables` except through the compat re-export.

- [ ] **Outer SELECT dispatch**
  - File: `libs-server/data-views/select-string.mjs`
  - Purpose: Replace hardcoded SUM and numerator-denominator expression emission with aggregator registry dispatch when `params.output` is present.
  - Changes:
    - When column params contain `output`, call the resolved aggregator's `emit_outer_select` and use its return value as the outer SELECT.
    - Preserve existing paths for columns without `output` to avoid touching non-measure columns.

- [ ] **Update column definitions that read `rate_type_tables` directly**
  - File: `libs-server/data-views-column-definitions/player-games-played-column-definitions.mjs`
  - File: `libs-server/data-views-column-definitions/player-routes-column-definitions.mjs`
  - File: `libs-server/data-views-column-definitions/player-snaps-column-definitions.mjs`
  - File: `libs-server/data-views-column-definitions/player-team-column-definition.mjs`
  - Purpose: These four column definitions read `data_view_options.rate_type_tables` directly. Without updating them, the rename to `output_ctes` causes runtime errors.
  - Changes:
    - Replace every `data_view_options.rate_type_tables` reference with `data_view_options.output_ctes`.
    - Update any lookup key shape to match the new CTE name hash contract.
    - Add targeted tests in the parity spec covering these columns under both legacy `rate_type` input and native `output` input.

- [ ] **Column definition retrofit for every rate-type column**
  - File: every file under `libs-server/data-views-column-definitions/` that currently declares `supported_rate_types` (6 files, 47 occurrences; `player-stats-from-plays-column-definitions.mjs` alone has 35)
  - Purpose: Add `supports_output_aggregation`, `supports_output`, `measure_source`, `measure_expr`, and optional `measure_predicate`. Keep `supported_rate_types` during the deprecation window.
  - Prerequisite: the parity test scaffolding under "Testing and Validation" must land before this task so each column retrofit can be validated column-by-column rather than big-bang at the end.
  - Changes:
    - For each affected column, add the new flags declaring which periods and aggregations are valid.
    - Extract the measure SQL currently embedded in `main_select` into a `measure_expr` function for reuse by the period-CTE helper.
    - Extract any pre-aggregation WHERE filter into `measure_predicate`.
    - Preserve `team_unit` and `is_team` fields verbatim on the column definition; they flow through the new plugin interface unchanged.
    - Document any column whose `main_select` contains bespoke logic that must be preserved post-retrofit in a migration note on the task.

- [ ] **Legacy rate-type compat re-exports**
  - File: `libs-server/data-views/rate-type/index.mjs`
  - File: `libs-shared/rate-type-column-param.mjs`
  - Purpose: Keep legacy import paths functional for one release cycle.
  - Changes:
    - Replace implementation with thin re-export from the new output-aggregator and output-column-param modules.
    - Add a module-top comment noting the deprecation and follow-up removal task.

### Frontend

- [ ] **Output param editor component**
  - File: `app/views/components/column-param-output/column-param-output.js`
  - File: `app/views/components/column-param-output/column-param-output.styl`
  - File: `app/views/components/column-param-output/index.js`
  - Purpose: Nested param editor replacing the rate-type dropdown.
  - Changes:
    - Render a period dropdown driven by the column definition's `supports_output.periods`.
    - Render an aggregation dropdown driven by `supports_output.aggregations`.
    - Conditionally render a threshold sub-form (operator select plus numeric input) when `aggregation === 'count'`.
    - Emit a single `output` param object into the column config.
    - Register via `column_param_definition.component` on the shared `output` param definition.

- [ ] **Params-aware formatter resolution**
  - File: `app/core/data-views/resolve-column-formatter.mjs`
  - Purpose: Compute the column cell format based on `params.output.aggregation` at column build time.
  - Changes:
    - Return integer format when `aggregation === 'count'`.
    - Return `fixed: 2` when `aggregation === 'rate'`.
    - Return the column definition's default format otherwise.
    - Wire the helper into the column build pipeline in the data views app code so cell renderers receive the resolved format.

- [ ] **Remove rate-type dropdown wiring**
  - File: any `app/` file that today imports `rate-type-column-param.mjs` for direct UI rendering
  - Purpose: Replace rate-type-specific UI plumbing with the shared output param component.
  - Changes:
    - Replace imports with the output column param module.
    - Remove any rate-type-only branches once the output component renders the full semantic surface.

### Data Migration

- [ ] **Shared pure migrator (canonical source of token mapping)**
  - File: `libs-shared/data-views-output-migration.mjs`
  - Purpose: Rewrite `params.rate_type` to `params.output` in saved `table_state` payloads, idempotently. Acts as the single source of truth for the 17-token mapping table consumed by both the saved-view migrator and the server request-time normalizer.
  - Changes:
    - Export `LEGACY_RATE_TYPE_TO_OUTPUT` table covering all 17 legacy tokens (per_game, per_team_play, per_team_pass_play, per_team_rush_play, per_team_half, per_team_quarter, per_team_drive, per_team_series, per_player_rush_attempt, per_player_pass_attempt, per_player_target, per_player_catchable_target, per_player_deep_target, per_player_catchable_deep_target, per_player_reception, per_player_play, per_player_pass_play, per_player_rush_play, per_player_route).
    - Export `migrate_rate_type_to_output(table_state)` returning `{ changed, table_state }` that uses the mapping table.
    - Traverse `columns`, `prefix_columns`, `where`, and `sort` arrays.
    - Rewrite `rate_type_match_column_params` → `output_match_column_params` and `rate_type_column_params` → `output_column_params` wherever they appear.
    - No-op when `params.output` is already present on a given column entry.

- [ ] **Server migration script**
  - File: `scripts/migrate-data-views-output.mjs`
  - Purpose: Apply the shared migrator to persisted saved views in `user_data_views.table_state` and `user_plays_views.table_state`.
  - Changes:
    - Follow the structure of `scripts/migrate-data-views-single-nfl-week.mjs`.
    - Support a dry-run default and an `--apply` flag.
    - Batch updates and log row counts.

- [ ] **Browser storage migration hook**
  - File: `app/core/data-views/browser-storage.mjs`
  - Purpose: Upgrade localStorage snapshots on restore so offline-saved views adopt the new shape without a server round-trip.
  - Changes:
    - Import the shared migrator and compose it into the existing restore pipeline alongside the `nfl_week_id` migrator.

### Documentation

- [ ] **Update data views system reference**
  - File: `docs/data-views-system.md`
  - Purpose: Document the unified output aggregation primitive, registry shape, aggregator plugin contract, period-CTE helper, and the rate-type deprecation alias.
  - Changes:
    - Add an "Output Aggregation" section replacing the rate-type sections or rewrite the rate-type section in terms of the new registry.
    - Document validation rules, param shape, and split interactions.
    - Update pipeline stage descriptions where the rate-type discovery loop is mentioned.

### Knowledge Base Updates

- [ ] **Record design-decision and deprecation observations**
  - Entity: the task entity created by this plan
  - Purpose: Persist the architectural decision and deprecation contract for future sessions.
  - Changes:
    - Add observation `[design-decision] Unified rate-type and threshold-count aggregations under a single output param keyed on (period, aggregation).` on task completion.
    - Add observation `[deprecation] rate_type param deprecated at the API alias level for one release cycle in favor of output.`
    - Add observation `[migration-pattern] Saved-view rate-type to output migration follows the dual-site shape established by nfl_week_id migration.`

- [ ] **Link follow-up removal task**
  - Entity: new task entity for retiring the `rate_type` compat shims after one release cycle
  - Purpose: Ensure the deprecation window has an owner.
  - Changes:
    - Create a follow-up task under `task/league/` titled "Retire rate_type compat shims" with relation `blocked_by [[user:task/league/unify-data-view-output-aggregation.md]]`.

### Testing and Validation

- [ ] **Migration unit tests**
  - File: `test/data-views-output-migration.spec.mjs`
  - Purpose: Verify the pure migrator against representative saved-view fixtures.
  - Changes:
    - Cover every legacy rate-type token to `(period, aggregation)` mapping.
    - Cover idempotency when `params.output` is already present.
    - Cover `where`, `sort`, `columns`, and `prefix_columns` traversal.

- [ ] **Parity audit test scaffolding (lands before column retrofit)**
  - File: `test/data-views-output-parity.spec.mjs`
  - Purpose: Provide a fixture-based SQL-equality harness that lands BEFORE the column retrofit task so each column can be validated in isolation as it is migrated. Also covers the four `rate_type_tables`-reading columns independently of the large stat-from-plays retrofit.
  - Changes:
    - Build a parameterized helper that takes `(column_id, params_legacy, params_native)` and asserts the generated SQL is identical.
    - Seed a small fixture covering one representative column per retrofit surface (stats-from-plays, fantasy-points, games-played, routes, snaps, team).
    - Integrate with the existing test harness (`test/global.mjs` setup pattern) so the specs run without a full DB execution cycle where possible.

- [ ] **Output aggregator integration tests**
  - File: `test/data-views-output.spec.mjs`
  - Purpose: Validate end-to-end query generation for each aggregator plus split combinations.
  - Changes:
    - Assert CTE structure and outer SELECT emission for time-grain entries (`game:rate`, `season:rate`, `game:count`, `season:count`) and a representative subset of denominator-unit rate entries (at least one team-unit entry and one player-unit entry).
    - Assert the count aggregator emits `COUNT(DISTINCT period_key) FILTER (WHERE measure_total op threshold)` against the CTE join, not a pre-aggregation filter.
    - Assert year pushdown pushes `effective_years` into every source table.
    - Assert `withMaterialized` registration.
    - Assert the CTE name hash differentiates columns with different `team_unit`, `is_team`, or `measure_source` values.
    - Assert week-split interactions: silent drop for `rate`+`game`, hard reject for `count`+`season`, and debug-level log emission on the silent-drop path.
    - Assert rejection of invalid combinations (threshold without count, count without threshold, denominator-unit period with count aggregation).

- [ ] **Parity audit per retrofitted column**
  - File: extend `test/data-views-output-parity.spec.mjs`
  - Purpose: Confirm every column that declared `supported_rate_types` produces equivalent SQL under the migrated `output` configuration.
  - Changes:
    - For each retrofitted column, add a test that builds the same query twice — once with legacy `rate_type` input (which normalizes to `output`) and once with native `output` input — and asserts SQL equality.

- [ ] **Update existing rate-type-based tests**
  - File: every existing `test/*data-view*.spec.mjs` or similar that asserts on `rate_type`
  - Purpose: Update assertions to reflect the canonical `output` shape while preserving test intent.
  - Changes:
    - Rewrite fixture inputs to use `output`.
    - Keep one representative legacy-input test per migrated surface to exercise the compat normalizer.
