---
title: Read negative from-plays fantasy points instead of dropping the player
type: task
status: completed
description: >-
  build_role_union_period_cte filters havingRaw(SUM(role_union.pts) > 0), so a player whose net
  from-plays fantasy points are zero or negative is dropped from the result rather than read as
  negative. A format with turnover penalties produces genuinely negative games, and the gamelog path
  reads them. Fixing it moves data-view goldens.
base_uri: user:task/league/read-negative-from-plays-fantasy-points-instead-of-dropping-the-player.md
created_at: '2026-08-05T19:26:37.202Z'
entity_id: 49ae5497-c53c-4218-ae37-2bd3e368e3f4
finished_at: '2026-10-29T06:28:44.029Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[████████████████████-█████-████-████-████-███████-█████████]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-10-29T06:28:44.029Z'
visibility_analyzed_at: '2026-11-05T23:49:53.998Z'
---

# Read negative from-plays fantasy points instead of dropping the player

`build_role_union_period_cte` in `libs-server/data-views/output-aggregator/build-period-cte.mjs` ends its outer aggregate with `havingRaw('SUM(role_union.pts) > 0')`. A player whose net from-plays fantasy points for the period are zero or negative therefore produces no row at all, rather than a row reading the negative value.

Found 2026-08-05 while reading the builder for the SFB16 per-game grain stage. It is unrelated to that work and is carved out for that reason.

## Why it is wrong

A negative fantasy-point total is a real outcome, not an error state. Interceptions score `-1` in most formats and `-2` in some; fumbles lost score `-1` to `-2`. A quarterback with three interceptions and under 100 passing yards is genuinely negative for the game, and today the from-plays path reports him as absent.

The two paths disagree as a result: the gamelog path reads the negative value, the from-plays path drops the row. That is the same class of silent two-path divergence that the parity spec and the term table exist to eliminate.

Absence and zero are also not distinguishable to a consumer. A player with a genuinely zero game and a player who did not play both come back as no row.

## Why it is not a drive-by

Removing or relaxing the predicate changes the row SET of every role-union data-view query, so it will move data-view goldens — including result-equivalence fixtures, whose `expected_rows` are hand-written and cannot be regenerated. It needs its own verification pass rather than being folded into an unrelated change.

## Open questions to settle at start time

- **Is the predicate load-bearing for anything other than tidiness?** It plausibly exists to keep players with no participation out of the result. If so, the correct replacement is a predicate on participation (the player appeared in the union at all) rather than on the sign of the measure — those coincide for every format with no negative terms, which is why this has survived.
- **What does the `with` builder do at the same point?** Check whether it carries a matching filter. If it does not, the two builders already disagree and the fix should make them agree; if it does, both move together.
- **Which goldens move, and does any result-equivalence fixture need hand-correcting?** Regeneration only rewrites `expected_query`.

## Plan

- [ ] **Establish the blast radius before changing anything**
  - Measure against production: how many player-periods would enter the result under a relaxed predicate, for a format with turnover penalties and for one without.
  - Confirm whether the `with` builder filters equivalently.

- [ ] **Replace the sign predicate with a participation predicate**
  - Verify: a seeded player with a genuinely negative period reads negative rather than being absent, and a player with no participation is still absent.

- [ ] **Regenerate goldens deliberately**
  - Verify: every moved golden is explained by the row-set change and nothing else. Result-equivalence `expected_rows` are hand-written — check them rather than regenerating.
