Files
ersatztv/docs/decisions/records/sched/autotune-per-source-weights.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

5.0 KiB
Raw Blame History

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
sched.autotune-per-source-weights 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425) active 2026-07-18 none none Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385 `AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator`

Per-source rotation weights (3× Show A, 1× Show B) and query corrections (exclude / add-untagged) for an auto-tune channel are supplied at bulk-create time — an optional sources: [{sourceId, weight, excluded}] list on each AutoTunedChannelRequest (the same DetailPanel-wizard surface #385 added its per-channel advanced/templateId/logo to). There is no post-hoc PUT .../weights, no lazy upgrade/downgrade, and no idempotent desired-state handler: the auto-tune DetailPanel (#383/#386) is a create-wizard, and #425's Done-when is "create → built playout". (An earlier plan draft assumed a PUT on an existing channel; the shipped flow is create-time, matching #385.)

Structure — Option A (reuse #70), not a new fake-collection weight path. When any source is customized (a non-default weight, an exclusion, or an added out-of-axis id), the channel is backed by a system-owned MultiCollection of per-source SmartCollections, weights on MultiCollectionSmartItem.Weight, and its single-item lineup points at the MultiCollectionId with PlaybackOrder.WeightedShuffle — the exact path WeightedShuffleCollectionEnumerator already consumes (GetMultiCollectionCollections forwards the per-row weight). Rejected: threading a weight map through GroupIntoFakeCollections (the fake-collection path a lone SmartCollection takes, which hardcodes weight 1) — it derives its group keys at runtime, is shared with the unrelated FillWithGroupMode, and would need a parallel weight-persistence home. When no source is customized (all weights 1, nothing excluded/added) the channel keeps the #69 single-SmartCollection fair-share shape — cheaper, and identical output for TV since the fake path already groups per show.

Discriminators. A source's member query is discriminator-only (membership is fixed at tune time): TV uses type:episode AND show_title:"X" (episode docs carry no parent-show id in the index — show_title is the only per-show field, the same one #69's TvShow axis already uses; a post-create rename empties the member until re-tuned), and movies use the stable, rename-proof type:movie AND id:{mediaItemId} (a movie is the played item). The remainder is ({base}) AND NOT ({d1} OR {d2} …) over every materialized excluded discriminator — a partition of the base set, so no item is counted twice or dropped. Exclude = omit the member and keep it in the NOT-list (else its items leak back through the remainder); add-untagged = an ordinary member whose id isn't in the base set (no genre clause, so it just airs).

Materialization bound is axis-dependent — "weight N" means N× each other source. TV materializes every base show as its own member (un-weighted shows keep per-show fair-share; a single merged remainder would regress them to item-proportional, so a 200-episode show would swamp a 20-episode one) plus one live remainder at weight 1 for shows/episodes added after tune-in (empty at create → harmlessly skipped by WeightedShuffleCollectionEnumerator's ActiveSources filter). MovieGenre materializes only the touched movies (weighted or added) and leaves every un-touched base movie in ONE remainder whose weight = its member count — exactly equivalent to materializing each movie individually, because the fake path already pools movies uniformly, without hundreds of rows. Cost note: a large TV genre materializes one SmartCollection per show (dozenshundreds), each a cheap stored term query; a future optimization could cap/warn.

Ownership + lifecycle. The MultiCollection and its member/remainder SmartCollections carry a nullable OwnedByChannelId (dual-provider migration AddCollectionOwnedByChannelId, indexed). Owned rows are hidden from the user-facing collection lists and cascade-cleaned when the channel is deleted (the ProgramScheduleItem → MultiCollection cascade removes the dangling flood item). Names embed a per-create token (at-mc:{token}, at:{token}:{n}) because both names are unique varchar(50) and the channel id isn't known until CreateChannelFromLineup runs; ownership is stamped immediately after. Non-weighted (#69) channels set no ownership, so their pre-existing orphan-on-delete behavior is unchanged. The create is non-atomic across the two handlers (mirrors #69) with best-effort rollback of the artifacts on channel-create failure.