---
title: Conform division finish to the canonical seeding ladder
type: task
status: completed
description: >-
  calculate-standings sorts each division on points for above raw all-play wins, inverting the
  constitution Article V Divisions subsection 5 and disagreeing with compare_playoff_seed; delete
  the hand-rolled comparator and sort flat stat objects on the canonical ladder.
base_uri: user:task/league/conform-division-finish-to-the-seeding-ladder.md
created_at: '2026-08-30T16:40:37.351Z'
entity_id: 0fd52b33-30a5-4ebd-b15b-8b23463f7dbb
finished_at: '2026-10-24T03:44:12.969Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - follows [[user:task/league/correct-the-season-forecast-model.md]]
  - >-
    relates
    [[██████████████-███████-█████████████-████████████████████████████████████████████-██-████████-█████████]]
  - follows [[███████████████████████████████-█████████████]]
started_at: '2026-10-23T23:08:29.103Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-10-24T03:44:12.969Z'
visibility_analyzed_at: '2026-11-05T23:48:36.076Z'
---

## Overview

Make `division_finish` use the league's canonical seeding ladder, so the standings calculation stops carrying its own hand-rolled comparator that disagrees with the constitution.

**Impact.** `division_finish` is persisted on `league_team_seasonlogs` and drives `careerlog.division_wins` at `scripts/calculate-league-careerlogs.mjs:181-182`, so a wrong division order becomes a wrong permanent career record. The disagreement only bites when two teams in a division tie on record and their all-play and points-for disagree — rare per season, permanent when it happens.

**Ordering.** Split out of `[[user:task/league/correct-the-season-forecast-model.md]]` on 2026-08-30. Follows it only so this task consumes the shared all-play module that task extracts, rather than reintroducing a second implementation of the same rule. If that task slips, this one can ship first and adopt the shared module afterward.

## Background

### The constitutional rule

Article V, Divisions, subsection 5, consolidated at `constitution.md:728` and introduced by Amendment XL, reads verbatim:

> A Division winner is the Team in a Division with the highest Head-to-Head record over the Qualifying Season, counting every matchup it played and not only those against its Divisional opponents; in the event that any Teams should be tied in respect of Head-to-Head record, the tie breaker shall be the higher All Play win percentage over the Qualifying Season, and if still tied, the higher Total Points For (PF) over the Qualifying Season.

Two clauses matter for implementation. The record is **overall**, not intra-divisional — so `regular_season_wins/losses/ties` are the right fields. And all-play ranks **above** points for.

The amendment's own risk note flags the overall-record reading as a drafter choice never operator-ratified. It is implemented as written; a spec author must not assume intra-divisional.

### The defect

`libs-shared/calculate-standings.mjs:242-266` sorts each division by wins, losses, ties, then **points for, then raw `all_play_wins`**. That inverts the last two keys against Article V, Divisions, subsection 5, and it uses raw all-play wins where the constitution and `compare-all-play-seed.mjs:25` both state a percentage.

The raw-versus-percentage half is not cosmetic. `get_all_play_win_percentage` counts ties as half a win, so 5-5-0 and 4-2-4 over ten games order differently by percentage than by raw wins.

`libs-shared/compare-playoff-seed.mjs:27-32` is the module's canonical ladder — head-to-head, then all-play percentage, then points for — and agrees with Article V, Divisions, subsection 5 exactly. It is already used for the whole field through `get_playoff_seeding`.

### The trap

`calculate-standings.mjs:242` sorts `div_teams`, which is an array of **team ids** built at `:234-238`, reaching stats via `teamStats[tid].stats`. `compare_playoff_seed` takes flat stat objects. A literal `div_teams.sort(compare_playoff_seed)` compares raw integers, every term reads `undefined`, and the comparator returns 0 for every pair — leaving insertion order. Verified by execution: `compare_playoff_seed(3, 1)` returns `0`.

`flat_teams`, the only place in the module where stats are flattened into the shape the comparator reads, is built at `:280-284` — after the division block. The fix is to hoist that construction above the division loop and sort objects, not ids.

### Assumptions

- **`compare_playoff_seed` reads fields that exist on the standings objects.** Checked: `regular_season_wins/losses/ties`, `all_play_wins/losses/ties` and `points_for` all live on `teamStats[tid].stats` and are spread into `flat_teams` at `:284`. The defect is the container, not the keys.
- **A literal comparator swap is a silent no-op rather than an error.** Checked by executing `compare_playoff_seed(3, 1)`, which returns 0, and `[3,1,2].sort(compare_playoff_seed)`, which returns `[3,1,2]` unchanged.
- **`test/calculate-standings.spec.mjs` has no `division_finish` assertion today.** Checked: `grep -n division test/calculate-standings.spec.mjs` returns only `has_division_winner_berths` at `:21` and the fixture at `:50-53`. The comment at `:46-47` says seeding is division-independent; it does not say the spec scopes away from division ordering, though the effect is the same.
- **`division_finish` is fabricated when divisions are absent.** Checked: with all-null divisions, `:233-238` buckets every team under the key `"null"` and assigns 1..10. This task does not fix that; see the follow-up item.

## Design

One ladder, used everywhere. The hand-rolled comparator is deleted rather than corrected, because a second implementation of a constitutional rule is the defect — matching its key order to `compare_playoff_seed` would leave two copies to drift again.

**Hoist, then sort objects.** The `flat_teams` construction moves above the division block, and the division sort operates on those objects. This is the whole of the fix and it is also the whole of the risk: sorting ids silently succeeds.

**The all-play source.** Once `[[user:task/league/correct-the-season-forecast-model.md]]` extracts `calculate_week_all_play_records`, the nested-filter block at `:173-187` should call it rather than keeping a third implementation. If this task ships first, leave that block alone and record the adoption as a follow-up — correcting the comparator does not depend on it.

### Knowledge Base Impact

- No new entity. Article V, Divisions, subsection 5 is the source of truth and is already reachable through Amendment XL.
- `docs/glossary.md` may need the All Play and division-winner vocabulary if it does not carry it.

## Notes

- Use `yarn test:isolated`, never `yarn test:db:up && yarn test`.
- The RED-first confirmation is the point of this task, not a formality. A fixture whose insertion order happens to match the correct answer produces a green test against a broken comparator.

## Plan

### Core Implementation

- [x] **Hoist the flat stat construction above the division block**
  - File: `libs-shared/calculate-standings.mjs`
  - Purpose: the division sort needs objects carrying the stat keys, and the only such construction currently sits below it at `:280-284`.
  - Changes: move the `flat_teams` build (currently `:280-284`) above the division loop at `:232`; leave the later `get_playoff_seeding` call reading the same array. Everything `compare_playoff_seed` reads is complete by `:188`, and the derived-stat loop at `:203-230` also precedes `:232`, so the hoist is safe.
  - Shape change to note: `flat_teams` spreads `...p.stats` AFTER `division_finish` is assigned at `:272`, so today those objects carry `division_finish` and after the hoist they will not. Nothing reads it off them today — `get-playoff-seeding.mjs:131-144` derives division winners from the `division` key and `by_record` order — but the omission is invisible, so say it in a comment rather than relying on the spec to notice.
  - Verify: `yarn test:isolated test/calculate-standings.spec.mjs` passes unchanged, confirming the move alone changes no output.

- [x] **Replace the hand-rolled division comparator**
  - File: `libs-shared/calculate-standings.mjs`
  - Purpose: `division_finish` must use `compare_playoff_seed`, matching the module's own ladder and Article V, Divisions, subsection 5. The comparator at `:242-266` orders points for above all-play and uses raw all-play wins rather than the percentage.
  - Changes: delete the inline comparator; sort each division's flat stat objects with `compare_playoff_seed`; assign `division_finish` from the resulting order.
  - Verify: the two new spec cases below, both confirmed RED first.

### Testing & Validation

- [x] **Key-order case: all-play outranks points for**
  - File: `test/calculate-standings.spec.mjs`
  - Purpose: this is the Article V, Divisions, subsection 5 disagreement.
  - Changes: two teams in one division tied on wins, losses and ties, where one leads on all-play percentage and the other leads on points for. Assert the all-play leader takes `division_finish: 1`. Construct the fixture so the WRONG answer is also the insertion order, so a no-op sort fails the case.
  - Verify: confirm RED against the current comparator before the change, and confirm RED against a literal `div_teams.sort(compare_playoff_seed)` on ids.

- [x] **Percentage-versus-raw case**
  - File: `test/calculate-standings.spec.mjs`
  - Purpose: the comparator's other half. Every team plays the same number of all-play games, so raw wins and percentage can disagree ONLY when all-play ties are present, since a tie counts as half a win.
  - The target case, verified by execution: 5-5-0 (percentage .500) versus 4-2-4 (percentage .600). Raw wins favour the first, percentage the second, and `compare_playoff_seed` returns +0.1 — so the 4-2-4 team takes `division_finish: 1`.
  - Fixture geometry, which the existing spec cannot supply: all-play records are DERIVED inside `calculate-standings.mjs:173-187` from weekly scores, not assignable as numbers. Each team plays `num_teams - 1` all-play games per week, so ten all-play games requires 6 teams over 2 weeks or 11 teams over 1 week. The current fixture is 4 teams over 1 week (`test/calculate-standings.spec.mjs:49-53`, with the clock pinned at `:16` so `final_week` is 1), giving 3 all-play games per team — the case is not expressible in it.
  - Changes: build a NEW fixture of at least 6 teams over at least 2 weeks. The four all-play ties must be produced by engineering exactly equal weekly scores through gamelogs, since fractional scoring makes a natural tie essentially impossible. State the intended per-week score matrix in a comment so a later reader can see which pairs are meant to tie.
  - Assert the 4-2-4 team takes `division_finish: 1`.
  - Verify: confirm RED against the current comparator, which reads `all_play_wins` directly.

- [x] **Full suite and gates**
  - Verify: `yarn test:isolated` green; `yarn lint`; `yarn check:types`; `npx prettier --check` on the touched files only.

### Knowledge Base Updates

- [x] **Record the phantom-division-finish follow-up**
  - Purpose: with all-null divisions every team buckets under one key and receives `division_finish` 1..10, and `scripts/calculate-league-careerlogs.mjs:181-182` then credits a division title to whoever holds 1. Correcting the comparator does not address a division that does not exist.
  - Changes: record it as an observation on this task, or as an item on `[[████████████████████████-████████-██████-████████████]]`.
  - Verify: `base entity get` shows the observation or the item.

- [x] **Adopt the shared all-play module if it exists**
  - File: `libs-shared/calculate-standings.mjs`
  - Purpose: `:173-187` computes all-play with three nested filters per team per week. If the forecast task has landed `calculate_week_all_play_records`, this block should call it rather than remaining a third implementation.
  - Changes: conditional on that module existing; otherwise skip and leave the follow-up recorded.
  - Verify: `yarn test:isolated test/calculate-standings.spec.mjs` passes unchanged, confirming identical output on existing fixtures. If the module does not exist, mark this item skipped and say so in the commit.
