---
title: Price Both Points Added Variants
type: task
status: completed
description: >-
  Priced the signed net points-added variant everywhere the positive-only one was priced, and moved
  the pricing denominator inside calculate-prices.mjs as the sum of positive parts — a raw-total
  denominator INVERTS the board rather than zeroing it. Shipped and deployed 2026-08-04; the period
  split of the format projection-values table moved to ██████████████████████████████████.
base_uri: user:task/league/price-both-points-added-variants.md
created_at: '2026-08-03T22:48:30.775Z'
entity_id: d4745c1e-ba06-4375-b3c6-886ad896842d
finished_at: '2026-08-04T06:05:57.030Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: High
public_read: true
relations:
  - relates [[████████████████████████-███████-█████-████████████]]
  - relates [[███████████████████████-█████-█████████-████████]]
  - follows [[████████████████████████████████-█████-████████████]]
  - relates [[user:task/league/surface-projected-points-added-net.md]]
  - relates [[███████████████████████████-████-██-██████-███████████]]
started_at: '2026-08-04T02:47:34.815Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-10-26T05:00:35.138Z'
visibility_analyzed_at: '2026-11-05T23:49:50.486Z'
---

# Price Both Points Added Variants

**Completed and deployed 2026-08-04** on `e2437eca8`. Both variants now carry cap dollars everywhere the positive
one did. § Overview and § Background below describe the PRE-change world and are kept as the problem statement;
§ Current State carries what actually shipped, and the period split moved to
[[███████████████████████████-████-██-██████-███████████]].

## Overview

The points added pipeline computes two aggregates at three independent sites — a positive-only variant and a signed
net variant — and prices only the positive one at every site. The net variant is persisted and never converted to
cap dollars, so it reaches no consumer that speaks in money.

Operator ruling, 2026-08-03: **both variants ship.** They serve different league formats and carry different
perspectives. The positive-only variant answers "what he adds when you can bench him"; the net variant answers "what
he adds when you must start him every week."

This task prices the net variant everywhere the positive one is priced, and removes the mixed `week` key that made
the omission invisible. It operates entirely on the SHIPPED projection pipeline and depends on no model.

## Background

### The shape occurs three times, independently

| site | aggregates | priced |
| --- | --- | --- |
| `scripts/calculate-points-added.mjs:176-193` | `pts_added.earned`, `pts_added.earned_net` | `earned` only |
| `libs-shared/calculate-player-values-rest-of-season.mjs:26-46` | `pts_added.ros`, `pts_added.ros_net` | `ros` only |
| `scripts/generate-league-format-player-seasonlogs.mjs` | `points_added_earned`, `points_added_net` | `earned` only |

Each accumulates its pricing denominator inside a `value > 0` branch and calls `calculatePrices` with the positive
key. Three writers made the same choice independently, which is the signature of a missing abstraction rather than
three bugs.

Live confirmation: `league_format_player_projection_values` holds 22,620 `week = 'ros_net'` rows with `pts_added`
populated and `market_salary` count **zero**. `league_format_player_seasonlogs` carries `points_added_net` with no
rank columns and no salary, against five derived columns plus `earned_salary` on the earned side.

### The denominator invariant — the reason this is not a two-line change

`calculatePrices` computes `rate = cap / total_pts_added`. Measured on live 2026 data for `genesis_10_team`,
rest-of-season:

| aggregate | Σ over all players | Σ of positive parts |
| --- | --- | --- |
| `ros` | 3,230.7 | 3,230.7 |
| `ros_net` | **−41,841.3** | 3,114.5 |

Passing the net total as the denominator yields a **negative rate**. Every price then computes negative and hits
the existing floor, so the entire net board prices at `$0` — silently, with no error and no failing test. The same
holds for `half_ppr_10_team` (−42,015.5) and `draftkings_classic` (−92,618.6).

**The invariant: `denominator = Σ_players max(aggregate_i, 0)`.** For every aggregate priced today this is a no-op,
because those aggregates are already non-negative — which is exactly why no existing code states it and no test
covers it. It is the one rule that unifies all three sites.

Note `Σ max(net_i, 0)` = 3,114.5 is NOT equal to the `ros` total of 3,230.7. A player can carry a positive
positive-only aggregate and a negative net aggregate; he belongs in the first denominator and not the second. The
two variants are genuinely different quantities, not a rescale of one another.

### Where the variant belongs — the repo has already answered this

`league_format_player_projection_values.week` is a mixed key holding numeric weeks `1..18`, `0` (the season board),
`ros`, and `ros_net`. Two different axes — period and variant — share one column.

The league side already fixed this. `scripts/process-projections.mjs:501-505` records the migration in place:

> The period sentinels are no longer written into the week column. This loop was period-blind by construction ...
> which is exactly what let the sentinels accumulate. Each period now routes to its own table.

`league_player_projection_values`, `league_player_season_projection_values` and
`league_player_rest_of_season_projection_values` are the result. The FORMAT side never followed.

So the end state is not a new `week` value and not a discriminator column. It is the split the league side already
shipped, with the variant as a named column pair — mirroring `league_format_player_seasonlogs`, which already names
`points_added_earned` beside `points_added_net`.

## Design

### One pricing entry point, one denominator rule

`calculatePrices` needs no signature change: its `week` parameter is already an aggregation key rather than a week,
since it accepts `'earned'` and `'ros'` alongside numeric weeks. What changes is that the denominator is derived
rather than passed in by each caller.

Move denominator derivation into the pricing module: given the players and the aggregate key, compute
`Σ max(aggregate_i, 0)`. Every current caller's hand-accumulated total is then deleted, which removes the branch
where the omission lives at all three sites. Rename the parameter to say what it is.

A caller cannot then price a variant with the wrong denominator, and cannot add a variant that is silently unpriced,
because pricing takes the aggregate key and derives everything else.

### The season board's net variant is the same draws without the floor

`libs-shared/calculate-distributional-baselines.mjs` draws 1,000 seasons and accumulates
`E[max(X − baseline, 0)]`. The net variant is `E[X − baseline]` — the same loop, without the per-draw floor,
accumulated into a second total. It costs one addition per draw and no new draws.

Do NOT compute it as `max(E[X] − baseline, 0)` or as a transform of the floored figure. That module's header
documents why the positive part must be taken per draw; the net variant must be accumulated per draw for the
mirror-image reason.

### Schema

Split `league_format_player_projection_values` by period, matching the league-side tables:

- `league_format_player_projection_values` — weekly, keyed `(pid, league_format_id, year, week)` with `week` now
  strictly numeric
- `league_format_player_season_projection_values` — the season board
- `league_format_player_rest_of_season_projection_values` — rest of season

The rest-of-season and season tables each carry a named pair rather than a variant key:
`points_added_positive` / `points_added_positive_cap_dollars` and `points_added_net` / `points_added_net_cap_dollars`.
Every cap-dollar column ends in `_cap_dollars` so no reader infers a unit.

**Naming corrected 2026-08-04.** This section originally said `points_added_earned`. That is wrong:
[[user:task/league/surface-projected-points-added-net.md]] settled the vocabulary at its line 85, and `earned`
correlates with *realized* while actually meaning *positive-only*, so it carries two axes in one token. The axes are
realized-vs-projected and positive-vs-net, and they must not collapse. `positive` states the rule literally;
`gross` was rejected as worse than `earned` (ordinarily "net" means *after filtering*, but here net is the variant
that INCLUDES everything, so `gross`/`net` reads backwards) and `absolute` rejected outright (reads as `abs()`,
which flips negatives rather than dropping them). Shipped gamelog/seasonlog/careerlog columns stay `_earned`
because renaming them is out of scope on that task, so the schema carries two vocabularies until that sweep runs.

`league_format_player_seasonlogs` gains the missing net columns on the realized side: `points_added_net_cap_dollars`
plus the rank columns the earned side already has, so the two variants are symmetric.

The `_history` companion table follows the same split.

**No compatibility view, no dual-write window, no `week`-key shim.** One cutover.

### Not in scope, and named so they are not rediscovered

- **Retyping `numeric(6,2)` dollar columns to integer.** Both `market_salary` and `earned_salary` hold integers in a
  decimal type. The honest end state is `integer`, and it carries its own consumer sweep and ███████████████████
  exposure. Separate entity.
- **The four `is_superflex: true` hardcodes** in `grade-trades.mjs` and `compute-snapshots-bulk.mjs` — separate entity.
- **Feeding a FORECAST from a production model through this conversion** — that is stage two of
  [[████████████████████████-███████-█████-████████████]] and needs a model that does not exist.

### Knowledge Base Impact

- [[████████████████████████████████-█████-████████████]] — add the denominator invariant as a binding rule. Its
  `surface_on` list already covers `calculate-prices.mjs`, `calculate-player-values-rest-of-season.mjs`,
  `calculate-distributional-baselines.mjs` and `process-projections*.mjs`, so the rule surfaces on exactly the files
  where the mistake gets made. Add `calculate-values.mjs` is already listed; add the seasonlog generator
- [[███████████████████████-█████-█████████-████████]] — the facts doc. State that the model publishes two variants,
  what each answers, and that they are not a rescale of one another
- `text/league/player-valuation-system.md` — § "Cumulative Points Added -- Earned vs Net" describes the
  variants and predates their pricing; § "Market Salary" and § "Data-View Surface" both need the new tables
- `repository/active/league/CLAUDE.md` — the mixed-`week`-key trap is worth recording alongside the existing
  period-sentinel note, since this task is what closes it
- Data-view field definitions in `app/core/data-views-fields/league-format-logs-table-fields.js` gain the net
  salary and rank fields

### Working tier

No new scratch slug. Denominator measurements and the before/after board comparison the valuation guideline
requires go to `scratch/league/dynasty-value/`, which exists and is owned by the rebuild task.

### Orchestrator shape

Single task entity. Three code sites, one DDL, one consumer sweep — bounded well below the orchestrator threshold,
and it sits under [[user:orchestrator/league-xo-football/league-xo-football.md]] with its three siblings.

## Current State (2026-08-04)

The pricing arithmetic is **complete, committed and PUSHED** as `dd48ce077` — confirmed on `origin/master` by
`git ls-remote`, so the guards below are already the only thing protecting production — 10 files plus
`test/libs-shared.calculate-prices.spec.mjs`. It is handed to the live session
`█████████████████████████████████████████`.

It was made safe to land alone by guarding both writers, since falling through would put `'ros_net'` into
`league_player_projection_values.week`, a `varchar(3)`, and that table is written delete-by-lid THEN `batch_insert` —
the delete commits, the insert throws, and the table is left EMPTY rather than stale. The league-side guard is now
retired by the routing in `584967e8d`; the two FORMAT-side guards stay until the redesign task builds those tables.

The zero-denominator guard has a **live production justification**, not just a fixture. On 2026-08-04 the season
board went to `$0` for every player on 22 of 23 formats: `get_player_projections` defaults `week` to
`current_season.nfl_seas_week`, which flipped 0 → 1 overnight, but `projections_index` stores FANTASY weeks where
`0` IS the season row — so the floor amputated every week-0 source row and the consensus wrote NULL. All 642 players
took the `-999` default, the denominator went to zero, and `rate = cap / 0 = Infinity`. `Math.round(Infinity * -999)`
is `-Infinity`, which is TRUTHY, so the `|| 0` fallback did not catch it and `Math.max(-Infinity, 0)` wrote a
non-null `0.00` on every row. Nothing complained. The guard turns exactly that into an explicitly unpriced board.

Note the shape change: those rows write NULL rather than `0.00` once the guard is in. That is the honest answer, but
it is a live change for any consumer rendering week-0 `market_salary` without a `?? 0`.

Two findings changed the plan:

- **The recorded failure mode was wrong.** A raw-total denominator does not floor the board to `$0`; it INVERTS it,
  because a negative rate times a negative aggregate is positive. Above-replacement players floor to `$0` while
  below-replacement players carry salaries. Guideline corrected.
- **Pricing moved into `calculate_projection_values`.** All four projection callers were calling `calculatePrices`
  immediately after it with the same week — the same open-coded pairing `get_discretionary_cap` was consolidated to
  remove. That deletes four duplicate call sites rather than updating them.

### Both rulings landed 2026-08-04 — this section supersedes the naming in § Design above

**1. Naming: `points_added_positive` / `points_added_net`.** Every points-added column names its variant explicitly
and never implies `positive` by absence; `earned` retires as a variant token and `pts_added` becomes `points_added`.

This task had argued the opposite — that `earned`/`net` already means the variant consistently across ~30 shipped
columns, with the realized axis carried separately by the `realized_` / `projected_` prefix on
`roster_asset_holding`. That argument was rejected, and the fact that settles it is not about connotation:
[[███████████████████████████-████-██-██████-███████████]] planned to rename the per-week `pts_added` to
`pts_added_earned` on a column that is **signed**, justifying the absence of a separate net column on the grounds
that the two would "carry identical values" — identical precisely because the column already IS the net. The
positive-only token was about to be attached to a net quantity. There is a shipped instance too: the data-view field
`player_week_projected_points_added` reads that signed column under a name implying a floored one.

So § Design's `points_added_earned` is **superseded**; every table there takes `_positive` / `_net`.

**2. `██████████████████████████████████` owns the period split AND the vocabulary rename.** This task does minimum
routing only — send the net aggregates to period tables that already exist, and touch neither the sentinel migration
nor the `week` narrowing. Nothing shipped gets renamed here; any NEW column lands `_positive` / `_net` from birth.

That task's scope dwarfs this one: five tables, 81,227 sentinel rows, `league_format_player_projection_values_history`
at `varchar(10)`, and a 2026-07-30 ruling that the sentinels must MIGRATE rather than be deleted, because
`get-players.mjs` assigns by dynamic week key and `trade-player.js` renders in-season — so dropping a payload key
blanks a surface with no SQL error.

### Minimum routing — DONE, `584967e8d`

`league_player_season_projection_values` and `league_player_rest_of_season_projection_values` each gained
`salary_adj_points_added_net` (`db/adhoc/2026-08-04-add-net-salary-adj-to-league-period-projection-values.sql`,
applied), and `process-projections.mjs` now routes both variants there.

Both variants land as named columns on ONE row per period, not as two rows. Pushing the net variant as its own row
collides with the positive one on `(pid, lid, season_year)` inside the same batch, and `onConflict(...).merge()`
raises "ON CONFLICT DO UPDATE command cannot affect row a second time" for a duplicate within a single INSERT — it
does not merge them. So the period keys are read directly and the weekly loop now excludes all four.

The guard that protected `league_player_projection_values.week` is therefore gone, replaced by an explicit route:
the net keys still never reach the `varchar(3)` column, structurally rather than by a skip. **The two FORMAT-side
guards stay** — `process-projections.mjs:330` and `process-projections-for-league-format.mjs:82` skip
`season_net_projection_key`, because the format side has no period tables until the redesign task builds them.

Deliberately left: `salary_adj_pts_added` sits beside `salary_adj_points_added_net` on both tables, a mixed
vocabulary that reads as two quantities rather than two variants of one. That rename rides the redesign cutover.

## Plan

### Pricing arithmetic

- [x] **Derive the pricing denominator inside `libs-shared/calculate-prices.mjs`** — done 2026-08-04, `dd48ce077`
  - Purpose: the denominator is the defect. Three callers hand-accumulate it inside a `value > 0` branch, which is
    both the bug and the thing that hides it
  - Compute `Σ max(aggregate_i, 0)` over the players being priced, from the aggregate key. Delete the
    `total_pts_added` parameter and every caller's accumulator
  - Rename the `week` parameter to name the aggregation key it actually is
  - Do NOT introduce a constant of any kind — [[████████████████████████████████-█████-████████████]] forbids a fitted
    parameter here and the deleted `surplus_cap_share` comment records why
  - Verify: a fixture whose net aggregate sums negative across the population prices strictly positive for every
    player with a positive aggregate, and `$0` for the rest. Assert the fixture FAILS against the pre-change
    denominator — a control that cannot fail proves nothing
  - Verify: every currently-priced aggregate is byte-identical, since `Σ max(x,0) = Σ x` where `x ≥ 0`

- [x] **Price the net variant at all three producers** — done 2026-08-04, `dd48ce077`
  - `scripts/calculate-points-added.mjs`, `libs-shared/calculate-player-values-rest-of-season.mjs`,
    `scripts/generate-league-format-player-seasonlogs.mjs`
  - Verify: `market_salary` / `earned_salary` for the positive variant is unchanged at every site, to the cent, and
    the net variant is non-null wherever its aggregate is positive

- [x] **Accumulate the season board's net variant in the existing draw loop** — done 2026-08-04, `dd48ce077`
  - `libs-shared/calculate-distributional-baselines.mjs`. Second accumulator, no per-draw floor, same draws
  - Verify: net expectation is ≤ floored expectation for every player, with equality only where the player never
    drew below replacement; and the draw count is unchanged, proving no second pass was added

### Schema

- [x] **Route the net variant to the hosted-league period tables** — done 2026-08-04, `584967e8d`
  - `db/adhoc/2026-08-04-add-net-salary-adj-to-league-period-projection-values.sql`, applied.
    `league_player_season_projection_values` and `league_player_rest_of_season_projection_values` each gained
    `salary_adj_points_added_net numeric(5,2)`
  - Ratchet green (559 baselined, exit 0); suite 2947 passing in an isolated database

- [x] **Add the net columns `league_format_player_seasonlogs`' generator already writes** — done 2026-08-04,
  `80aaba6b4`
  - `dd48ce077` was PUSHED writing five columns that did not exist, so the generator would have thrown on insert the
    moment it deployed and taken the stats pipeline with it. The suite could not see it: `test/global.mjs` loads
    `db/schema.postgres.sql`, which lacked them too, and no spec runs that generator
  - Verified by dry-running against production: 689 seasonlogs, 689 earned / 689 net priced

**Splitting `league_format_player_projection_values` by period is NOT this task's work.** Operator ruling
2026-08-04 gave it to [[███████████████████████████-████-██-██████-███████████]] along with the `_history`
companion, the `week` narrowing, the sentinel migration and the `_positive` vocabulary rename. Both carry-forward
constraints below are recorded as observations on that task.

- Both variants must land as named columns on ONE row per period. Pushing the net variant as its own row collides
  with the positive one inside the same batch, and `onConflict(...).merge()` raises "ON CONFLICT DO UPDATE command
  cannot affect row a second time" rather than merging — the format side will hit the same shape.
- The two FORMAT-side guards (`process-projections.mjs:330`, `process-projections-for-league-format.mjs:82`) skip
  `season_net_projection_key` and must come out in the same change that gives it a column.

### Validation

- [x] **Run the valuation guideline's pre-deploy gate** — run POST-hoc 2026-08-04,
  `scratch/league/dynasty-value/both-variants-pricing-gate.md`
  - It could not be a pre-deploy gate: `dd48ce077` was already in production, carried there by a sibling's deploy
    of `b72d26333`. Operator directed proceed-and-deploy with that known
  - Both boards shown, rest-of-season grain, `genesis_10_team`: 119 vs 87 priced, same cap spent, top-24 share
    57.8 vs 59.8 percent, positional shares within a point
  - NOT done, and deliberately recorded as such: the multi-season point-in-time backtest. What ran is a
    single-season, single-format cross-section. The positive board was also not A/B'd against a pre-change run,
    since the change was live before the gate started — the evidence it is unchanged is the spec plus
    `Σ max(x,0) = Σ x` for `x ≥ 0`

- [x] **Specs** — `test/libs-shared.calculate-prices.spec.mjs`, 7 passing, fails at HEAD
  - `test/libs-shared.calculate-prices.spec.mjs` covering the negative-net-total case explicitly
  - Verify: the new spec fails at HEAD in a clean worktree before it passes in this tree

### Knowledge base

- [x] **Add the denominator invariant to [[████████████████████████████████-█████-████████████]]** — done 2026-08-04
  - The rule, the measured negative totals, and a `[correction]` observation replacing the wrong failure mode: a
    raw-total denominator INVERTS the board rather than flooring it to `$0`

- [x] **Update [[███████████████████████-█████-█████████-████████]] and `text/league/player-valuation-system.md`**
  — done 2026-08-04, `e978bad16`
  - `player-valuation-system.md` had asserted the opposite of the truth in bold ("Only the earned variant is priced")
  - Both now record the positive/net vocabulary ruling and that the shipped `_earned` columns await the redesign
    task's cutover

- [x] **Expose the new seasonlog net columns in the data-view picker** — done 2026-08-04, `6720223e2`
  - Five fields across the column definition, the shared field index, and
    `app/core/data-views-fields/league-format-logs-table-fields.js`; the four ranks carry `reverse_percentiles`
    like their positive-side counterparts
  - `node db/adhoc/check-data-view-sql-validity.mjs` from a clean worktree at HEAD: GATE OK, 1140 statements
    across 570 columns
  - Confirmed in the deployed bundle by grepping the host's whole `dist/`, against a pre-existing field as a
    positive control — both in `1065.bcacb96b.chunk.js` and `4880.ffed7fa3.chunk.js`
