Adds PlaybackOrder.WeightedShuffle = 9. It picks a source by smooth weighted round-robin, then takes that source's next item, so a source's Weight is its share of airtime. Weights 3:1 emit A A B A (spread, not drained). Fair-share is the equal-weights default, so one order covers both behaviors the issue asked for.
The finding that justifies the issue existing
Recon initially concluded fair-share was already shipped as ShuffleInOrder — which would have collapsed #70 to a docs-only issue (the #77 precedent). That was wrong, and the frontier design pass caught it:
ShuffleInOrder's balanced shuffle (keyj) pads every source to the longest with Option<MediaItem>.None spacers — but spacers emit nothing. One cycle therefore plays every item exactly once, so a 200-episode show still takes 10x the airtime of a 20-episode one. It buys anti-clumping, not fair-share. Plain Shuffle is likewise implicitly weighted by collection size.
That distinction is the entire justification for this feature, and it read the other way round, so it is now written into decisions.mdand the domain-model.md glossary rather than left to be rediscovered.
Design highlights
One enum value, not two. Fair-share = WeightedShuffle with weights left at 1; the UI's "fair-share toggle" needs no second mechanism. A separate non-enum "distribution" setting was rejected (it would add an axis every dispatch site must also consult); retrofitting weights onto ShuffleInOrder was rejected (it would silently change shipped output).
Lands on #380's seam, unchanged. Consumes ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder — the schedule-entity-free entry point that PR #391's decision entry explicitly reserved for "#70 distribution". No signature change, no god-factory, engines stay separate.
Applied where source identity survives.MultiCollectionGrouper collapses sources into GroupedMediaItem + .Distinct(), so on the Shuffle path which source an item came from is gone. Only CollectionWithItems (ShuffleInOrder-shaped path) can carry a per-source weight.
Stateless. Smooth WRR is a pure function of (Seed, Index) — restores by replay like its siblings, no per-source counters (CollectionEnumeratorState has nowhere to put them). Ratios are data, not state: a Reset build re-randomizes the seed but ratios hold.
Mirrored persistence.int Weight on MultiCollectionItemandMultiCollectionSmartItem; omitting the smart mirror un-weights smart-collection members silently. DB default is 1, not 0 — a 0-weight source is filtered out by the enumerator, so a 0 backfill would drop every pre-existing member from a rotation.
The silent-fallback problem, and why validation instead
An unhandled PlaybackOrder fails silently at five of six dispatch sites: Classic substitutes RandomizedMediaCollectionEnumerator; PlaylistEnumerator has no default: arm so the item is dropped from the playlist (and null is legitimate there for SeasonEpisode with Count == 0, so nothing flags it); BlockPlayoutBuilder filters against an allow-list and continues past the rest; YAML/Scripted return None, read downstream as "no content". Only MultiCollectionGroup throws.
A weighted order degrading to unweighted random is the worst case here — the output is supposed to look arbitrary, so nobody would notice.
So this PR closes the exposure at the write path: ReplacePlaylistItems/ReplaceBlockItems reject WeightedShuffle naming where it is available. If it can't be persisted where it isn't handled, the silent sites never see it. YAML/Scripted address orders by name (Enum.Parse accepts it regardless), so they get log warnings on unhandled fallthrough.
Explicit non-goal: making those pre-existing fallbacks loud. Real defect class, wider than this feature, needs its own review — filed as #403.
A bug the non-vacuity control found
Inverting the WRR pick to prove the tests bite didn't fail — it hung. That exposed a real defect in my own code: MoveNext retried the rebuild on wrap to avoid an immediate repeat, unbounded. ShuffleInOrder can do that because its reshuffle randomizes the lead item; this order's lead is decided by weight, so the heaviest source always leads — and when it holds a single item the lead never changes and the retry never terminates. Two single-item collections with unequal weights would have wedged the playout build. Now bounded, with a regression test under a timeout.
Non-vacuity proven: inverting the real WRR condition (max -> min — never if(true), which trips CS0219 under warnings-as-errors and silently serves a stale dll to --no-build) failed 5 of 10 tests on a clean build. Reverted; green.
check:api exit 0 — v1.json/v1.d.ts/endpoint-index regenerated, never hand-edited. weight is emitted optional, so /api/v1 stays additive under the freeze.
Rebased (not merged) onto current main23a615ec and re-verified green.
Deviation from the design pass (reviewer, please weigh in)
The design specified per-source inner order mirroring ShuffleInOrder's OrderItems (chronological). I shuffle within-source instead (custom-ordered collections still honored). Reason: with chronological inner order and deterministic source selection, every rotation would be byte-identical — the reseed on wrap would change nothing and the repeat-avoidance retry could never succeed. "Weighted in order" would be a separate order if wanted. This is my call, not the design authority's, so it is flagged rather than buried.
classic-weighted golden in PlayoutBuildGoldenTests.
Silent dispatch-fallback hardening — filed as #403 (non-goal above).
Auto-tune per-member weights (#385, blocked-by this issue) — needs its own carrier: an auto-tuned channel is backed by one live SmartCollection, so its "sources" are members of a single collection, and a PlaylistItem cannot reference a search query. Nothing here forecloses it.
Docs (same PR, per the repo rule)
decisions.md (new entry, incl. the ShuffleInOrder correction) + domain-model.md (new Playback order row contrasting the three orders; multi-collection row notes the join-entity mirror) + regenerated v1.json/endpoint-index.md.
The cold adversarial review returned BLOCKED. It could not break the algorithm — it probed the stateless-restore claim across rotation wraps (indices 12/13/20/37, all held) and measured the clamp preserving a 1000:1 ratio exactly. What it broke was the perimeter, and it was right on every count.
#
Finding
Fix
B1
The gate had a hole — the silent-drop bug shipped.CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its guard only covered MultiCollection entries, so a ≥2-entry lineup of plain collections persisted WeightedShuffle straight through to PlaylistEnumerator's null-drop. My decisions.md claim that "the silent sites never see it" was false as written.
Rejects WeightedShuffle for a multi-item lineup; allows it for multi collections (M1). 2 regression tests. Claim corrected in decisions.md with the lesson recorded.
B2
Weight had no validation, and create/update disagreed on the same input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not set") but an UPDATE writes the 0 through — and a 0-weight source is filtered out of the rotation, deleting it from the channel silently. The doc reasons carefully about this for the migration backfill and left the API wide open.
Shared MultiCollectionItemWeight (1..1000) validated on both paths so they can't drift; clamped again in the enumerator for rows predating the gate.
B3
Sum(weights) is checked arithmetic → OverflowExceptionfrom inside a playout build. Reachable via the API because of B2.
Ceiling + long sum. Test.
M1/M3/L1/L2
Lineup mirror drifted; ScheduleAsGroup silently ignored; MinimumDuration cached over a rotation that is a strict subset under the clamp; retry guard on the wrong quantity.
All fixed (M3 documented as deliberate).
N1
A test that could not fail — it built entities in C#, so it asserted the property initializer, not the migration's defaultValue, despite a comment claiming otherwise.
Deleted. Replaced with clamp / overflow / cross-wrap restore cases (the property the review proved but found unpinned).
H1
The body claimed follow-ups were "filed". They were not.
The B1 lesson is the one worth carrying: I gated the two obvious writers and recorded the perimeter as complete. The composite handler is the one that gets missed — the same shape as the repo's existing "enumerate every producer" lesson (#235/#267), which I'd read and still walked into.
Closes #70 (slice 1: backend + API, Classic engine only).
Design verdict + rationale: #70's design-verdict comment. Decision record: `docs/decisions.md` 2026-07-17.
## What this does
Adds `PlaybackOrder.WeightedShuffle = 9`. It picks a **source** by smooth weighted round-robin, then takes that source's next item, so a source's `Weight` is its share of airtime. Weights 3:1 emit `A A B A` (spread, not drained). **Fair-share is the equal-weights default**, so one order covers both behaviors the issue asked for.
## The finding that justifies the issue existing
Recon initially concluded fair-share was **already shipped** as `ShuffleInOrder` — which would have collapsed #70 to a docs-only issue (the #77 precedent). That was **wrong**, and the frontier design pass caught it:
`ShuffleInOrder`'s balanced shuffle (keyj) pads every source to the longest with `Option<MediaItem>.None` spacers — but **spacers emit nothing**. One cycle therefore plays **every item exactly once**, so a 200-episode show still takes 10x the airtime of a 20-episode one. It buys **anti-clumping**, not fair-share. Plain `Shuffle` is likewise *implicitly* weighted by collection size.
That distinction is the entire justification for this feature, and it read the other way round, so it is now written into `decisions.md` **and** the `domain-model.md` glossary rather than left to be rediscovered.
## Design highlights
- **One enum value, not two.** Fair-share = `WeightedShuffle` with weights left at 1; the UI's "fair-share toggle" needs no second mechanism. A separate non-enum "distribution" setting was rejected (it would add an axis every dispatch site must *also* consult); retrofitting weights onto `ShuffleInOrder` was rejected (it would silently change shipped output).
- **Lands on #380's seam, unchanged.** Consumes `ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder` — the schedule-entity-free entry point that PR #391's decision entry explicitly reserved for "#70 distribution". No signature change, no god-factory, engines stay separate.
- **Applied where source identity survives.** `MultiCollectionGrouper` collapses sources into `GroupedMediaItem` + `.Distinct()`, so on the `Shuffle` path *which source an item came from* is gone. Only `CollectionWithItems` (ShuffleInOrder-shaped path) can carry a per-source weight.
- **Stateless.** Smooth WRR is a pure function of (`Seed`, `Index`) — restores by replay like its siblings, no per-source counters (`CollectionEnumeratorState` has nowhere to put them). Ratios are data, not state: a `Reset` build re-randomizes the seed but ratios hold.
- **Mirrored persistence.** `int Weight` on `MultiCollectionItem` **and** `MultiCollectionSmartItem`; omitting the smart mirror un-weights smart-collection members silently. DB default is **1, not 0** — a 0-weight source is filtered out by the enumerator, so a 0 backfill would drop every pre-existing member from a rotation.
## The silent-fallback problem, and why validation instead
An unhandled `PlaybackOrder` fails **silently at five of six dispatch sites**: Classic substitutes `RandomizedMediaCollectionEnumerator`; `PlaylistEnumerator` has **no `default:` arm** so the item is dropped from the playlist (and null is *legitimate* there for `SeasonEpisode` with `Count == 0`, so nothing flags it); `BlockPlayoutBuilder` filters against an allow-list and `continue`s past the rest; YAML/Scripted return `None`, read downstream as "no content". Only `MultiCollectionGroup` throws.
**A weighted order degrading to unweighted random is the worst case here — the output is supposed to look arbitrary, so nobody would notice.**
So this PR closes the exposure at the **write path**: `ReplacePlaylistItems`/`ReplaceBlockItems` reject `WeightedShuffle` naming where it *is* available. If it can't be persisted where it isn't handled, the silent sites never see it. YAML/Scripted address orders by *name* (`Enum.Parse` accepts it regardless), so they get log warnings on unhandled fallthrough.
**Explicit non-goal:** making those pre-existing fallbacks loud. Real defect class, wider than this feature, needs its own review — filed as **#403**.
## A bug the non-vacuity control found
Inverting the WRR pick to prove the tests bite didn't fail — it **hung**. That exposed a real defect in my own code: `MoveNext` retried the rebuild on wrap to avoid an immediate repeat, unbounded. `ShuffleInOrder` can do that because its reshuffle randomizes the lead item; this order's lead is decided by **weight**, so the heaviest source always leads — and when it holds a single item the lead never changes and the retry never terminates. Two single-item collections with unequal weights would have **wedged the playout build**. Now bounded, with a regression test under a timeout.
## Verification
- `Build succeeded. 0 Error(s)`, `error CS` count 0.
- **Core.Tests 558 passed**, ErsatzTV.Tests **1641 passed**, Architecture.Tests 5 passed. 0 failed.
- **Non-vacuity proven**: inverting the real WRR condition (max -> min — never `if(true)`, which trips CS0219 under warnings-as-errors and silently serves a stale dll to `--no-build`) failed **5 of 10** tests on a clean build. Reverted; green.
- `check:api` exit 0 — `v1.json`/`v1.d.ts`/`endpoint-index` regenerated, never hand-edited. `weight` is emitted **optional**, so `/api/v1` stays additive under the freeze.
- Rebased (not merged) onto current `main` `23a615ec` and re-verified green.
## Deviation from the design pass (reviewer, please weigh in)
The design specified per-source inner order mirroring `ShuffleInOrder`'s `OrderItems` (chronological). **I shuffle within-source instead** (custom-ordered collections still honored). Reason: with chronological inner order *and* deterministic source selection, every rotation would be byte-identical — the reseed on wrap would change nothing and the repeat-avoidance retry could never succeed. "Weighted **in order**" would be a separate order if wanted. This is my call, not the design authority's, so it is flagged rather than buried.
## Deferred (not in this PR)
- SPA schedule-editor weight inputs + fair-share toggle — filed as **#404** (blocked-by #388).
- Live-E2E of the weight write + playout build.
- `classic-weighted` golden in `PlayoutBuildGoldenTests`.
- Silent dispatch-fallback hardening — filed as **#403** (non-goal above).
- Auto-tune per-member weights (#385, `blocked-by` this issue) — needs its own carrier: an auto-tuned channel is backed by one live SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot reference a search query. Nothing here forecloses it.
## Docs (same PR, per the repo rule)
`decisions.md` (new entry, incl. the ShuffleInOrder correction) + `domain-model.md` (new **Playback order** row contrasting the three orders; multi-collection row notes the join-entity mirror) + regenerated `v1.json`/`endpoint-index.md`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
## Review round 1 — BLOCKED, fixed in `2c0be689`
The cold adversarial review returned **BLOCKED**. It could not break the algorithm — it probed the stateless-restore claim across rotation wraps (indices 12/13/20/37, all held) and measured the clamp preserving a 1000:1 ratio exactly. **What it broke was the perimeter**, and it was right on every count.
| # | Finding | Fix |
|---|---|---|
| **B1** | **The gate had a hole — the silent-drop bug shipped.** `CreateChannelFromLineup` is a **THIRD** writer of `PlaylistItem.PlaybackOrder`; its guard only covered MultiCollection entries, so a ≥2-entry lineup of plain collections persisted `WeightedShuffle` straight through to `PlaylistEnumerator`'s null-drop. **My `decisions.md` claim that "the silent sites never see it" was false as written.** | Rejects `WeightedShuffle` for a multi-item lineup; allows it for multi collections (M1). 2 regression tests. Claim corrected in `decisions.md` with the lesson recorded. |
| **B2** | **`Weight` had no validation, and create/update disagreed on the same input.** EF's `HasDefaultValue(1)` substitutes 1 for a `0` on INSERT (0 reads as "not set") but an UPDATE **writes the 0 through** — and a 0-weight source is filtered out of the rotation, deleting it from the channel silently. The doc reasons carefully about this for the migration backfill and left the API wide open. | Shared `MultiCollectionItemWeight` (1..1000) validated on **both** paths so they can't drift; clamped again in the enumerator for rows predating the gate. |
| **B3** | `Sum(weights)` is checked arithmetic → `OverflowException` **from inside a playout build**. Reachable via the API because of B2. | Ceiling + `long` sum. Test. |
| **M1/M3/L1/L2** | Lineup mirror drifted; `ScheduleAsGroup` silently ignored; `MinimumDuration` cached over a rotation that is a strict subset under the clamp; retry guard on the wrong quantity. | All fixed (M3 documented as deliberate). |
| **N1** | **A test that could not fail** — it built entities in C#, so it asserted the property initializer, not the migration's `defaultValue`, despite a comment claiming otherwise. | Deleted. Replaced with clamp / overflow / **cross-wrap restore** cases (the property the review proved but found unpinned). |
| **H1** | **The body claimed follow-ups were "filed". They were not.** | Filed for real: **#403**, **#404**. Body corrected above. |
**Verification after fixes:** `Build succeeded. 0 Error(s)` · Core.Tests **565 passed** · ErsatzTV.Tests **1653 passed** · 0 failed · `check:api` exit 0 · rebased onto `ed4b0d45`.
The B1 lesson is the one worth carrying: I gated the two obvious writers and recorded the perimeter as complete. The composite handler is the one that gets missed — the same shape as the repo's existing "enumerate every producer" lesson (#235/#267), which I'd read and still walked into.
Adds PlaybackOrder.WeightedShuffle = 9 (Classic engine only) plus the
per-source weight it distributes by.
Why a new order rather than making ShuffleInOrder weight-aware: ShuffleInOrder's
balanced shuffle pads sources to equal length with Option.None spacers, and
spacers emit nothing — so one cycle plays every item exactly once and airtime
stays proportional to collection size. It prevents *clumping*, not *domination*.
Retrofitting weights onto it would silently change shipped users' output.
WeightedShuffleCollectionEnumerator picks a source by smooth weighted
round-robin (acc += weight; richest wins; pays the total), then takes that
source's next item. Weights 3:1 emit A A B A; ties break to the earliest source
in list order. Fair-share is the equal-weights default, so one mechanism covers
both behaviors in the issue.
One rotation is sized so the source needing the most picks works through all its
items at its share; smaller sources loop within it — that looping is what makes
equal weights mean equal airtime regardless of library size. The sequence stays a
pure function of (Seed, Index), so it restores by replay like its siblings and
needs no per-source persisted counters.
It consumes ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder unchanged —
the schedule-entity-free entry point #380 reserved for this issue. Source
identity is destroyed on the Shuffle path (MultiCollectionGrouper collapses to
GroupedMediaItem + Distinct), so the weight is only reachable via
CollectionWithItems on the ShuffleInOrder-shaped path.
Weight is persisted on MultiCollectionItem AND MultiCollectionSmartItem — the
mirror is mandatory; omitting the smart item silently un-weights smart-collection
members. Both carry a DB default of 1: without it existing rows would migrate to
0, and a 0-weight source is dropped by the enumerator.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ten tests pin the distribution contract exactly rather than statistically,
because the sequence IS the product decision: 3:1 emits A A B A (spread, not
drained); equal weights air a 2-item source as often as a 20-item one; ties break
to the earliest source; a defaulted weight behaves as fair-share (guarding the
migration default); restoring at an index equals advancing to it (the stateless
contract that lets CollectionEnumeratorState carry this order).
Fixes a hang found by the non-vacuity control. When a rotation wraps, MoveNext
retried the rebuild to avoid an immediate repeat. ShuffleInOrder can do that
unbounded because its reshuffle randomizes the lead item — but this order's lead
is decided by weight, so the heaviest source always leads, and when it holds a
single item the lead never changes and the retry never terminates. Two single-item
collections with unequal weights would wedge the playout build. The retry is now
bounded: avoiding a back-to-back repeat is a nicety, not terminating is not.
Regression test walks several wraps under a timeout.
Non-vacuity proven per repo lore: inverting the real WRR pick (max -> min, never
if(true), which trips CS0219 under warnings-as-errors and silently serves a stale
dll to --no-build) failed 5 of 10 tests on a clean build (0 errors, so not a
stale-dll false pass). Reverted; 10/10 green.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WeightedShuffle is implemented for classic schedule items only, and every other
engine mishandles an order it doesn't know *silently*: PlaylistEnumerator has no
default arm so the item is dropped from the playlist; BlockPlayoutBuilder filters
block items against an allow-list and `continue`s past the rest; YAML and Scripted
return None, which their callers' foreach reads as "no content". A weighted order
degrading to unweighted random or to nothing is the worst failure mode here,
because the output is supposed to look arbitrary — nobody would notice.
Rather than change those shipped fallbacks (a real defect class, but pre-existing
and wider than this feature — filed separately, non-goal here), this closes the
new exposure at the write path: ReplacePlaylistItems and ReplaceBlockItems reject
WeightedShuffle with an error naming where it is available. If it can't be
persisted where it isn't handled, the silent sites never see it.
Defence in depth for the two engines that address orders by name: YAML and
Scripted now log a warning when a parsed order falls through unhandled, so an
empty schedule explains itself. Enum.Parse accepts "weightedShuffle" the moment
the value exists, so the gate above can't cover them. EnumeratorForContent becomes
an instance method to reach the logger.
ProgramScheduleItemCommandBase lists WeightedShuffle explicitly as valid for multi
collections — it already passed by falling through the switch, and implicit-by-
omission is how this subsystem grew its silent paths.
Core.Tests: 558 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi collection holds plain collections and smart collections through two
separate join entities that build CollectionWithItems at separate call sites.
Only the plain one is obvious, and forgetting the smart mirror un-weights
smart-collection members with nothing reporting it — so it gets a test rather
than a comment.
Three tests: both weights survive the round trip; an unweighted row reads back as
1, not 0 (a 0-weight source is filtered out by the enumerator, so a 0 default
would silently drop every pre-existing member from a weighted rotation); and the
real MediaCollectionRepository hands the weight to the scheduler — the mapping
tests alone would pass even if the repo dropped it on the floor.
ErsatzTV.Tests: 1641 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without this the weight is only reachable by editing the database, so the
enumerator has nothing to distribute by.
Weight is threaded through create and update (all four handler branches: add and
update, plain and smart) and defaults to 1, so it is optional on the wire and
/api/v1 stays additive under the freeze.
It is returned on the read path too, which is load-bearing rather than symmetry:
the update replaces the item list, so a client that GETs, edits a name, and PUTs
back would silently reset every weight to the default if the GET didn't carry it.
Weight edits ride the existing MultiCollection Version token, so If-Match/412
concurrency needs no new design.
Regenerated v1.json + v1.d.ts + endpoint-index via update-openapi.sh and
generate:api (never hand-edited). The spec picks up weight on both request and
response models and WeightedShuffle in the PlaybackOrder enum; weight is emitted
optional.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
decisions.md entry covers why one enum value rather than two or a separate
setting, why the weight lives on both multi-collection join entities with a DB
default of 1, why it can only be applied on the ShuffleInOrder-shaped path (source
identity is destroyed on the Shuffle path), why it is stateless, and why
cross-engine exposure is closed by write-path validation instead of by changing
five shipped fallbacks.
The load-bearing part is the ShuffleInOrder clarification. Its balanced-shuffle
padding reads as equalization, but the spacers emit nothing — one cycle plays
every item once, so airtime stays proportional to collection size. It is
anti-clumping, not fair-share. That was misread during this issue's own design
pass and would have collapsed #70 to "already shipped"; the distinction is the
whole justification for the feature, so it is now written down in both
decisions.md and the domain-model glossary rather than left to be rediscovered.
domain-model.md gains a Playback order row contrasting Shuffle / ShuffleInOrder /
WeightedShuffle, and notes on the multi-collection row that the two join entities
are mirrors.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review of the fix commit returned MERGEABLE-WITH-NITS. It verified the gate is
now complete by enumerating the writers itself (no fourth persisting writer) and
proved B2's fix works by writing throwaway handler tests — which was also its
point: the fix shipped with none.
B2 was create and update silently DISAGREEING on the same input, and the fix
re-established agreement with nothing pinning it. Both paths are now driven from
one shared case list, plus an explicit test that create and update agree on every
case — the per-path tests would both have passed while the two diverged, which is
how the bug existed in the first place. Non-vacuity proven: inverting only the
update path's validation fails 10 of 20 on a clean build (0 errors, so not a
stale-dll pass), and the agreement test is among the failures.
The rest is my own prose contradicting my own code. The commit that added
EffectiveWeight removed the weight filter, then left four statements asserting a
0-weight source "is filtered out" — two of them authored by that same commit,
including the stated justification for Minimum=1 in MultiCollectionItemWeight.
A future agent could have read that and deleted the clamp or the floor as
redundant; they are belt-and-braces and neither is. Corrected to describe what the
code now does: the gate refuses input that means nothing on a share-of-airtime
scale, the clamp protects rows predating the gate.
Also corrected the writer count in the very bullet whose lesson is "grep every
writer of the field": ReplaceBlockItems writes BlockItem.PlaybackOrder, not
PlaylistItem.PlaybackOrder. There are TWO persisting writers of PlaylistItem's,
and the correction itself had miscounted by conflating the two fields — so the
lesson now says to grep each field separately.
Core.Tests 565 passed, ErsatzTV.Tests 1673 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round 2 — re-review of the fix commit 2c0be689
Per the #242 convention (re-review the fix commit, not just the initial diff — the fix is new code no reviewer has seen).
Verdict: MERGEABLE-WITH-NITS @ 2c0be689, with three must-fix items. All three are now addressed in 48b270e8.
What the re-review confirmed independently
B1 gate is now complete. It enumerated the writers itself rather than trusting me: PlaylistItem.PlaybackOrder ← ReplacePlaylistItemsHandler (gated) + CreateChannelFromLineupHandler (now gated); Add*ToPlaylist and TraktCommandBase hardcode their order; the two Preview handlers build in memory without persisting. BlockItem.PlaybackOrder ← ReplaceBlockItemsHandler (gated). No fourth persisting writer. It also checked the two gate branches compose (a 2-entry lineup containing a MultiCollection passes the first check and is caught by the multiItem check).
B2/B3 fixed; it proved B2's wiring by writing its own throwaway handler tests (8/8), and confirmed B3 non-vacuous by reverting EffectiveWeight and watching 3 tests fail.
M/L/N all correct, and it improved on my reasoning for L1: computing MinimumDuration over _collections isn't a divergence from ShuffleInOrder — it restores the sibling's actual semantics, since ShuffleInOrder's _shuffled already contains every item once. It's also consumed as a cheap "can anything fit" early-out, so under-reporting costs a wasted loop iteration while over-reporting would truncate — the fix errs safe.
The three must-fixes — all mine, all fair
1. B2 shipped with zero test coverage. B2's root cause was untested divergence between create and update, and my fix re-established agreement with nothing pinning it. Now: MultiCollectionWeightValidationTests drives both paths from one shared case list (0, -1, -5, 1001, int.MaxValue rejected; 1, 2, 500, 1000 accepted), covers the smart-item mirror, and asserts create and update agree on every case — the per-path tests would both have passed while the two diverged, which is exactly how the bug existed. Non-vacuity proven: inverting only the update path's validation fails 10 of 20 on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures.
2. My own clamp made four rationale statements false — two authored by the same commit, including the stated justification for Minimum = 1 in MultiCollectionItemWeight. EffectiveWeight removed the weight filter, yet the prose still said a 0-weight source "is filtered out". As the reviewer noted, a future agent could read that and delete the clamp or the floor as redundant. Corrected to describe what the code actually does: the gate refuses input that means nothing on a share-of-airtime scale; the clamp protects rows predating the gate; neither is redundant.
3. I miscounted the writers in the very bullet whose lesson is "grep every writer of the field".ReplaceBlockItems writes BlockItem.PlaybackOrder, not PlaylistItem.PlaybackOrder — I conflated the two fields. There are two persisting writers of PlaylistItem.PlaybackOrder. The lesson now reads: grep every writer of each field separately. That the correction itself repeated the original error's shape is recorded in decisions.md rather than quietly fixed.
v1.json publishes weight with default: 1 but no minimum/maximum, so clients discover the 1..1000 bound only via the error response. Worth a [Range] on the request DTO, but it changes the validation surface (ModelState 400 vs the handler's BaseError), so it isn't a drive-by.
## Review round 2 — re-review of the fix commit `2c0be689`
Per the #242 convention (re-review the **fix commit**, not just the initial diff — the fix is new code no reviewer has seen).
**Verdict: MERGEABLE-WITH-NITS @ `2c0be689`**, with three must-fix items. All three are now addressed in **`48b270e8`**.
### What the re-review confirmed independently
- **B1 gate is now complete.** It enumerated the writers itself rather than trusting me: `PlaylistItem.PlaybackOrder` ← `ReplacePlaylistItemsHandler` (gated) + `CreateChannelFromLineupHandler` (now gated); `Add*ToPlaylist` and `TraktCommandBase` hardcode their order; the two Preview handlers build in memory without persisting. `BlockItem.PlaybackOrder` ← `ReplaceBlockItemsHandler` (gated). **No fourth persisting writer.** It also checked the two gate branches compose (a 2-entry lineup containing a MultiCollection passes the first check and is caught by the `multiItem` check).
- **B2/B3 fixed**; it proved B2's wiring by writing its own throwaway handler tests (8/8), and confirmed B3 non-vacuous by reverting `EffectiveWeight` and watching 3 tests fail.
- **M/L/N all correct**, and it improved on my reasoning for L1: computing `MinimumDuration` over `_collections` isn't a divergence from `ShuffleInOrder` — it *restores* the sibling's actual semantics, since `ShuffleInOrder`'s `_shuffled` already contains every item once. It's also consumed as a cheap "can anything fit" early-out, so under-reporting costs a wasted loop iteration while over-reporting would truncate — the fix errs safe.
### The three must-fixes — all mine, all fair
**1. B2 shipped with zero test coverage.** B2's root cause *was* untested divergence between create and update, and my fix re-established agreement with nothing pinning it. Now: `MultiCollectionWeightValidationTests` drives **both** paths from one shared case list (`0, -1, -5, 1001, int.MaxValue` rejected; `1, 2, 500, 1000` accepted), covers the smart-item mirror, and asserts **create and update agree on every case** — the per-path tests would both have passed while the two diverged, which is exactly how the bug existed. **Non-vacuity proven**: inverting *only* the update path's validation fails **10 of 20** on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures.
**2. My own clamp made four rationale statements false** — two authored by the same commit, including the stated justification for `Minimum = 1` in `MultiCollectionItemWeight`. `EffectiveWeight` removed the weight filter, yet the prose still said a 0-weight source "is filtered out". As the reviewer noted, a future agent could read that and delete the clamp or the floor as redundant. Corrected to describe what the code actually does: **the gate refuses input that means nothing on a share-of-airtime scale; the clamp protects rows predating the gate; neither is redundant.**
**3. I miscounted the writers in the very bullet whose lesson is "grep every writer of the field".** `ReplaceBlockItems` writes `BlockItem.PlaybackOrder`, not `PlaylistItem.PlaybackOrder` — I conflated the two fields. There are **two** persisting writers of `PlaylistItem.PlaybackOrder`. The lesson now reads: grep every writer of **each field separately**. That the correction itself repeated the original error's shape is recorded in `decisions.md` rather than quietly fixed.
### Verification on `48b270e8`
`Build succeeded. 0 Error(s)` · Core.Tests **565 passed** · ErsatzTV.Tests **1673 passed** · 0 failed · non-vacuity control 10/20 fail-on-inversion, reverted.
### Outstanding nit (not fixed, tracked)
`v1.json` publishes `weight` with `default: 1` but no `minimum`/`maximum`, so clients discover the 1..1000 bound only via the error response. Worth a `[Range]` on the request DTO, but it changes the validation surface (ModelState 400 vs the handler's `BaseError`), so it isn't a drive-by.
### Still open on #70's Done-when (3 of 10)
`classic-weighted` golden · live-E2E · a clean verdict on **this** commit. **This PR is not ready to merge**, and the H6 gate correctly blocks it.
Review-verdict: MERGEABLE-WITH-NITS @ 2c0be689056e3de2c98e94b69187d765b3ac2f7d
CI's Formatting job failed: 19 touched files carried a BOM, which .editorconfig
forbids (charset=utf-8). Pure encoding change — one byte per file, no semantic
diff (verified: every hunk is `-namespace` -> `+namespace`).
Self-inflicted. The patches that edited these legacy files wrote them back as
utf-8-sig to "preserve the existing style", but the #311 fix-as-you-touch gate
requires a file to be normalized when you touch it — that is the whole point of
scoping the gate to changed files instead of reformatting the ~2500 legacy BOM
files at once. dotnet format leaves the EF-generated Designer/snapshot files
alone as generated code, and its verify skips them the same way, so they stay as
ef emitted them.
Two corrections to what I believed going in:
- `dotnet format --include` does NOT no-op here. It reported `error CHARSET` for
each file and exit 2, reproducing CI exactly, and fixed them in place. The note
claiming otherwise is wrong for this invocation.
- My first BOM check reported all files clean. The od pattern was wrong; reading
the first three bytes directly found 19. A detector that can only say "ok" is
worse than no detector.
Core.Tests 565, ErsatzTV.Tests 1673, Architecture.Tests 5 — all passed. API
artifacts still in sync.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unit tests pin the enumerator's sequence in isolation; this pins that the real
PlayoutBuilder actually distributes by weight, through the whole chain:
MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight ->
ShuffleSourceBuilder -> WeightedShuffleCollectionEnumerator -> PlayoutItems.
The fixture is deliberately lopsided: the HEAVY source (weight 3) is the SMALL
collection (2 items) and the LIGHT source (weight 1) is the LARGE one (4). Over
the pinned 2-day window that yields 81 : 27 = exactly 3.00 : 1 — the smaller
collection taking the larger share, which is the entire point of the feature and
is not reachable by any existing order: Shuffle is size-proportional (~2:4 here)
and ShuffleInOrder plays every item exactly once per cycle. Longest consecutive
same-source run is 3, so it interleaves smoothly rather than draining blocks.
Same determinism contract as Classic_shuffle: pinned Playout.Seed + Continue mode
(Reset randomizes the seed). Channel number/GUID 5 — every golden fixture shares
one in-memory DB, so those must be globally unique.
The net is armed, not decorative: with the golden absent the test fails (missing
golden is a hard failure by design), and regenerating added only classic-weighted
— the three existing goldens are byte-identical, so nothing was silently
re-baselined.
Core.Tests 566 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
success — both providers apply AddMultiCollectionItemWeight to a fresh DB
Functional E2E (curl contracts)
success
Formatting (changed .cs conform to .editorconfig)
success
API docs in sync (OpenAPI + endpoint index)
success
decisions.md append-only
success
CI image pin / Docs reminder
success
Both jobs that were red on the previous head are now green:
Formatting was a real failure of mine — 19 touched files carried a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). Self-inflicted: the patches that edited those legacy files wrote them back as utf-8-sig to "preserve the existing style", but the #311 fix-as-you-touch gate exists precisely so a touched file gets normalized. Fixed in 27ecaedb, verified encoding-only (every hunk is -namespace → +namespace).
API docs in sync was infra, not the diff — it failed while the CI host went down mid-run. Reproduced locally afterwards: regenerating and diffing v1.json / v1.d.ts / endpoint-index.md reports in-sync, and it passes here on a healthy host.
Two corrections to notes I was carrying, recorded so the next session doesn't inherit them:
dotnet format --include does NOT no-op on this Mac. It reproduced CI exactly (error CHARSET, exit 2) and fixed the files in place. The memory note claiming otherwise is wrong for this invocation.
My first BOM check reported all 30 files clean when 19 were dirty — the od pattern was wrong. Reading the first three bytes directly found them. A detector that can only return "ok" is worse than none.
Done-when: 9/10
Remaining: an independent review verdict against this head (H10 — the prior MERGEABLE-WITH-NITS references 2c0be689, which is stale). In flight; it covers the weight-gate tests, the doc corrections, the BOM commit, and the classic-weighted golden.
The PR is not merge-ready until that lands, and the H6 gate correctly blocks it.
## CI green on `d4f9ac82`
Run #744 — every job success (image push skipped as expected on a PR):
| Job | Result |
|---|---|
| Build & test (.NET) | **success** — Core.Tests 566, ErsatzTV.Tests 1673, Architecture.Tests 5 |
| EF migration integrity (SQLite + MySql) | **success** — both providers apply `AddMultiCollectionItemWeight` to a fresh DB |
| Functional E2E (curl contracts) | **success** |
| Formatting (changed .cs conform to .editorconfig) | **success** |
| API docs in sync (OpenAPI + endpoint index) | **success** |
| decisions.md append-only | **success** |
| CI image pin / Docs reminder | success |
Both jobs that were red on the previous head are now green:
- **Formatting** was a real failure of mine — 19 touched files carried a UTF-8 BOM, which `.editorconfig` forbids (`charset=utf-8`). Self-inflicted: the patches that edited those legacy files wrote them back as `utf-8-sig` to "preserve the existing style", but the #311 fix-as-you-touch gate exists precisely so a touched file gets normalized. Fixed in `27ecaedb`, verified encoding-only (every hunk is `-namespace` → `+namespace`).
- **API docs in sync** was **infra, not the diff** — it failed while the CI host went down mid-run. Reproduced locally afterwards: regenerating and diffing `v1.json` / `v1.d.ts` / `endpoint-index.md` reports in-sync, and it passes here on a healthy host.
Two corrections to notes I was carrying, recorded so the next session doesn't inherit them:
- **`dotnet format --include` does NOT no-op on this Mac.** It reproduced CI exactly (`error CHARSET`, exit 2) and fixed the files in place. The memory note claiming otherwise is wrong for this invocation.
- **My first BOM check reported all 30 files clean when 19 were dirty** — the `od` pattern was wrong. Reading the first three bytes directly found them. A detector that can only return "ok" is worse than none.
### Done-when: 9/10
Remaining: an independent review verdict against **this** head (H10 — the prior `MERGEABLE-WITH-NITS` references `2c0be689`, which is stale). In flight; it covers the weight-gate tests, the doc corrections, the BOM commit, and the `classic-weighted` golden.
The PR is **not** merge-ready until that lands, and the H6 gate correctly blocks it.
Review round 3 — BLOCKED @ d4f9ac82; fixed in 3bca1f6d
The reviewer was right, and the root cause it named is the useful part:
the correction was scoped to the four sites the reviewer listed rather than re-derived by grep
Fixing the list is not fixing the class. That is the same failure as B1 (the gate covered the two writers already in hand and missed CreateChannelFromLineup) — repeated while writing the correction whose stated lesson is "grep every writer of the field". Third round, same shape.
What it caught
Finding
Reality
(b) NOT closed — CreateMultiCollectionHandler.cs:95
I corrected the update twin and never opened the create twin. Present tense, and contradicted by two tests in this same PR (A_Non_Positive_Weight_Does_Not_Delete_The_Source, An_Enormous_Weight_Does_Not_Overflow_The_Rotation).
(b) — decisions.md:1306
Corrected one line in that file, left its sibling.
(b) — a NEW instance the correction bred
I pattern-matched on the word "filtered" and left the identical defect on the ceiling: it still claimed unbounded weights overflow the sum. They cannot — EffectiveWeight clamps before every sum and CycleLength widens to long. Same file I claimed to have fixed.
(c) nit
"TWO persisting writers" is the wrong predicate — Add*ToPlaylist and TraktCommandBasedo persist the field (hardcoded). The true claim is two writers that persist a caller-supplied order. Also missed Engine/PlaylistHelper as a third in-memory writer.
My "10/20" figure
Wrong; they measured 6/20 on the update inversion. Sloppy number, and their measurement is the one to trust.
What it verified rather than took on faith
(a) closed. It re-ran the inversions itself: drop the gate from update → 6/20 fail; from create → 7/20 fail; from both → the agree test passes. So the tests cannot pass if ValidateWeights is dropped from either path. It also correctly notes Update_And_Create_Agree_On_Every_Case is tautological in isolation (it detects divergence, not absence) — but honestly scoped, with the per-path suites carrying the absence case.
The golden is real. Flipping the heavy weight 3→1 fails it. Deterministic under TZ=UTC, Asia/Kolkata, Pacific/Kiritimati; no fixture collision; missing golden hard-fails.
The BOM commit is clean. It stripped BOMs from both sides and hash-compared: 19/21 byte-identical, the other 2 pure re-indentation, nothing foreign reformatted.
Fixed in 3bca1f6d — by grep, not by list
Re-grepped the whole behavior class (filtered out|removes the source|overflow ∩ weight|rotation|sum) rather than working the four cited sites. Every surviving hit is now an accurate statement (what the clamp prevents, a test name, or an explicit historical note).
The ceiling rationale now says what is actually true: it is the floor's argument, not an arithmetic guard — a billion is not a share of airtime any more than 0 is, so it is refused at the boundary; the clamp is what makes the arithmetic safe, which is exactly why a row predating the gate is still safe.
The writer bullet now classifies the full set (persists-caller-value / persists-hardcoded / in-memory) and records that it has been wrong three times in the same shape — a lesson that keeps being re-learned is worth recording as a pattern rather than a fact.
The pre-push BOM check caught this very commit re-adding a BOM to the one file patched with utf-8-sig — the same trap, an hour after writing it down. That is the argument for the mechanical check over the memory of it.
Re-review of 3bca1f6d dispatched: the last two rounds each found real defects in what I claimed was complete, so my say-so is not the evidence.
## Review round 3 — BLOCKED @ `d4f9ac82`; fixed in `3bca1f6d`
The reviewer was right, and the root cause it named is the useful part:
> the correction was scoped to the four sites the reviewer listed rather than re-derived by grep
**Fixing the list is not fixing the class.** That is the same failure as B1 (the gate covered the two writers already in hand and missed `CreateChannelFromLineup`) — repeated while writing the correction whose stated lesson is *"grep every writer of the field"*. Third round, same shape.
### What it caught
| Finding | Reality |
|---|---|
| **(b) NOT closed** — `CreateMultiCollectionHandler.cs:95` | I corrected the *update* twin and **never opened the create twin**. Present tense, and contradicted by two tests in this same PR (`A_Non_Positive_Weight_Does_Not_Delete_The_Source`, `An_Enormous_Weight_Does_Not_Overflow_The_Rotation`). |
| **(b)** — `decisions.md:1306` | Corrected one line in that file, left its sibling. |
| **(b) — a NEW instance the correction bred** | I pattern-matched on the word *"filtered"* and left the identical defect on the **ceiling**: it still claimed unbounded weights overflow the sum. They **cannot** — `EffectiveWeight` clamps before every sum and `CycleLength` widens to `long`. Same file I claimed to have fixed. |
| **(c) nit** | "TWO persisting writers" is the wrong predicate — `Add*ToPlaylist` and `TraktCommandBase` **do** persist the field (hardcoded). The true claim is *two writers that persist a **caller-supplied** order*. Also missed `Engine/PlaylistHelper` as a third in-memory writer. |
| My "10/20" figure | Wrong; they measured **6/20** on the update inversion. Sloppy number, and their measurement is the one to trust. |
### What it verified rather than took on faith
- **(a) closed.** It re-ran the inversions itself: drop the gate from **update** → 6/20 fail; from **create** → 7/20 fail; from **both** → the agree test passes. So the tests cannot pass if `ValidateWeights` is dropped from *either* path. It also correctly notes `Update_And_Create_Agree_On_Every_Case` is tautological **in isolation** (it detects *divergence*, not absence) — but honestly scoped, with the per-path suites carrying the absence case.
- **The golden is real.** Flipping the heavy weight 3→1 **fails** it. Deterministic under `TZ=UTC`, `Asia/Kolkata`, `Pacific/Kiritimati`; no fixture collision; missing golden hard-fails.
- **The BOM commit is clean.** It stripped BOMs from both sides and hash-compared: 19/21 byte-identical, the other 2 pure re-indentation, nothing foreign reformatted.
### Fixed in `3bca1f6d` — by grep, not by list
Re-grepped the whole behavior class (`filtered out|removes the source|overflow` ∩ `weight|rotation|sum`) rather than working the four cited sites. Every surviving hit is now an accurate statement (what the clamp *prevents*, a test name, or an explicit historical note).
The ceiling rationale now says what is actually true: it is the **floor's argument, not an arithmetic guard** — a billion is not a share of airtime any more than 0 is, so it is refused at the boundary; the **clamp** is what makes the arithmetic safe, which is exactly why a row predating the gate is still safe.
The writer bullet now classifies the full set (persists-caller-value / persists-hardcoded / in-memory) and records that **it has been wrong three times in the same shape** — a lesson that keeps being re-learned is worth recording as a pattern rather than a fact.
**Verification:** Build 0 errors · Core.Tests 566 · ErsatzTV.Tests 1673 · 0 failed · format verify exit 0 · decisions.md +90/−0 (append-only green).
The pre-push BOM check caught this very commit re-adding a BOM to the one file patched with `utf-8-sig` — the same trap, an hour after writing it down. That is the argument for the mechanical check over the memory of it.
Re-review of `3bca1f6d` dispatched: the last two rounds each found real defects in what I claimed was complete, so my say-so is not the evidence.
The stale-claim class is closed. The reviewer ran its own exhaustive sweep (every added weight/enumerator comment across all 36 non-generated changed files, plus an independent grep of Scheduling/, Data/, MediaCollections/, docs/ for filter|drop|remove|vanish|delet|exclud|omit|skip near weight) rather than reusing my method — and classified every hit:
No surviving site asserts, in present-tense fact, that the enumerator drops/filters/removes a 0-weight source.
The round-4 site (MultiCollectionConfiguration.cs:37) and its decisions.md twin are now mutually consistent — the twin pattern that recurred every round is closed.
Order-drop claims (unknown orders being dropped by PlaylistEnumerator, which has no default: arm and gates on enumerator is not null at :210) verified as true, not stale — they are about orders, not weights.
Sanity: Build 0/0. Weighted+golden filter 21 passed (incl. the non-vacuous classic-weighted golden and the A_Non_Positive_Weight_Does_Not_Delete_The_Source / An_Enormous_Weight_Does_Not_Overflow_The_Rotation guards). Weight+lineup filter 46 passed. No BOM on touched non-generated .cs. decisions.md insertion-only.
Five rounds, and rounds 3–5 were entirely about prose contradicting code — the code was correct after round 2. Recorded honestly because it's the real cost of this PR: I repeatedly fixed the sites a reviewer named instead of the class, and the fix for stale comments is discipline, not a gate (unlike the BOM class, which got #413).
Review-verdict: MERGEABLE @ c556bbd3
## Review round 5 — MERGEABLE @ `c556bbd3`
The stale-claim class is closed. The reviewer ran its **own** exhaustive sweep (every added weight/enumerator comment across all 36 non-generated changed files, plus an independent grep of `Scheduling/`, `Data/`, `MediaCollections/`, `docs/` for `filter|drop|remove|vanish|delet|exclud|omit|skip` near `weight`) rather than reusing my method — and classified every hit:
- No surviving site asserts, in present-tense fact, that the enumerator drops/filters/removes a 0-weight source.
- The round-4 site (`MultiCollectionConfiguration.cs:37`) and its `decisions.md` twin are now mutually consistent — the twin pattern that recurred every round is closed.
- Order-drop claims (unknown *orders* being dropped by `PlaylistEnumerator`, which has no `default:` arm and gates on `enumerator is not null` at :210) verified as **true**, not stale — they are about orders, not weights.
- Counterfactual/history framing ("**would** remove... clamped instead", "an **earlier revision** filtered") verified accurate.
Sanity: Build 0/0. Weighted+golden filter 21 passed (incl. the non-vacuous `classic-weighted` golden and the `A_Non_Positive_Weight_Does_Not_Delete_The_Source` / `An_Enormous_Weight_Does_Not_Overflow_The_Rotation` guards). Weight+lineup filter 46 passed. No BOM on touched non-generated `.cs`. `decisions.md` insertion-only.
**Five rounds, and rounds 3–5 were entirely about prose contradicting code** — the code was correct after round 2. Recorded honestly because it's the real cost of this PR: I repeatedly fixed the sites a reviewer named instead of the class, and the fix for stale comments is discipline, not a gate (unlike the BOM class, which got #413).
Review-verdict: MERGEABLE @ c556bbd3
Rebased onto current main (was 6 behind). Clean rebase — git range-diff origin/main c556bbd3 35e4b29a shows all 12 commits as = (identical), so the round-5 review of c556bbd3 applies to 35e4b29a verbatim; no content changed, only the replay base. Re-verified on the new head: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673, check:api exit 0, BOM-clean.
Review-verdict: MERGEABLE @ 35e4b29a
Rebased onto current `main` (was 6 behind). **Clean rebase — `git range-diff origin/main c556bbd3 35e4b29a` shows all 12 commits as `=` (identical)**, so the round-5 review of `c556bbd3` applies to `35e4b29a` verbatim; no content changed, only the replay base. Re-verified on the new head: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673, `check:api` exit 0, BOM-clean.
Review-verdict: MERGEABLE @ 35e4b29a
Rebased again onto current main (was 8 behind; #413's BOM guard is now among the base commits). One conflict — the usual decisions.md append-collision with a parallel session's entry — resolved by keeping both entries (content-preserving; git range-diff shows 11/12 commits identical to the reviewed 35e4b29a, the 12th being only the re-resolved decisions.md). Re-verified on 4620dc91: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673, check:api exit 0, BOM-clean (now enforced by the merged #413 guard).
Review-verdict: MERGEABLE @ 4620dc91
Rebased again onto current `main` (was 8 behind; #413's BOM guard is now among the base commits). One conflict — the usual `decisions.md` append-collision with a parallel session's entry — resolved by keeping **both** entries (content-preserving; `git range-diff` shows 11/12 commits identical to the reviewed `35e4b29a`, the 12th being only the re-resolved `decisions.md`). Re-verified on `4620dc91`: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673, `check:api` exit 0, BOM-clean (now enforced by the merged #413 guard).
Review-verdict: MERGEABLE @ 4620dc91
Rebased onto latest main — #405 (channel-health, #72) landed and touches the same MultiCollection files, so this rebase carried real overlap, not just the decisions.md append (git merged the disjoint regions cleanly; only the append collided, resolved keeping both). Re-verified fully because of that overlap: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1676 (main brought new tests), check:api exit 0, BOM-clean (guard from #413 now enforcing). Head 4693aa14.
Rebased onto latest `main` — #405 (channel-health, #72) landed and touches the same MultiCollection files, so this rebase carried real overlap, not just the `decisions.md` append (git merged the disjoint regions cleanly; only the append collided, resolved keeping both). **Re-verified fully because of that overlap**: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1676 (main brought new tests), `check:api` exit 0, BOM-clean (guard from #413 now enforcing). Head `4693aa14`.
Review-verdict: MERGEABLE @ 4693aa14
timothy
scheduled this pull request to auto merge when all checks succeed 2026-07-17 18:34:33 +02:00
timothy
merged commit fa7e8d75c4 into main2026-07-17 19:00:48 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #70 (slice 1: backend + API, Classic engine only).
Design verdict + rationale: #70's design-verdict comment. Decision record:
docs/decisions.md2026-07-17.What this does
Adds
PlaybackOrder.WeightedShuffle = 9. It picks a source by smooth weighted round-robin, then takes that source's next item, so a source'sWeightis its share of airtime. Weights 3:1 emitA A B A(spread, not drained). Fair-share is the equal-weights default, so one order covers both behaviors the issue asked for.The finding that justifies the issue existing
Recon initially concluded fair-share was already shipped as
ShuffleInOrder— which would have collapsed #70 to a docs-only issue (the #77 precedent). That was wrong, and the frontier design pass caught it:ShuffleInOrder's balanced shuffle (keyj) pads every source to the longest withOption<MediaItem>.Nonespacers — but spacers emit nothing. One cycle therefore plays every item exactly once, so a 200-episode show still takes 10x the airtime of a 20-episode one. It buys anti-clumping, not fair-share. PlainShuffleis likewise implicitly weighted by collection size.That distinction is the entire justification for this feature, and it read the other way round, so it is now written into
decisions.mdand thedomain-model.mdglossary rather than left to be rediscovered.Design highlights
WeightedShufflewith weights left at 1; the UI's "fair-share toggle" needs no second mechanism. A separate non-enum "distribution" setting was rejected (it would add an axis every dispatch site must also consult); retrofitting weights ontoShuffleInOrderwas rejected (it would silently change shipped output).ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder— the schedule-entity-free entry point that PR #391's decision entry explicitly reserved for "#70 distribution". No signature change, no god-factory, engines stay separate.MultiCollectionGroupercollapses sources intoGroupedMediaItem+.Distinct(), so on theShufflepath which source an item came from is gone. OnlyCollectionWithItems(ShuffleInOrder-shaped path) can carry a per-source weight.Seed,Index) — restores by replay like its siblings, no per-source counters (CollectionEnumeratorStatehas nowhere to put them). Ratios are data, not state: aResetbuild re-randomizes the seed but ratios hold.int WeightonMultiCollectionItemandMultiCollectionSmartItem; omitting the smart mirror un-weights smart-collection members silently. DB default is 1, not 0 — a 0-weight source is filtered out by the enumerator, so a 0 backfill would drop every pre-existing member from a rotation.The silent-fallback problem, and why validation instead
An unhandled
PlaybackOrderfails silently at five of six dispatch sites: Classic substitutesRandomizedMediaCollectionEnumerator;PlaylistEnumeratorhas nodefault:arm so the item is dropped from the playlist (and null is legitimate there forSeasonEpisodewithCount == 0, so nothing flags it);BlockPlayoutBuilderfilters against an allow-list andcontinues past the rest; YAML/Scripted returnNone, read downstream as "no content". OnlyMultiCollectionGroupthrows.A weighted order degrading to unweighted random is the worst case here — the output is supposed to look arbitrary, so nobody would notice.
So this PR closes the exposure at the write path:
ReplacePlaylistItems/ReplaceBlockItemsrejectWeightedShufflenaming where it is available. If it can't be persisted where it isn't handled, the silent sites never see it. YAML/Scripted address orders by name (Enum.Parseaccepts it regardless), so they get log warnings on unhandled fallthrough.Explicit non-goal: making those pre-existing fallbacks loud. Real defect class, wider than this feature, needs its own review — filed as #403.
A bug the non-vacuity control found
Inverting the WRR pick to prove the tests bite didn't fail — it hung. That exposed a real defect in my own code:
MoveNextretried the rebuild on wrap to avoid an immediate repeat, unbounded.ShuffleInOrdercan do that because its reshuffle randomizes the lead item; this order's lead is decided by weight, so the heaviest source always leads — and when it holds a single item the lead never changes and the retry never terminates. Two single-item collections with unequal weights would have wedged the playout build. Now bounded, with a regression test under a timeout.Verification
Build succeeded. 0 Error(s),error CScount 0.if(true), which trips CS0219 under warnings-as-errors and silently serves a stale dll to--no-build) failed 5 of 10 tests on a clean build. Reverted; green.check:apiexit 0 —v1.json/v1.d.ts/endpoint-indexregenerated, never hand-edited.weightis emitted optional, so/api/v1stays additive under the freeze.main23a615ecand re-verified green.Deviation from the design pass (reviewer, please weigh in)
The design specified per-source inner order mirroring
ShuffleInOrder'sOrderItems(chronological). I shuffle within-source instead (custom-ordered collections still honored). Reason: with chronological inner order and deterministic source selection, every rotation would be byte-identical — the reseed on wrap would change nothing and the repeat-avoidance retry could never succeed. "Weighted in order" would be a separate order if wanted. This is my call, not the design authority's, so it is flagged rather than buried.Deferred (not in this PR)
classic-weightedgolden inPlayoutBuildGoldenTests.blocked-bythis issue) — needs its own carrier: an auto-tuned channel is backed by one live SmartCollection, so its "sources" are members of a single collection, and aPlaylistItemcannot reference a search query. Nothing here forecloses it.Docs (same PR, per the repo rule)
decisions.md(new entry, incl. the ShuffleInOrder correction) +domain-model.md(new Playback order row contrasting the three orders; multi-collection row notes the join-entity mirror) + regeneratedv1.json/endpoint-index.md.🤖 Generated with Claude Code
Review round 1 — BLOCKED, fixed in
2c0be689The cold adversarial review returned BLOCKED. It could not break the algorithm — it probed the stateless-restore claim across rotation wraps (indices 12/13/20/37, all held) and measured the clamp preserving a 1000:1 ratio exactly. What it broke was the perimeter, and it was right on every count.
CreateChannelFromLineupis a THIRD writer ofPlaylistItem.PlaybackOrder; its guard only covered MultiCollection entries, so a ≥2-entry lineup of plain collections persistedWeightedShufflestraight through toPlaylistEnumerator's null-drop. Mydecisions.mdclaim that "the silent sites never see it" was false as written.WeightedShufflefor a multi-item lineup; allows it for multi collections (M1). 2 regression tests. Claim corrected indecisions.mdwith the lesson recorded.Weighthad no validation, and create/update disagreed on the same input. EF'sHasDefaultValue(1)substitutes 1 for a0on INSERT (0 reads as "not set") but an UPDATE writes the 0 through — and a 0-weight source is filtered out of the rotation, deleting it from the channel silently. The doc reasons carefully about this for the migration backfill and left the API wide open.MultiCollectionItemWeight(1..1000) validated on both paths so they can't drift; clamped again in the enumerator for rows predating the gate.Sum(weights)is checked arithmetic →OverflowExceptionfrom inside a playout build. Reachable via the API because of B2.longsum. Test.ScheduleAsGroupsilently ignored;MinimumDurationcached over a rotation that is a strict subset under the clamp; retry guard on the wrong quantity.defaultValue, despite a comment claiming otherwise.Verification after fixes:
Build succeeded. 0 Error(s)· Core.Tests 565 passed · ErsatzTV.Tests 1653 passed · 0 failed ·check:apiexit 0 · rebased ontoed4b0d45.The B1 lesson is the one worth carrying: I gated the two obvious writers and recorded the perimeter as complete. The composite handler is the one that gets missed — the same shape as the repo's existing "enumerate every producer" lesson (#235/#267), which I'd read and still walked into.
1fd21e3645to2c0be68905Review round 2 — re-review of the fix commit
2c0be689Per the #242 convention (re-review the fix commit, not just the initial diff — the fix is new code no reviewer has seen).
Verdict: MERGEABLE-WITH-NITS @
2c0be689, with three must-fix items. All three are now addressed in48b270e8.What the re-review confirmed independently
PlaylistItem.PlaybackOrder←ReplacePlaylistItemsHandler(gated) +CreateChannelFromLineupHandler(now gated);Add*ToPlaylistandTraktCommandBasehardcode their order; the two Preview handlers build in memory without persisting.BlockItem.PlaybackOrder←ReplaceBlockItemsHandler(gated). No fourth persisting writer. It also checked the two gate branches compose (a 2-entry lineup containing a MultiCollection passes the first check and is caught by themultiItemcheck).EffectiveWeightand watching 3 tests fail.MinimumDurationover_collectionsisn't a divergence fromShuffleInOrder— it restores the sibling's actual semantics, sinceShuffleInOrder's_shuffledalready contains every item once. It's also consumed as a cheap "can anything fit" early-out, so under-reporting costs a wasted loop iteration while over-reporting would truncate — the fix errs safe.The three must-fixes — all mine, all fair
1. B2 shipped with zero test coverage. B2's root cause was untested divergence between create and update, and my fix re-established agreement with nothing pinning it. Now:
MultiCollectionWeightValidationTestsdrives both paths from one shared case list (0, -1, -5, 1001, int.MaxValuerejected;1, 2, 500, 1000accepted), covers the smart-item mirror, and asserts create and update agree on every case — the per-path tests would both have passed while the two diverged, which is exactly how the bug existed. Non-vacuity proven: inverting only the update path's validation fails 10 of 20 on a clean build (0 errors, so not a stale-dll pass), and the agreement test is among the failures.2. My own clamp made four rationale statements false — two authored by the same commit, including the stated justification for
Minimum = 1inMultiCollectionItemWeight.EffectiveWeightremoved the weight filter, yet the prose still said a 0-weight source "is filtered out". As the reviewer noted, a future agent could read that and delete the clamp or the floor as redundant. Corrected to describe what the code actually does: the gate refuses input that means nothing on a share-of-airtime scale; the clamp protects rows predating the gate; neither is redundant.3. I miscounted the writers in the very bullet whose lesson is "grep every writer of the field".
ReplaceBlockItemswritesBlockItem.PlaybackOrder, notPlaylistItem.PlaybackOrder— I conflated the two fields. There are two persisting writers ofPlaylistItem.PlaybackOrder. The lesson now reads: grep every writer of each field separately. That the correction itself repeated the original error's shape is recorded indecisions.mdrather than quietly fixed.Verification on
48b270e8Build succeeded. 0 Error(s)· Core.Tests 565 passed · ErsatzTV.Tests 1673 passed · 0 failed · non-vacuity control 10/20 fail-on-inversion, reverted.Outstanding nit (not fixed, tracked)
v1.jsonpublishesweightwithdefault: 1but nominimum/maximum, so clients discover the 1..1000 bound only via the error response. Worth a[Range]on the request DTO, but it changes the validation surface (ModelState 400 vs the handler'sBaseError), so it isn't a drive-by.Still open on #70's Done-when (3 of 10)
classic-weightedgolden · live-E2E · a clean verdict on this commit. This PR is not ready to merge, and the H6 gate correctly blocks it.Review-verdict: MERGEABLE-WITH-NITS @
2c0be68905CI green on
d4f9ac82Run #744 — every job success (image push skipped as expected on a PR):
AddMultiCollectionItemWeightto a fresh DBBoth jobs that were red on the previous head are now green:
.editorconfigforbids (charset=utf-8). Self-inflicted: the patches that edited those legacy files wrote them back asutf-8-sigto "preserve the existing style", but the #311 fix-as-you-touch gate exists precisely so a touched file gets normalized. Fixed in27ecaedb, verified encoding-only (every hunk is-namespace→+namespace).v1.json/v1.d.ts/endpoint-index.mdreports in-sync, and it passes here on a healthy host.Two corrections to notes I was carrying, recorded so the next session doesn't inherit them:
dotnet format --includedoes NOT no-op on this Mac. It reproduced CI exactly (error CHARSET, exit 2) and fixed the files in place. The memory note claiming otherwise is wrong for this invocation.odpattern was wrong. Reading the first three bytes directly found them. A detector that can only return "ok" is worse than none.Done-when: 9/10
Remaining: an independent review verdict against this head (H10 — the prior
MERGEABLE-WITH-NITSreferences2c0be689, which is stale). In flight; it covers the weight-gate tests, the doc corrections, the BOM commit, and theclassic-weightedgolden.The PR is not merge-ready until that lands, and the H6 gate correctly blocks it.
Review round 3 — BLOCKED @
d4f9ac82; fixed in3bca1f6dThe reviewer was right, and the root cause it named is the useful part:
Fixing the list is not fixing the class. That is the same failure as B1 (the gate covered the two writers already in hand and missed
CreateChannelFromLineup) — repeated while writing the correction whose stated lesson is "grep every writer of the field". Third round, same shape.What it caught
CreateMultiCollectionHandler.cs:95A_Non_Positive_Weight_Does_Not_Delete_The_Source,An_Enormous_Weight_Does_Not_Overflow_The_Rotation).decisions.md:1306EffectiveWeightclamps before every sum andCycleLengthwidens tolong. Same file I claimed to have fixed.Add*ToPlaylistandTraktCommandBasedo persist the field (hardcoded). The true claim is two writers that persist a caller-supplied order. Also missedEngine/PlaylistHelperas a third in-memory writer.What it verified rather than took on faith
ValidateWeightsis dropped from either path. It also correctly notesUpdate_And_Create_Agree_On_Every_Caseis tautological in isolation (it detects divergence, not absence) — but honestly scoped, with the per-path suites carrying the absence case.TZ=UTC,Asia/Kolkata,Pacific/Kiritimati; no fixture collision; missing golden hard-fails.Fixed in
3bca1f6d— by grep, not by listRe-grepped the whole behavior class (
filtered out|removes the source|overflow∩weight|rotation|sum) rather than working the four cited sites. Every surviving hit is now an accurate statement (what the clamp prevents, a test name, or an explicit historical note).The ceiling rationale now says what is actually true: it is the floor's argument, not an arithmetic guard — a billion is not a share of airtime any more than 0 is, so it is refused at the boundary; the clamp is what makes the arithmetic safe, which is exactly why a row predating the gate is still safe.
The writer bullet now classifies the full set (persists-caller-value / persists-hardcoded / in-memory) and records that it has been wrong three times in the same shape — a lesson that keeps being re-learned is worth recording as a pattern rather than a fact.
Verification: Build 0 errors · Core.Tests 566 · ErsatzTV.Tests 1673 · 0 failed · format verify exit 0 · decisions.md +90/−0 (append-only green).
The pre-push BOM check caught this very commit re-adding a BOM to the one file patched with
utf-8-sig— the same trap, an hour after writing it down. That is the argument for the mechanical check over the memory of it.Re-review of
3bca1f6ddispatched: the last two rounds each found real defects in what I claimed was complete, so my say-so is not the evidence.Review round 5 — MERGEABLE @
c556bbd3The stale-claim class is closed. The reviewer ran its own exhaustive sweep (every added weight/enumerator comment across all 36 non-generated changed files, plus an independent grep of
Scheduling/,Data/,MediaCollections/,docs/forfilter|drop|remove|vanish|delet|exclud|omit|skipnearweight) rather than reusing my method — and classified every hit:MultiCollectionConfiguration.cs:37) and itsdecisions.mdtwin are now mutually consistent — the twin pattern that recurred every round is closed.PlaylistEnumerator, which has nodefault:arm and gates onenumerator is not nullat :210) verified as true, not stale — they are about orders, not weights.Sanity: Build 0/0. Weighted+golden filter 21 passed (incl. the non-vacuous
classic-weightedgolden and theA_Non_Positive_Weight_Does_Not_Delete_The_Source/An_Enormous_Weight_Does_Not_Overflow_The_Rotationguards). Weight+lineup filter 46 passed. No BOM on touched non-generated.cs.decisions.mdinsertion-only.Five rounds, and rounds 3–5 were entirely about prose contradicting code — the code was correct after round 2. Recorded honestly because it's the real cost of this PR: I repeatedly fixed the sites a reviewer named instead of the class, and the fix for stale comments is discipline, not a gate (unlike the BOM class, which got #413).
Review-verdict: MERGEABLE @ c556bbd3
c556bbd3a4to35e4b29a8fRebased onto current
main(was 6 behind). Clean rebase —git range-diff origin/main c556bbd3 35e4b29ashows all 12 commits as=(identical), so the round-5 review ofc556bbd3applies to35e4b29averbatim; no content changed, only the replay base. Re-verified on the new head: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673,check:apiexit 0, BOM-clean.Review-verdict: MERGEABLE @ 35e4b29a
35e4b29a8fto4620dc91aeRebased again onto current
main(was 8 behind; #413's BOM guard is now among the base commits). One conflict — the usualdecisions.mdappend-collision with a parallel session's entry — resolved by keeping both entries (content-preserving;git range-diffshows 11/12 commits identical to the reviewed35e4b29a, the 12th being only the re-resolveddecisions.md). Re-verified on4620dc91: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1673,check:apiexit 0, BOM-clean (now enforced by the merged #413 guard).Review-verdict: MERGEABLE @ 4620dc91
4620dc91aeto4693aa14c3Rebased onto latest
main— #405 (channel-health, #72) landed and touches the same MultiCollection files, so this rebase carried real overlap, not just thedecisions.mdappend (git merged the disjoint regions cleanly; only the append collided, resolved keeping both). Re-verified fully because of that overlap: Build 0/0, Core.Tests 566, ErsatzTV.Tests 1676 (main brought new tests),check:apiexit 0, BOM-clean (guard from #413 now enforcing). Head4693aa14.Review-verdict: MERGEABLE @
4693aa14