commit fa073c74a242bd7a71c5d7605966e8ca64269f73 parent a7e1e20f0a2b8ea9baeefe7f864774d12503c9dd Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Wed, 26 Aug 2026 10:43:30 -0400 editor: the surfaces that still said "backfill" for an operation say "speakers" Slice 2 handed three id spaces forward to the vocabulary pass; this is that pass on the UI, plus the "unit" disambiguation and one dead file. StationId "backfill" → "speakers" on the /channels group line. The station's LABEL is derived (ChannelGroupSection.speakersLabel, via operationsGroupLabel over the operations enabled on the backfill lane) rather than the literal "Speakers": under the default settings no speaker operation is on and the honest label is "Derived data" — the same rule the channel page's stage title already follows, pinned by channel-stage-selection.spec. channel-groups.spec asserts that derived string on the button. STATION_IDS (exported, unreferenced) is gone; backfillChannelGroupAction is speakersChannelGroupAction and still runs backfillChannelAction, which is the LANE runner. The /actionable section id is "speakers" and its count column says "reachable" — the count is reachableOperationWork summed over the lane's entries, and "to backfill" named the queue. BackfillStage.tsx is SpeakersStage.tsx, BackfillKindView is OperationRowView, its `kinds` prop is `operations`, and every population label on the card says "speakers" (15 spec lines follow 1:1). The comment that explained why they were left spelling "backfill" is replaced by the rule that names them now. "unit" has one meaning. A unit is one item of dispatchable work — ArbiterUnit, startWorkerUnit, /api/worker/unit, and three of those are persisted — so the dispatch side keeps the word. What one video of an operation costs is its cost basis, and the prose that said "unit" for that says "cost basis". The Figure({unit}) prop in OperationRail and SweepLane held "reachable"/"blocked"/ "held" — a population label, neither meaning — and is `population`. jobs/components/PauseBackfillButton.tsx had zero importers since LaneDeck absorbed it; deleted. Kept, each on purpose: `pause backfill` / `resume backfill` / `start|stop backfill sweep` (they act on the lane), data-lane="backfill", every backfill*Action, SettingsForm's "Run the backfill lane" fieldset (slice 3 moves it), the widget's SectionId, JobTaskKind, and every persisted key. FACTS.md carries the full old→new mapping, the lane-word rule, the "unit" rule and the list of what still says "backfill"; STATE.md and the IA doc are updated; plans/ history is not search-replaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Diffstat:
30 files changed, 548 insertions(+), 451 deletions(-)
diff --git a/common/lib/operations.test.ts b/common/lib/operations.test.ts @@ -1117,7 +1117,7 @@ test("digest: a transcript with NO cues.json defers too — the case that is 97. }); test("a kind that can defer says WHY, and digest's reason is not 'it clears itself'", () => { - // BackfillStage used to hardcode one sentence about the diarization duration + // SpeakersStage used to hardcode one sentence about the diarization duration // cap for every kind's deferred videos at once. Correct only while diarization // was the sole kind that could defer. assert.match( diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -377,7 +377,7 @@ export type Operation = { // the one who decides what is too expensive. costBasis: string; // What a `deferred` video of THIS kind is waiting for, and what an operator - // can do about it. Belongs to the kind, not to the card: BackfillStage used to + // can do about it. Belongs to the kind, not to the card: SpeakersStage used to // hardcode "too long to diarize under the current limit", which was correct // only while diarization was the sole kind that could defer. Digest defers for // an unrelated reason (no current normalized transcript), so a card summing @@ -620,7 +620,7 @@ const diarization: Operation = { group: "speakers", shortLabel: "Diarize", costBasis: "one pass over the audio per video", - // The wording BackfillStage used to hardcode for every kind at once. + // The wording SpeakersStage used to hardcode for every kind at once. deferredHint: "too long to diarize under the current limit — raise or clear Max audio hours in Settings to include them", tier: "lane", diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The word "backfill" now means one thing: the shared queue.** It had been doing double duty — naming the queue that diarization, speaker attribution and the digest share, *and* standing in for each of those operations wherever a screen had no better word. The `/channels` group button that read **Backfill** is named after the operations it runs: **Speakers** once a speaker operation is switched on, **Derived data** while none is — the same derived label the channel page's stage already carried, so the button never claims work its lane is not doing. The `/actionable` section and every row of the channel page's Speakers card are labelled by the operations, not the queue, and the count column there says *reachable* rather than *to backfill*. *Pause backfill*, *Resume backfill* and *Start backfill sweep* keep their names, because those act on the queue itself. Under the hood the operation registry is `operations.ts` and its types say *Operation*, not *BackfillKind*; nothing stored on disk changed. - **The channel page's "Backfill" card is the *Speakers* card, and it says what each operation costs.** "Backfill" is the name of a *queue*, and on this install that one word was standing in for three operations with different inputs, different costs and different reasons to be switched on — nobody can arm, pause or run "a backfill". The card is now named after the work it holds, one row per operation, each row carrying what one unit of it costs. Old links keep working: **`?stage=backfill` still opens the Speakers panel** rather than silently dropping you on the channel overview. - **A run's summary line reports everything that happened to it, wherever the run was started from.** Starting the same work from the channel card and from the corpus-wide sweep used to produce two differently-worded summaries: the card's mentioned videos that were *deferred* (over the length limit) and *blocked* (waiting on an earlier operation) but not the ones *skipped* mid-rewrite; the sweep's did the opposite. Both now print all of them, so "what did that run actually do" has one answer. The needs-re-acquiring count stays its own separate number and is never folded into the rest — on this corpus it is ~91× larger, and one summed "remaining" figure would be useless. - **A GPU-bound speaker run is now scheduled as GPU-bound.** When diarization is configured to run on the Vulkan backend it holds ~4.4 GB of the same card the transcription engine wants, and it *declared* that — but the dispatcher never asked, and scheduled it as if it only wanted CPU cores. It now stands aside for transcription the way it was always meant to. The same fix means a **metered** digest run is reserved on the metered lane rather than the local one, so the two digest lanes genuinely run side by side. diff --git a/editor/app/actionable/page.tsx b/editor/app/actionable/page.tsx @@ -202,11 +202,14 @@ export default async function ActionablePage() { }, { config: { - id: "backfill", + // "speakers" is the backfill lane's section: named for the operations + // it holds, as the channel page's speakers card and the /channels + // station are, not for the queue they share. + id: "speakers", title: "Channels missing derived data the corpus predates", description: `Videos an enabled derived-data operation has nothing on disk for — no record, or one produced by a different engine/model/threshold than the current settings. Run \u201c${laneAction}\u201d to catch them up. The count is what the lane can do TODAY; the second column is the separate population whose source media has already been deleted, which needs the opt-in re-download to reach at all.`, - countLabel: "to backfill", + countLabel: "reachable", emptyLabel: "Nothing pending.", getCount: actionableBackfillCount, // Same treatment "Est. reclaim" gets, and for a stronger reason: these diff --git a/editor/app/channels/[slug]/components/flow/FlowStation.tsx b/editor/app/channels/[slug]/components/flow/FlowStation.tsx @@ -81,7 +81,7 @@ export function StationFoot({ station }: { station: Station }) { // // The station used to draw a single meter over a number that summed // three unrelated pipelines. Its members are three different - // populations in two different units, and the only honest picture is + // populations with two different cost bases, and the only honest picture is // three bands — the same instrument, at the smallest of its three // scales, so a reader coming from /channels or /operations already // knows what the fills mean. @@ -132,7 +132,7 @@ function StationPipeline({ return ( <div className="flex flex-col gap-0.5" - // The exact figures, and the unit one of them is counted in. Never summed + // The exact figures, and the cost basis one of them is counted against. Never summed // and never rounded — the band is the shape, this is the arithmetic. title={`${operation.label} — ${sentence}. ${operation.costBasis}.`} > diff --git a/editor/app/channels/[slug]/components/stages/BackfillStage.tsx b/editor/app/channels/[slug]/components/stages/BackfillStage.tsx @@ -1,259 +0,0 @@ -"use client"; - -// The channel-level view of one derived-data lane: what is reachable, what would -// need its media re-acquired, and a button to run it. -// -// IT NAMES THE WORK, NOT THE QUEUE. This card used to be headed "Backfill -// derived data" with a button reading "Backfill channel", and on this install -// that one word was standing in for THREE operations with different inputs, -// different costs and different reasons to be armed — a fact no screen could -// state. The heading now comes from the group its kinds declare, and every -// kind's own row states what one unit of it costs. -// -// Modelled on DigestStage — same bucket-count heading, same StreamActionLog + -// QueueControl shape — so the stage rail reads as one system. -// -// THE TWO NUMBERS ARE NEVER ADDED. The heading counts what this button would -// actually do; the muted sub-line reports what it cannot reach without a -// re-download. Measured on the real corpus those are 835 and ~76,270, and a -// single "77,105 remaining" here would make the card useless the day it shipped -// — the same failure /api/widget/actionable already documents for `noDigest`. - -import { useState } from "react"; -import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { QueueControl } from "../../../../components/QueueControl"; -import { cancelJobAction } from "../../../../jobs/actions"; -import { backfillChannelAction } from "../../backfillActions"; -import { OperationWork } from "./OperationWork"; - -export type BackfillKindView = { - id: string; - label: string; - // missing + stale: what the lane can do right now. - reachableIds: string[]; - // Videos whose input is gone. A COUNT only — the id list is corpus-sized and - // deliberately not stored in the snapshot (see OperationSnapshotEntry). - missingInput: number; - // Videos this kind refuses to attempt under the current configuration. A - // THIRD number, never added to the other two: summing it would let a capped - // corpus report as finished. - deferred: number; - // WHY this kind defers, as a sentence fragment completing "N videos are …", - // resolved from the registry on the server. It used to be one hardcoded - // sentence about the diarization duration cap, which was only ever right - // because diarization was the sole kind that could defer — the moment a - // second kind defers for another reason, a card summing them states a cause - // that is false for most of what it counts. - deferredHint?: string; - // Videos waiting on another kind's output. A FOURTH number, never added to - // the others either — but unlike the three above it needs nothing from the - // operator, because the prerequisite lane brings it down on its own. - blocked: number; - // Labels of the kinds this one waits on, resolved from the registry's - // dependsOn on the server so the copy can name them. - dependsOnLabels: string[]; - stale: number; - // What one unit of this operation costs, from the registry. Printed on the - // kind's own row, because an armed operation with a five-figure backlog and - // a per-CHUNK unit reads exactly like a quiet one without it. - costBasis: string; -}; - -type Props = { - slug: string; - existingQueues: string[]; - defaultQueueKey: string; - kinds: BackfillKindView[]; - // settings.backfill.allowRedownload — surfaced because it is the difference - // between the sub-line being informational and being actionable. - allowRedownload: boolean; - // What this lane's operations are, as a group — "Speakers" for the eyebrow, - // "speaker work" for the button. Derived on the server from the registry, so - // a lane holding a mix of groups says "derived data" rather than naming one - // member. See operationsGroupLabel / operationsActionLabel. - groupLabel: string; - actionLabel: string; - // No backfill feature is switched on at all. The card still renders (the rail - // is fixed) but says so rather than reporting an empty work list as "done". - anyEnabled: boolean; -}; - -export function BackfillStage({ - slug, - existingQueues, - defaultQueueKey, - kinds, - allowRedownload, - anyEnabled, - groupLabel, - actionLabel, -}: Props) { - // "speaker work" → "Speaker work". Sentence case, not title case: it is a - // phrase, and "Speaker Work" is a proper noun this system does not have. - const heading = actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1); - const runLabel = `Run ${actionLabel}`; - const [queue, setQueue] = useState(defaultQueueKey); - - const reachable = kinds.reduce((n, k) => n + k.reachableIds.length, 0); - const missingInput = kinds.reduce((n, k) => n + k.missingInput, 0); - const blocked = kinds.reduce((n, k) => n + k.blocked, 0); - // ONE LINE PER KIND, not one summed line: each kind defers for its own reason - // and the fix differs, so a single count under a single sentence would attach - // one kind's remedy to another kind's videos. - const deferredKinds = kinds.filter((k) => k.deferred > 0); - // What the blocked videos are waiting for, named. Only kinds that actually - // have blocked videos contribute, so the sentence never lists a prerequisite - // that is not holding anything up. - const blockedOn = [ - ...new Set( - kinds.filter((k) => k.blocked > 0).flatMap((k) => k.dependsOnLabels), - ), - ]; - const allReachableIds = [ - ...new Set(kinds.flatMap((k) => k.reachableIds)), - ].sort(); - - return ( - <OperationWork - // RENAMED WITH THE STAGE. The POPULATION labels below ("backfill - // reachable", "backfill kind …") are deliberately NOT renamed: they name - // the LANE's populations, appear on 16 spec lines, and go in the - // vocabulary pass that follows this slice. Fewer hand-edited spec lines is - // less e2e risk for the same result. - sectionLabel="speakers section" - heading={ - <> - {heading} · {kinds.length}{" "} - {kinds.length === 1 ? "operation" : "operations"} - </> - } - lines={[ - { - key: "reachable", - ariaLabel: "backfill reachable", - node: anyEnabled ? ( - <> - {reachable.toLocaleString()}{" "} - {reachable === 1 ? "video" : "videos"} can be worked on now. - {kinds.length > 1 - ? " These operations share one queue and one pause, which is the only sense in which they are one lane." - : ""} A re-run does only what is still missing or stale, so running - it twice costs nothing the second time. - </> - ) : ( - "Nothing here is enabled. Turn an operation on in Settings and this card will report what the existing corpus is missing." - ), - }, - ...(missingInput > 0 - ? [ - { - key: "missing-input", - ariaLabel: "backfill needs re-acquiring", - node: ( - <> - {missingInput.toLocaleString()} more{" "} - {missingInput === 1 ? "video needs" : "videos need"} their - media re-acquired first - {allowRedownload - ? " — re-download is on, so this run will fetch and then delete it, bounded by the free-disk floor." - : " — re-download is off, so this run skips them."} - </> - ), - }, - ] - : []), - // ONE LINE PER KIND, not one summed line: each kind defers for its own - // reason and the fix differs, so a single count under a single sentence - // would attach one kind's remedy to another kind's videos. - ...deferredKinds.map((k) => ({ - key: `deferred-${k.id}`, - ariaLabel: "backfill deferred", - dataKind: k.id, - node: ( - <> - {k.deferred.toLocaleString()}{" "} - {k.deferred === 1 ? "video is" : "videos are"}{" "} - {k.deferredHint ?? - `being skipped by ${k.label} under the current configuration`} - . - </> - ), - })), - ...(blocked > 0 - ? [ - { - key: "blocked", - ariaLabel: "backfill blocked", - node: ( - <> - {blocked.toLocaleString()}{" "} - {blocked === 1 ? "video is" : "videos are"} waiting on{" "} - {blockedOn.length > 0 - ? blockedOn.join(" and ") - : "an earlier backfill"}{" "} - and will become available as{" "} - {blockedOn.length === 1 ? "it runs" : "those run"} — nothing - to do here. - </> - ), - }, - ] - : []), - ]} - // ONE ROW PER OPERATION, always — not only when there are several. A - // single-operation lane still has to say WHICH operation, because the - // heading above it no longer does: it names a group. - rows={kinds.map((k) => ({ - key: k.id, - ariaLabel: `backfill kind ${k.id}`, - node: ( - <> - <span className="font-medium">{k.label}</span>:{" "} - {k.reachableIds.length} reachable - {k.stale > 0 && ` (${k.stale} stale)`} ·{" "} - {k.missingInput.toLocaleString()} needing media - {k.deferred > 0 && ` · ${k.deferred.toLocaleString()} deferred`} - {k.blocked > 0 && ` · ${k.blocked.toLocaleString()} blocked`} - {/* WHAT ONE UNIT COSTS, beside the backlog and never as a - judgement. 11,337 reachable videos means ~194,000 model calls - when the unit is the transcript chunk and ~11,337 when it is the - video, and no other line on this page can tell you which. Stated - flat: a "this is a lot" threshold would be a magic number the - next operation gets wrong, and what is affordable is the - operator's call. */} - {k.costBasis && ( - <span className="italic"> — {k.costBasis}</span> - )} - </> - ), - }))} - list={{ - slug, - ids: allReachableIds, - ariaLabel: "videos needing a backfill list", - emptyAriaLabel: "videos needing a backfill empty", - emptyMessage: anyEnabled - ? `Nothing reachable for ${actionLabel}.` - : "Nothing here is enabled.", - itemAriaLabel: (id: string) => `video needing a backfill ${id}`, - }} - > - <StreamActionLog - trigger={() => backfillChannelAction(slug, queue)} - cancelAction={cancelJobAction} - buttonLabel={runLabel} - runningLabel="Running…" - label={runLabel} - extraControls={ - <QueueControl - value={queue} - onChange={setQueue} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={runLabel} - /> - } - /> - </OperationWork> - ); -} diff --git a/editor/app/channels/[slug]/components/stages/OperationWork.tsx b/editor/app/channels/[slug]/components/stages/OperationWork.tsx @@ -3,7 +3,7 @@ // THE PRESENTATION TWO STAGE CARDS SHARE: a labelled section, a count heading, // the per-population lines, and the id list. // -// NOT ONE COMPONENT FOR BOTH CARDS. BackfillStage and DigestStage overlap in +// NOT ONE COMPONENT FOR BOTH CARDS. SpeakersStage and DigestStage overlap in // what they SHOW and diverge in everything that ACTS, so merging them would mean // one component branching on its own identity — the shape this whole slice is // undoing. What actually diverges: diff --git a/editor/app/channels/[slug]/components/stages/SpeakersStage.tsx b/editor/app/channels/[slug]/components/stages/SpeakersStage.tsx @@ -0,0 +1,259 @@ +"use client"; + +// The channel-level view of one derived-data lane: what is reachable, what would +// need its media re-acquired, and a button to run it. +// +// IT NAMES THE WORK, NOT THE QUEUE. This card used to be headed "Backfill +// derived data" with a button reading "Backfill channel", and on this install +// that one word was standing in for THREE operations with different inputs, +// different costs and different reasons to be armed — a fact no screen could +// state. The heading now comes from the group its operations declare, and every +// kind's own row states its cost basis. +// +// Modelled on DigestStage — same bucket-count heading, same StreamActionLog + +// QueueControl shape — so the stage rail reads as one system. +// +// THE TWO NUMBERS ARE NEVER ADDED. The heading counts what this button would +// actually do; the muted sub-line reports what it cannot reach without a +// re-download. Measured on the real corpus those are 835 and ~76,270, and a +// single "77,105 remaining" here would make the card useless the day it shipped +// — the same failure /api/widget/actionable already documents for `noDigest`. + +import { useState } from "react"; +import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; +import { QueueControl } from "../../../../components/QueueControl"; +import { cancelJobAction } from "../../../../jobs/actions"; +import { backfillChannelAction } from "../../backfillActions"; +import { OperationWork } from "./OperationWork"; + +export type OperationRowView = { + id: string; + label: string; + // missing + stale: what the lane can do right now. + reachableIds: string[]; + // Videos whose input is gone. A COUNT only — the id list is corpus-sized and + // deliberately not stored in the snapshot (see OperationSnapshotEntry). + missingInput: number; + // Videos this kind refuses to attempt under the current configuration. A + // THIRD number, never added to the other two: summing it would let a capped + // corpus report as finished. + deferred: number; + // WHY this kind defers, as a sentence fragment completing "N videos are …", + // resolved from the registry on the server. It used to be one hardcoded + // sentence about the diarization duration cap, which was only ever right + // because diarization was the sole kind that could defer — the moment a + // second kind defers for another reason, a card summing them states a cause + // that is false for most of what it counts. + deferredHint?: string; + // Videos waiting on another kind's output. A FOURTH number, never added to + // the others either — but unlike the three above it needs nothing from the + // operator, because the prerequisite lane brings it down on its own. + blocked: number; + // Labels of the operations this one waits on, resolved from the registry's + // dependsOn on the server so the copy can name them. + dependsOnLabels: string[]; + stale: number; + // What one video of this operation costs, from the registry. Printed on the + // operation's own row, because an armed operation with a five-figure backlog + // and a per-CHUNK cost basis reads exactly like a quiet one without it. + costBasis: string; +}; + +type Props = { + slug: string; + existingQueues: string[]; + defaultQueueKey: string; + operations: OperationRowView[]; + // settings.backfill.allowRedownload — surfaced because it is the difference + // between the sub-line being informational and being actionable. + allowRedownload: boolean; + // What this lane's operations are, as a group — "Speakers" for the eyebrow, + // "speaker work" for the button. Derived on the server from the registry, so + // a lane holding a mix of groups says "derived data" rather than naming one + // member. See operationsGroupLabel / operationsActionLabel. + groupLabel: string; + actionLabel: string; + // No backfill feature is switched on at all. The card still renders (the rail + // is fixed) but says so rather than reporting an empty work list as "done". + anyEnabled: boolean; +}; + +export function SpeakersStage({ + slug, + existingQueues, + defaultQueueKey, + operations, + allowRedownload, + anyEnabled, + groupLabel, + actionLabel, +}: Props) { + // "speaker work" → "Speaker work". Sentence case, not title case: it is a + // phrase, and "Speaker Work" is a proper noun this system does not have. + const heading = actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1); + const runLabel = `Run ${actionLabel}`; + const [queue, setQueue] = useState(defaultQueueKey); + + const reachable = operations.reduce((n, k) => n + k.reachableIds.length, 0); + const missingInput = operations.reduce((n, k) => n + k.missingInput, 0); + const blocked = operations.reduce((n, k) => n + k.blocked, 0); + // ONE LINE PER KIND, not one summed line: each kind defers for its own reason + // and the fix differs, so a single count under a single sentence would attach + // one kind's remedy to another kind's videos. + const deferredOperations = operations.filter((k) => k.deferred > 0); + // What the blocked videos are waiting for, named. Only operations that actually + // have blocked videos contribute, so the sentence never lists a prerequisite + // that is not holding anything up. + const blockedOn = [ + ...new Set( + operations.filter((k) => k.blocked > 0).flatMap((k) => k.dependsOnLabels), + ), + ]; + const allReachableIds = [ + ...new Set(operations.flatMap((k) => k.reachableIds)), + ].sort(); + + return ( + <OperationWork + // Every label on this card says "speakers": the section, its populations + // and its rows are named for the operations the lane holds, never for + // the queue key they share. "speakers" is the STATION name — the same id + // the /channels group line and the /actionable section use — and the + // visible heading stays derived (Speaker work / Derived data). + sectionLabel="speakers section" + heading={ + <> + {heading} · {operations.length}{" "} + {operations.length === 1 ? "operation" : "operations"} + </> + } + lines={[ + { + key: "reachable", + ariaLabel: "speakers reachable", + node: anyEnabled ? ( + <> + {reachable.toLocaleString()}{" "} + {reachable === 1 ? "video" : "videos"} can be worked on now. + {operations.length > 1 + ? " These operations share one queue and one pause, which is the only sense in which they are one lane." + : ""} A re-run does only what is still missing or stale, so running + it twice costs nothing the second time. + </> + ) : ( + "Nothing here is enabled. Turn an operation on in Settings and this card will report what the existing corpus is missing." + ), + }, + ...(missingInput > 0 + ? [ + { + key: "missing-input", + ariaLabel: "speakers needs re-acquiring", + node: ( + <> + {missingInput.toLocaleString()} more{" "} + {missingInput === 1 ? "video needs" : "videos need"} their + media re-acquired first + {allowRedownload + ? " — re-download is on, so this run will fetch and then delete it, bounded by the free-disk floor." + : " — re-download is off, so this run skips them."} + </> + ), + }, + ] + : []), + // ONE LINE PER KIND, not one summed line: each kind defers for its own + // reason and the fix differs, so a single count under a single sentence + // would attach one kind's remedy to another kind's videos. + ...deferredOperations.map((k) => ({ + key: `deferred-${k.id}`, + ariaLabel: "speakers deferred", + dataKind: k.id, + node: ( + <> + {k.deferred.toLocaleString()}{" "} + {k.deferred === 1 ? "video is" : "videos are"}{" "} + {k.deferredHint ?? + `being skipped by ${k.label} under the current configuration`} + . + </> + ), + })), + ...(blocked > 0 + ? [ + { + key: "blocked", + ariaLabel: "speakers blocked", + node: ( + <> + {blocked.toLocaleString()}{" "} + {blocked === 1 ? "video is" : "videos are"} waiting on{" "} + {blockedOn.length > 0 + ? blockedOn.join(" and ") + : "an earlier run"}{" "} + and will become available as{" "} + {blockedOn.length === 1 ? "it runs" : "those run"} — nothing + to do here. + </> + ), + }, + ] + : []), + ]} + // ONE ROW PER OPERATION, always — not only when there are several. A + // single-operation lane still has to say WHICH operation, because the + // heading above it no longer does: it names a group. + rows={operations.map((k) => ({ + key: k.id, + ariaLabel: `speakers operation ${k.id}`, + node: ( + <> + <span className="font-medium">{k.label}</span>:{" "} + {k.reachableIds.length} reachable + {k.stale > 0 && ` (${k.stale} stale)`} ·{" "} + {k.missingInput.toLocaleString()} needing media + {k.deferred > 0 && ` · ${k.deferred.toLocaleString()} deferred`} + {k.blocked > 0 && ` · ${k.blocked.toLocaleString()} blocked`} + {/* THE COST BASIS, beside the backlog and never as a judgement. + 11,337 reachable videos means ~194,000 model calls when the + cost basis is the transcript chunk and ~11,337 when it is the + video, and no other line on this page can tell you which. Stated + flat: a "this is a lot" threshold would be a magic number the + next operation gets wrong, and what is affordable is the + operator's call. */} + {k.costBasis && ( + <span className="italic"> — {k.costBasis}</span> + )} + </> + ), + }))} + list={{ + slug, + ids: allReachableIds, + ariaLabel: "videos needing speaker work list", + emptyAriaLabel: "videos needing speaker work empty", + emptyMessage: anyEnabled + ? `Nothing reachable for ${actionLabel}.` + : "Nothing here is enabled.", + itemAriaLabel: (id: string) => `video needing speaker work ${id}`, + }} + > + <StreamActionLog + trigger={() => backfillChannelAction(slug, queue)} + cancelAction={cancelJobAction} + buttonLabel={runLabel} + runningLabel="Running…" + label={runLabel} + extraControls={ + <QueueControl + value={queue} + onChange={setQueue} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={runLabel} + /> + } + /> + </OperationWork> + ); +} diff --git a/editor/app/channels/[slug]/lib/channelFlow.ts b/editor/app/channels/[slug]/lib/channelFlow.ts @@ -55,7 +55,7 @@ import { // everywhere else, hidden behind a station label that named the queue rather // than the work. On the live corpus it was adding audio passes (diarization: 4 // done of 11,338) to per-chunk model calls (attribution-text: 1 done of 11,338, -// and its unit is the transcript CHUNK, not the video), and calling the result +// and its cost basis is the transcript CHUNK, not the video), and calling the result // "Backfill". A station now carries its group's OPERATIONS, each with its own // band and its own denominator, and the numeral above them belongs to exactly // one of them — see leadOf. @@ -78,7 +78,7 @@ export type StationOperation = { id: string; label: string; shortLabel: string; - // What one unit costs, in words. Printed wherever the operation is armed, so + // What one video costs, in words — the cost basis. Printed wherever the operation is armed, so // an 11,337-video backlog of per-chunk model calls cannot read as a quiet row. costBasis: string; band: OperationBand; @@ -210,7 +210,7 @@ function stationOperations( // consumes or runs beside. // // Explicitly NOT a sum, and not an average either. The three speaker operations -// are three different populations measured in two different units — one audio +// are three different populations with two different cost bases — one audio // pass per video against ~1 model call per transcript chunk — and any single // figure over all three is the mistake this station used to make. One member // owns the numeral; the rest state themselves, separately, in the foot. @@ -584,7 +584,7 @@ function pickNext( } // What a deferred video of this kind is waiting for, from the registry rather -// than hardcoded here. BackfillStage used to say "too long to diarize", which +// than hardcoded here. SpeakersStage used to say "too long to diarize", which // was true only while diarization was the sole kind that could defer. function deferredHintFor( kinds: Operation[], diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -66,7 +66,7 @@ import { PlaylistStage } from "./components/stages/PlaylistStage"; import { TranscodeStage } from "./components/stages/TranscodeStage"; import { TranscribeStage } from "./components/stages/TranscribeStage"; import { DigestStage } from "./components/stages/DigestStage"; -import { BackfillStage } from "./components/stages/BackfillStage"; +import { SpeakersStage } from "./components/stages/SpeakersStage"; import { getOperation, backfillLaneOperations, @@ -437,7 +437,7 @@ export default async function ChannelDetailPage({ } case "speakers": return ( - <BackfillStage + <SpeakersStage slug={slug} existingQueues={existingQueues} defaultQueueKey={BACKFILL_QUEUE} @@ -445,12 +445,12 @@ export default async function ChannelDetailPage({ // reads the filesystem and calls controllers, so a client component // must never import it. Only ENABLED kinds are passed — a disabled // feature reports no backfill anywhere. - kinds={laneOperations.map((kind) => { + operations={laneOperations.map((kind) => { const entry = snapshot.backfill?.[kind.id]; return { id: kind.id, label: kind.label, - // What one unit of this operation costs. Declared on the + // What one video of this operation costs — its cost basis. Declared on the // registry so the card can print it beside the backlog without // knowing anything about diarization or model calls. costBasis: kind.costBasis, diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx @@ -8,9 +8,9 @@ import type { StationWork, } from "../lib/channelGroupSections"; import { - backfillChannelGroupAction, digestChannelGroupAction, downloadChannelGroupAction, + speakersChannelGroupAction, syncChannelGroupAction, transcribeChannelGroupAction, type GroupOpResult, @@ -25,7 +25,10 @@ import { type Station = { id: StationId; - label: string; + // Fixed for four stations; the speakers station reads its label off the + // section (`speakersLabel`), because what that lane is called depends on which + // operations are switched on and only the server can ask the registry. + label: string | ((section: ChannelGroupSection) => string); // What replaces the button once the group's work is provably finished. A dead // button reading "Download 0" invites a click that would do nothing. done: string; @@ -73,13 +76,14 @@ const STATIONS: Station[] = [ run: digestChannelGroupAction, }, { - id: "backfill", - label: "Backfill", - done: "Backfill current", - notEligible: "No channel in this group has media to backfill.", + id: "speakers", + label: (section) => section.speakersLabel, + done: "Speaker work current", + notEligible: + "No channel in this group has media for a speaker operation to work on.", confirm: (group, count) => - `Backfill ${count} video(s) across every channel in "${group}" with every enabled lane kind? On a large group this is hours to days of work.`, - run: backfillChannelGroupAction, + `Run every enabled speaker operation over ${count} video(s) across every channel in "${group}"? On a large group this is hours to days of work.`, + run: speakersChannelGroupAction, }, ]; @@ -158,6 +162,10 @@ export function ChannelGroupLine({ > {STATIONS.map((station, i) => { const work = section[station.id]; + const label = + typeof station.label === "function" + ? station.label(section) + : station.label; const figure = figureOf(work, station.id); const running = status.kind === "running" && status.station === station.id; @@ -173,10 +181,10 @@ export function ChannelGroupLine({ type="button" disabled aria-label={`${station.id} group ${name}`} - title={`The ${station.label.toLowerCase()} lane is switched off in Settings. Its channels may still carry recorded work — an off lane is not a finished one.`} + title={`The ${label.toLowerCase()} lane is switched off in Settings. Its channels may still carry recorded work — an off lane is not a finished one.`} className={CHIP + " opacity-50 cursor-not-allowed"} > - {station.label} off + {label} off </button> ) : work.eligible.length === 0 ? ( <button @@ -186,7 +194,7 @@ export function ChannelGroupLine({ title={station.notEligible} className={CHIP + " opacity-50 cursor-not-allowed"} > - {station.label} + {label} </button> ) : figure !== null && work.total === 0 && work.unknown.length === 0 ? ( /* Provably nothing to do. Static text, not a dead button. */ @@ -205,7 +213,7 @@ export function ChannelGroupLine({ title={unknownTitle(work)} className={CHIP + " hover:bg-muted disabled:opacity-50"} > - {running ? "Queuing…" : station.label} + {running ? "Queuing…" : label} {figure !== null && ( <> {" "} diff --git a/editor/app/channels/groupActions.ts b/editor/app/channels/groupActions.ts @@ -48,7 +48,7 @@ const KIND_FOR: Record<StationId, string> = { download: "download-missing", transcribe: "whisper-all", digest: "digest-channel-local", - backfill: "backfill-channel", + speakers: "backfill-channel", }; const RUN_FOR: Record< @@ -78,7 +78,7 @@ const RUN_FOR: Record< // the LANE, not one operation on it — KIND_FOR above says the same thing by // mapping it to the lane's job kind. Passing no kindIds is what "all of them" // means to the runner. - backfill: (slug) => backfillChannelAction(slug), + speakers: (slug) => backfillChannelAction(slug), }; async function runGroupStation( @@ -181,9 +181,9 @@ export async function digestChannelGroupAction( return runGroupStation("digest", siteId, groupId); } -export async function backfillChannelGroupAction( +export async function speakersChannelGroupAction( siteId: string, groupId: string, ): Promise<GroupOpResult> { - return runGroupStation("backfill", siteId, groupId); + return runGroupStation("speakers", siteId, groupId); } diff --git a/editor/app/channels/lib/channelGroupSections.test.ts b/editor/app/channels/lib/channelGroupSections.test.ts @@ -257,7 +257,7 @@ test("a social channel is eligible for sync only", () => { assert.deepEqual(s.download.eligible, []); assert.deepEqual(s.transcribe.eligible, []); assert.deepEqual(s.digest.eligible, []); - assert.deepEqual(s.backfill.eligible, []); + assert.deepEqual(s.speakers.eligible, []); }); test("sync skips a channel with no url or excluded from sync", () => { @@ -276,7 +276,7 @@ test("sync skips a channel with no url or excluded from sync", () => { assert.deepEqual(sections[0].download.eligible.sort(), ["excluded", "ok"]); }); -test("backfill reports `off`, not 0, when no lane kind is enabled", () => { +test("speakers reports `off`, not 0, when no operation on the backfill lane is enabled", () => { const withWork = channel( "c", {}, @@ -285,18 +285,18 @@ test("backfill reports `off`, not 0, when no lane kind is enabled", () => { }), ); const on = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_ON); - assert.equal(on[0].backfill.laneOff, undefined); - assert.equal(on[0].backfill.total, 7); + assert.equal(on[0].speakers.laneOff, undefined); + assert.equal(on[0].speakers.total, 7); const off = build(siteOf({ channels: [{ slug: "c" }] }), [withWork], LANE_OFF); // The snapshot still records 7 — an off lane is not a finished one, and the // UI must be able to tell the difference. - assert.equal(off[0].backfill.laneOff, true); - assert.equal(off[0].backfill.total, 0); - assert.deepEqual(off[0].backfill.eligible, []); + assert.equal(off[0].speakers.laneOff, true); + assert.equal(off[0].speakers.total, 0); + assert.deepEqual(off[0].speakers.eligible, []); }); -test("digest and backfill totals are disjoint", () => { +test("digest and speakers totals are disjoint", () => { const sections = build( siteOf({ channels: [{ slug: "c" }] }), [ @@ -314,7 +314,7 @@ test("digest and backfill totals are disjoint", () => { ], ); assert.equal(sections[0].digest.total, 1204); - assert.equal(sections[0].backfill.total, 88); + assert.equal(sections[0].speakers.total, 88); }); test("digest counts reachable work, never the noDigest bucket", () => { diff --git a/editor/app/channels/lib/channelGroupSections.ts b/editor/app/channels/lib/channelGroupSections.ts @@ -15,6 +15,7 @@ import { allOperations, backfillLaneOperations, backfillLaneEntriesOf, + operationsGroupLabel, reachableOperationWork, } from "yt-dlp-transcript-common/lib/operations"; import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; @@ -31,15 +32,11 @@ import { normalizeBuckets } from "../[slug]/lib/stageStatus"; // stationWorkFor below, which is what keeps a button from acting on a different // set than the number printed on it. -export type StationId = "sync" | "download" | "transcribe" | "digest" | "backfill"; - -export const STATION_IDS: StationId[] = [ - "sync", - "download", - "transcribe", - "digest", - "backfill", -]; +// "speakers" is the backfill LANE's station: every operation sharing +// BACKFILL_QUEUE, run over a group by the lane runner. The id names what the +// station is about (the operations), not the queue — the queue key and the job +// kind behind it still say backfill, on purpose. +export type StationId = "sync" | "download" | "transcribe" | "digest" | "speakers"; export type StationWork = { // Slugs this operation applies to at all. A channel whose report says it has @@ -56,7 +53,7 @@ export type StationWork = { // Eligible channels with no snapshot at all. `total` is a FLOOR while this is // non-empty, which the UI marks with a trailing "+". unknown: string[]; - // digest/backfill only: the lane is switched off in settings. A snapshot's + // digest/speakers only: the lane is switched off in settings. A snapshot's // counts outlive the feature being switched off, so "off" is rendered instead // of 0 (which reads as finished) or — (which reads as unknown). laneOff?: boolean; @@ -70,7 +67,12 @@ export type ChannelGroupSection = { download: StationWork; transcribe: StationWork; digest: StationWork; - backfill: StationWork; + speakers: StationWork; + // What the speakers station is called, derived from the operations enabled on + // the backfill lane exactly as the channel page's stage title is — "Speakers" + // once a speaker operation is on, "Derived data" when none is (the default + // test settings), so the button never claims work its lane is not doing. + speakersLabel: string; }; // Whether a whole station is unrunnable because its lane is switched off. @@ -87,7 +89,7 @@ export function laneOffFor( if (station === "digest") { return !allOperations(settings).some((k) => k.id === DIGEST_OPERATION_ID); } - if (station === "backfill") return backfillLaneOperations(settings).length === 0; + if (station === "speakers") return backfillLaneOperations(settings).length === 0; return false; } @@ -169,7 +171,7 @@ export function stationWorkFor( return { eligible: true, work: digestWorkOf(snapshot).reachable }; } - // Backfill. backfillLaneEntriesOf, NEVER Object.values: the per-kind map carries + // Speakers — the backfill lane. backfillLaneEntriesOf, NEVER Object.values: the per-kind map carries // every catalog operation including digest, which has its own station right // beside this one. The lane filter is what keeps the two figures disjoint. if (!snapshot) return { eligible: true, work: null }; @@ -278,7 +280,10 @@ export function buildChannelGroupSections( download: station("download"), transcribe: station("transcribe"), digest: station("digest"), - backfill: station("backfill"), + speakers: station("speakers"), + speakersLabel: operationsGroupLabel( + backfillLaneOperations(settings).map((k) => k.id), + ), }; }); } diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -463,8 +463,8 @@ export function LaneDeck({ const backfillLane = ( <LaneCard name={backfillGroup} - // `anyKind` false is UNAVAILABLE, not idle: with no backfill feature - // registered there is nothing to hold, and an empty work list because a + // `anyKind` false is UNAVAILABLE, not idle: with no operation registered + // on the backfill lane there is nothing to hold, and an empty work list because a // feature is switched off must not read as "all caught up". state={deriveLaneState({ available: backfillAvailable, @@ -473,7 +473,7 @@ export function LaneDeck({ })} figure={ backfill === null || !backfillAvailable ? ( - "no backfill feature is on" + "no operation on the backfill lane is on" ) : ( <> {formatCount(backfill.reachable)} reachable @@ -490,7 +490,7 @@ export function LaneDeck({ // joining them: this lane's figure is the one that cannot survive being // summed, so the detail's whole job is to take it apart. // - // Only when there is more than one kind — the rule BackfillStage already + // Only when there is more than one kind — the rule SpeakersStage already // applies per channel. A single-kind corpus would otherwise be shown a // breakdown of itself, restating the figure one line lower. detail={ @@ -499,7 +499,7 @@ export function LaneDeck({ {laneOperations.map((k) => ( <span key={k.id} - aria-label={`backfill lane kind ${k.id}`} + aria-label={`backfill lane operation ${k.id}`} data-kind={k.id} > <span className="font-medium">{k.label}</span>{" "} diff --git a/editor/app/components/pipelines/band.ts b/editor/app/components/pipelines/band.ts @@ -44,14 +44,15 @@ export type OperationBand = { missingInput: number; // Held by a gate — stale cues, a duration window. Dotted. deferred: number; - // WHAT ONE UNIT OF THIS OPERATION COSTS, in words, from the registry. + // WHAT ONE VIDEO OF THIS OPERATION COSTS — its cost basis — in words, from + // the registry. // // The fact that hid behind a shared lane name: an operation can be armed at // enormous cost and read as a quiet row, because "11,337 reachable" is the - // same shape of number whether the unit is one audio pass per video or ~1 - // model call per transcript CHUNK — a ~17x difference on the same figure. - // Carried on the band so every surface that draws a backlog can state its - // unit beside it, with no threshold and no editorialising. + // same shape of number whether the cost basis is one audio pass per video + // or ~1 model call per transcript CHUNK — a ~17x difference on the same + // figure. Carried on the band so every surface that draws a backlog can + // state its cost basis beside it, with no threshold and no editorialising. costBasis: string; // Whether the operation is dispatched by this system at all. False for the // two external pipelines, which are here because the rail's whole point is @@ -219,12 +220,12 @@ export function bandHeadline(band: OperationBand): string { // THIS IS THE ONE PLACE SUMMING ACROSS OPERATIONS IS LEGITIMATE, and it is worth // being precise about why, because every other surface is forbidden from doing // it. The rule that forbids it (buildBands' header, backfillLaneEntriesOf's) is about a -// FIGURE IN NO UNIT: adding diarization's videos to attribution-text's videos -// gives a number that is neither, because one unit of the first is an audio pass -// and one unit of the second is ~1 model call per transcript chunk. +// FIGURE WITH NO COST BASIS: adding diarization's videos to attribution-text's +// videos gives a number that is neither, because one video of the first costs +// an audio pass and one video of the second ~1 model call per transcript chunk. // // A sweep plan is not that number. The sweep dispatches one operation on one -// video at a time, so the unit here IS "one operation on one video" — and every +// video at a time, so the basis here IS "one operation on one video" — and every // segment of this band is counted in it. The rows are directly comparable // because they all sit under the SAME scope, which is the control immediately // above them. Change the scope and every row changes together. diff --git a/editor/app/jobs/components/PauseBackfillButton.tsx b/editor/app/jobs/components/PauseBackfillButton.tsx @@ -1,52 +0,0 @@ -"use client"; - -import { LaneActionButton } from "../../components/lanes/LaneActionButton"; -import { pauseBackfillAction, resumeBackfillAction } from "../actions"; - -// Hold the backfill lane without ending anything, in the prop shape the other -// two pause buttons use ({ paused, disabled?, onChange? }). -// -// It is a REAL pause, not a stop. backfillBatch's limit() re-reads -// settings.backfill.enabled at dispatch (~3s idle poll) and returns 0, so the -// pool idle-waits, the job stays alive and keeps its place, and resuming -// re-derives nothing. It writes THE SAME FIELD the Settings checkbox writes -// rather than a second `backfillPaused` flag, so the two cannot disagree — which -// is why backfill.spec asserts the settings field through this button's label -// rather than just the label flipping. -// -// A thin wrapper over the shared lane button: the dashboard and the widget both -// reach this gate through LaneDeck, and this keeps a direct call site available -// without a second implementation. -export function PauseBackfillButton({ - paused, - disabled, - onChange, -}: { - // settings.backfill.enabled INVERTED: `paused` is the lane held. - paused: boolean; - disabled?: boolean; - onChange?: () => void | Promise<void>; -}) { - return paused ? ( - <LaneActionButton - label="Resume the lane" - glyph="▶" - ariaLabel="resume backfill" - title="Resume the backfill lane. A held job picks up within a few seconds — it was holding, not stopped." - variant="active" - action={resumeBackfillAction} - onChange={onChange} - /> - ) : ( - <LaneActionButton - label="Hold the lane" - glyph="❙❙" - ariaLabel="pause backfill" - title="Hold the backfill lane without ending anything. A running job idles at zero and keeps its place; nothing is re-derived on resume. Survives a restart." - variant="idle" - disabled={disabled} - action={pauseBackfillAction} - onChange={onChange} - /> - ); -} diff --git a/editor/app/operations/components/ArbiterBar.tsx b/editor/app/operations/components/ArbiterBar.tsx @@ -110,4 +110,4 @@ export function ArbiterBar({ const NO_RULES = "No rule names an operation yet, so there would be nothing to dispatch. " + - "Point a rule at Digest or a backfill operation first."; + "Point a rule at Digest or a speaker operation first."; diff --git a/editor/app/operations/components/OperationDetail.tsx b/editor/app/operations/components/OperationDetail.tsx @@ -98,8 +98,8 @@ export function OperationDetail({ // The lane below is SHARED — every kind on BACKFILL_QUEUE is dispatched by one // sweep and held by one gate — and the panel says so rather than letting a // per-operation page imply a per-operation switch. What IS this operation's -// alone is the band: its own five populations, its own denominator, in its own -// unit. That is the figure the shared panel could not draw before, because a +// alone is the band: its own five populations, its own denominator, its own +// cost basis. That is the figure the shared panel could not draw before, because a // lane holding three operations has no single total that means anything. function SweepOperationView({ id, @@ -158,7 +158,7 @@ function SweepOperationView({ This operation shares one lane, one sweep and one pause with{" "} {joinLabels(siblings.map((s) => s.label))}. Arming the sweep below runs whatever is ticked in its scope, not just this one — and the - counts are in different units, so read the scope before arming it. + counts have different cost bases, so read the scope before arming it. </p> )} @@ -180,7 +180,8 @@ function SweepOperationView({ // because every control on it works. // // So this says the four true things instead: what state the corpus is in (the -// band), what has to happen first (dependsOn), what one unit costs, and — in a +// band), what has to happen first (dependsOn), what one video costs (the cost +// basis), and — in a // sentence taken from the descriptor's own hint — what does dispatch it. That // last one is why `hint` is worth keeping honest: it is the whole answer here. function NoConsoleView({ diff --git a/editor/app/operations/components/OperationRail.tsx b/editor/app/operations/components/OperationRail.tsx @@ -136,12 +136,12 @@ function RailRow({ corpus reachable and needs-media are 91x apart; one "remaining" number would say the same thing about a finished lane and a lane that cannot start. */} - <Figure n={band.reachable} unit="reachable" tone="text-foreground" /> - {band.blocked > 0 && <Figure n={band.blocked} unit="blocked" />} + <Figure n={band.reachable} population="reachable" tone="text-foreground" /> + {band.blocked > 0 && <Figure n={band.blocked} population="blocked" />} {band.missingInput > 0 && ( - <Figure n={band.missingInput} unit="no media" /> + <Figure n={band.missingInput} population="no media" /> )} - {band.deferred > 0 && <Figure n={band.deferred} unit="held" />} + {band.deferred > 0 && <Figure n={band.deferred} population="held" />} <span className="tabular-nums"> {coverage === null ? "coverage unknown" @@ -150,7 +150,7 @@ function RailRow({ {/* WHAT THIS BACKLOG COSTS, only where there IS one and only for an operation this system actually dispatches. The sentence the console was missing: attribution-text sits at 77,000-odd reachable on a lane - whose unit is the transcript CHUNK — the order of 194,000 model + whose cost basis is the transcript CHUNK — the order of 194,000 model calls — and read exactly like diarization's 647 audio passes. Stated flat. A "this is a lot" threshold would be a magic number the next operation gets wrong, and what is affordable is not this page's @@ -168,19 +168,22 @@ function RailRow({ ); } +// `population`, not "unit": the word beside the number names WHICH population +// of the band this is (reachable, blocked, held), and "unit" already means one +// item of dispatchable work everywhere the arbiter and the worker route use it. function Figure({ n, - unit, + population, tone = "", }: { n: number; - unit: string; + population: string; tone?: string; }) { return ( <span> <span className={`tabular-nums ${tone}`}>{n.toLocaleString()}</span>{" "} - {unit} + {population} </span> ); } diff --git a/editor/app/operations/components/SweepLane.tsx b/editor/app/operations/components/SweepLane.tsx @@ -52,7 +52,7 @@ export function SweepLane({ }: { lane: SweepLaneStatus; // This lane's own band. For backfill that is one of several — the rail shows - // each kind separately, because summed they are a figure in no unit — so the + // each kind separately, because summed they are a figure with no cost basis — so the // panel names the kinds rather than pretending to a single total. band: OperationBand | null; onRefresh: () => void | Promise<void>; @@ -214,7 +214,7 @@ export function SweepLane({ {!lane.available && ( <p className="text-sm text-muted-foreground"> - No backfill operation is switched on, so there is no lane to run. That + No operation on the backfill lane is switched on, so there is no lane to run. That is not the same as being finished — turn one on in Settings. </p> )} @@ -320,29 +320,29 @@ function LaneFigures({ return ( <p className="text-sm text-muted-foreground"> This lane covers several operations; see the rail above for each one — a - single total across them would be a figure in no unit. + single total across them would be a figure with no cost basis. </p> ); } return ( <p className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-muted-foreground"> - <Figure n={band.reachable} unit="reachable now" tone="text-foreground" /> + <Figure n={band.reachable} population="reachable now" tone="text-foreground" /> {band.blocked > 0 && ( <> <Sep /> - <Figure n={band.blocked} unit="blocked upstream" /> + <Figure n={band.blocked} population="blocked upstream" /> </> )} {band.missingInput > 0 && ( <> <Sep /> - <Figure n={band.missingInput} unit="need media back" /> + <Figure n={band.missingInput} population="need media back" /> </> )} {band.deferred > 0 && ( <> <Sep /> - <Figure n={band.deferred} unit="held by a gate" /> + <Figure n={band.deferred} population="held by a gate" /> </> )} <Sep /> @@ -398,18 +398,22 @@ function InFlight({ lane }: { lane: SweepLaneStatus }) { ); } +// `population`, not "unit": the word beside the number names WHICH population +// of the band this is (reachable, blocked, held), and "unit" already means one +// item of dispatchable work everywhere the arbiter and the worker route use it. function Figure({ n, - unit, + population, tone = "", }: { n: number; - unit: string; + population: string; tone?: string; }) { return ( <span> - <span className={`tabular-nums ${tone}`}>{n.toLocaleString()}</span> {unit} + <span className={`tabular-nums ${tone}`}>{n.toLocaleString()}</span>{" "} + {population} </span> ); } diff --git a/editor/app/operations/components/SweepScope.tsx b/editor/app/operations/components/SweepScope.tsx @@ -12,9 +12,9 @@ import type { SweepOperation } from "../lanes"; // order of 194,000 calls corpus-wide. "Diarization and names-from-audio only" was // a settings.json hand-edit or nothing. // -// EVERY ROW STATES ITS UNIT. The three operations here have backlogs of 1, 4 and -// 11,337 — and the last is not 11,337 times the first in cost, it is far more, -// because its unit is not the video. A checkbox list without the cost basis +// EVERY ROW STATES ITS COST BASIS. The three operations here have backlogs of +// 1, 4 and 11,337 — and the last is not 11,337 times the first in cost, it is +// far more, because its cost basis is not the video. A checkbox list without the cost basis // beside it would be asking for a decision with the deciding fact left out. // // NO NEW COLOUR. An operation that is out of scope drops to muted text; nothing diff --git a/editor/app/operations/components/railStates.ts b/editor/app/operations/components/railStates.ts @@ -9,7 +9,7 @@ import { idleReasonText } from "./dispatch"; // SEVERAL OPERATIONS SHARE ONE LANE, and that is the fact worth seeing: every // backfill kind runs on one queue, so a diarization row reading "Holding" and // an attribution row reading "Holding" are one gate, not two. The rail draws -// them separately because their WORK COUNTS are in different units, and maps +// them separately because their WORK COUNTS have different cost bases, and maps // them onto the same lane state because their DISPATCH is not. // // Any operation the console has no lane for reads "Off" rather than "Idle" — diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -229,7 +229,7 @@ test("the diarized lane upgrades a text-only record", async ({ page }) => { // every stage rendered expanded. await page.goto(channelStage(CHANNEL, "speakers")); await expect( - page.getByLabel("video needing a backfill attrvid0001"), + page.getByLabel("video needing speaker work attrvid0001"), ).toBeVisible(); await runBackfill(page); @@ -315,30 +315,30 @@ test("the stage card and /actionable show attribution beside diarization, with t // video is not waiting for media, it is waiting for the diarization lane — // and re-acquiring audio for it could never have helped. await expect( - section.getByLabel("backfill kind attribution-diarized"), + section.getByLabel("speakers operation attribution-diarized"), ).toContainText("1 reachable"); await expect( - section.getByLabel("backfill kind attribution-diarized"), + section.getByLabel("speakers operation attribution-diarized"), ).toContainText("0 needing media"); await expect( - section.getByLabel("backfill kind attribution-diarized"), + section.getByLabel("speakers operation attribution-diarized"), ).toContainText("1 blocked"); // And the card names what it is waiting for, rather than just saying stuck. - await expect(section.getByLabel("backfill blocked")).toContainText( + await expect(section.getByLabel("speakers blocked")).toContainText( "Speaker diarization", ); // The text lane reaches BOTH: its input is the cue stream, which every // transcribed video has. That is exactly why running it corpus-wide is the // expensive option. await expect( - section.getByLabel("backfill kind attribution-text"), + section.getByLabel("speakers operation attribution-text"), ).toContainText("2 reachable"); await expect( - section.getByLabel("backfill kind attribution-text"), + section.getByLabel("speakers operation attribution-text"), ).toContainText("0 needing media"); // Reachable work across kinds: 1 + 2. The needs-re-acquiring figure is on its // own line and is never folded into it. - await expect(section.getByLabel("backfill reachable")).toContainText( + await expect(section.getByLabel("speakers reachable")).toContainText( "3 videos can be worked on now", ); // The card names the WORK, not the queue: three operations were hiding behind @@ -350,17 +350,17 @@ test("the stage card and /actionable show attribution beside diarization, with t // attributed from audio is waiting for the diarization lane, and no download // would have helped it. Before this, that video was reported here and would // have cost a download-and-delete under allowRedownload. - await expect(section.getByLabel("backfill needs re-acquiring")).toHaveCount(0); - await expect(section.getByLabel("backfill blocked")).toContainText("1"); + await expect(section.getByLabel("speakers needs re-acquiring")).toHaveCount(0); + await expect(section.getByLabel("speakers blocked")).toContainText("1"); // /actionable, which sums across kinds but keeps the populations in separate // columns. await page.goto("/actionable"); - const row = page.getByLabel(`backfill row ${CHANNEL}`); + const row = page.getByLabel(`speakers row ${CHANNEL}`); await expect(row).toBeVisible(); // 3 reachable, and still never added to anything else. await expect(row).toContainText("3"); - await expect(page.getByLabel("backfill", { exact: true })).toContainText( + await expect(page.getByLabel("speakers", { exact: true })).toContainText( "Needs media", ); }); diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -153,18 +153,18 @@ test("the stage card separates reachable work from what needs its media back", a await page.goto(channelStage(SLUG, "speakers")); const section = page.getByLabel("speakers section"); - await expect(section.getByLabel("backfill reachable")).toContainText( + await expect(section.getByLabel("speakers reachable")).toContainText( "1 video can be worked on now", ); - await expect(section.getByLabel("backfill needs re-acquiring")).toContainText( + await expect(section.getByLabel("speakers needs re-acquiring")).toContainText( "1", ); // The id list is the reachable half only. await expect( - section.getByLabel("video needing a backfill vidA"), + section.getByLabel("video needing speaker work vidA"), ).toBeVisible(); await expect( - section.getByLabel("video needing a backfill vidB"), + section.getByLabel("video needing speaker work vidB"), ).toHaveCount(0); }); @@ -764,7 +764,7 @@ test("actionable shows the backfill section with both numbers", async ({ await generateReport(page, SLUG); await page.goto("/actionable"); - const section = page.getByLabel("backfill", { exact: true }); + const section = page.getByLabel("speakers", { exact: true }); await expect(section).toBeVisible(); await expect(section).toContainText(SLUG); // The re-acquire count rides in its own column, exactly as "Est. reclaim" @@ -883,7 +883,7 @@ test("a digest entry in the snapshot does not move the backfill instrument", asy const backfillSection = page.getByLabel("speakers section"); // 1 — vidA's diarization. NOT 3, which is what folding the digest entry in // would produce here. - await expect(backfillSection.getByLabel("backfill reachable")).toContainText( + await expect(backfillSection.getByLabel("speakers reachable")).toContainText( "1 video can be worked on now", ); diff --git a/editor/e2e/channel-groups.spec.ts b/editor/e2e/channel-groups.spec.ts @@ -160,12 +160,14 @@ test("a station with no eligible channel is disabled and says why", async ({ await expect(transcribe).toBeDisabled(); await expect(transcribe).toHaveAttribute("title", /never runs whisper/); - // The default test settings enable no backfill lane kind, so the station reads - // "off" — NOT 0 (which reads as finished) and not — (which reads as unknown). - const backfill = page.getByLabel("backfill group News"); - await expect(backfill).toHaveText("Backfill off"); - await expect(backfill).toBeDisabled(); - await expect(backfill).toHaveAttribute("title", /not a finished one/); + // The default test settings enable no operation on the backfill lane, so the + // speakers station reads "off" — NOT 0 (which reads as finished) and not — + // (which reads as unknown). Its label is derived from the operations that are + // on, and with none on it falls back to the honest generic name. + const speakers = page.getByLabel("speakers group News"); + await expect(speakers).toHaveText("Derived data off"); + await expect(speakers).toBeDisabled(); + await expect(speakers).toHaveAttribute("title", /not a finished one/); }); test("a group figure reads — until a channel reports, then a real number", async ({ diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -2164,3 +2164,84 @@ testing the id — and reads `sweepLaneIdFor(op)` (off `lane.queueKey`, in neither sweep runs. Null renders a "no console here" panel; the id-shaped predecessor would have rendered the backfill sweep's console — with a live Start button arming a corpus-scale sweep — under a `transcode` heading. + +## Verified 2026-08-26 — the vocabulary pass (`BackfillKind` → `Operation`) + +**Names in every section above this one are PRE-RENAME.** Three commits after `e0703b4` +renamed the registry and the id spaces slice 2 handed forward. Grep the mapping below when +an older section (or a commit body) names something that no longer exists. + +**The rule that decided every rename:** "backfill" survives where it names the QUEUE/LANE +and its persisted contracts; it goes where it named the KIND concept. The backfill lane +(`BACKFILL_QUEUE`) is one queue several operations share — one pause, one sweep, one share — +and is the only thing `common/lib/operations.ts` still calls "backfill". + +| Old | New | +| --- | --- | +| `common/lib/backfillKinds.ts` (+ `.test.ts`, `backfillUnit.test.ts`) | `common/lib/operations.ts` (+ `operations.test.ts`, `operationUnit.test.ts`) | +| `BackfillKind` | `Operation` | +| `BackfillLane` | `Lane` (generic `{queueKey, contendsFor}`) | +| `BackfillState` / `BackfillClassification` | `OperationState` / `OperationClassification` | +| `BackfillCostTier` | `OperationTier` (`"inline" \| "lane"` — a tier, not a cost) | +| `BackfillDispatch` / `BackfillProbe` | `OperationDispatch` / `OperationProbe` (the VALUE `"backfill"` survives — it names the machinery) | +| `BackfillRunOptions` / `BackfillRunOutcome` / `BackfillTargetContext` | `OperationRunOptions` / `OperationRunOutcome` / `OperationTargetContext` | +| `BACKFILL_KINDS` / `BACKFILL_KIND_BY_ID` | `OPERATIONS` / `OPERATION_BY_ID` | +| `getBackfillKind` / `allBackfillKinds` | `getOperation` / `allOperations` | +| `laneBackfillKinds` | `backfillLaneOperations` — LANE-scoped (filters `queueKey === BACKFILL_QUEUE`), so the lane word stays and moves first | +| `laneEntriesOf` / `laneKindEntriesOf` | `backfillLaneEntriesOf` / `backfillLaneOperationEntriesOf` — once `Lane` is generic, "entries of WHICH lane" has to be said | +| `resolveBackfillKinds` | `resolveBackfillLaneOperations` — resolves ids WITHIN the backfill lane | +| `BackfillCounts` / `emptyBackfillCounts` | `OperationCounts` / `emptyOperationCounts` | +| `addBackfillState` / `reachableBackfillWork` / `presentBackfillWork` | `addOperationState` / `reachableOperationWork` / `presentOperationWork` | +| `BackfillSnapshotEntry` | `OperationSnapshotEntry` (the VALUE type of `snapshot.backfill[id]`; the on-disk KEY does not move) | +| `DIGEST_KIND_ID` / `DIARIZATION_KIND_ID` | `DIGEST_OPERATION_ID` / `DIARIZATION_OPERATION_ID` | +| `StationId "backfill"` (`/channels` group line) | `"speakers"`; the station's label is `ChannelGroupSection.speakersLabel`, derived like the stage title ("Derived data" under default settings) | +| `/actionable` section id `"backfill"`, `countLabel "to backfill"` | `"speakers"`, `"reachable"` | +| `BackfillStage.tsx` / `BackfillKindView` / prop `kinds` | `SpeakersStage.tsx` / `OperationRowView` / `operations` | +| aria `backfill kind <id>` / `backfill reachable\|blocked\|deferred\|needs re-acquiring` | `speakers operation <id>` / `speakers reachable\|…` | +| aria `videos needing a backfill list\|empty`, `video needing a backfill <id>` | `videos needing speaker work list\|empty`, `video needing speaker work <id>` | +| `backfillChannelGroupAction` | `speakersChannelGroupAction` (still runs `backfillChannelAction`, the LANE runner) | +| `editor/app/jobs/components/PauseBackfillButton.tsx` | deleted — zero importers since `LaneDeck` absorbed it | + +**Deliberately still "backfill", each a decision:** `BACKFILL_QUEUE`; job kinds +`backfill-channel` / `backfill-sweep`; `JobTaskKind "backfill"` (persisted on job records); +`settings.backfill.*`, `BackfillSettings`, `sanitizeBackfill`, `defaultBackfill`; +`snapshot.backfill` (on-disk key); `spec.params.kindIds` and `settings.backfill.sweepKinds` +(persisted — `jobReplayRegistry` reads `p.kindIds`); the lane runners `backfillSweep.ts`, +`backfillBatch.ts`, `backfillReacquire.ts`, `backfillAvailability.ts`, `diarize-backfill.ts`; +`backfillActions.ts` / `backfillChannelAction` / `runBackfillChannelJob` (run the LANE over +one channel); `pause|resumeBackfillAction`, `start|stopBackfillSweepAction`, the +`/api/test/resume-backfill-sweep` route; `SweepLaneId "backfill"` and `data-lane="backfill"`; +the aria-labels `pause backfill` / `resume backfill` / `start|stop backfill sweep` (they act on +the lane); the widget's `SectionId "backfill"` (serialized as `?backfill=1` / layout code `bf` +— permanent); `SweepKindCounts` and every other `*Kind*` outside the registry's export table +("one registry entry", still true). `SettingsForm.tsx`'s "Run the backfill lane" fieldset +waits for slice 3, which moves it. + +**"unit" has ONE meaning** (written beside `costBasis` in `operations.ts`): a UNIT is one item +of dispatchable work — `ArbiterUnit`, `startWorkerUnit`, `runUnitViaRemote`, +`/api/worker/unit`, job kinds `worker-unit` / `auto-download-unit` — and three of those are +persisted contracts, so the dispatch side keeps the word. What one video of an operation +costs is its COST BASIS (`costBasis`), and no surface calls that a unit: the `Figure({unit})` +prop in `OperationRail.tsx` / `SweepLane.tsx` was a population label and is `population`; +prose saying "unit" for cost says "cost basis". `DurationUnit` is a third, unrelated meaning. + +**`transcode` is registered** (`EXTERNAL_OPERATIONS`, after `download`): group `media`, +`lane: { queueKey: TRANSCRIPTION_QUEUE, contendsFor: "cpu" }`, `dependsOn: ["download"]`, +`dispatch: "external"`, no `runner`, and the first `appliesTo(config)` — +`handling === "transcribe" && !!audioFormat`, exposed as `operationApplies(id, config)` and +replacing the three verbatim copies of that gate (`channels/[slug]/page.tsx`, +`channels/[slug]/videos/page.tsx`, `stageStatus.ts`). `/operations/transcode` renders slice +2's no-console panel. **The transcode BAND is not registered and cannot be yet**: +`buildOperationBands` is snapshot-only and pure (`noCorpusWalkInRenderPaths`), `ChannelSnapshot` +carries no `handling`/`audioFormat`, so `eligible`/`present` for a channel that never +transcodes is indistinguishable from one that has finished. The honest route is the snapshot +writer recording a transcode population — a snapshot-shape change filed with unified-ops +step 1. `EXTERNAL_BAND_IDS` stays two. Side effect accepted: `settings/page.tsx` offers every +catalog id as a worker tag, so "transcode" is offerable beside "download". + +**Grep gates used, over `common/ editor/` `*.ts *.tsx`:** must be 0 — `\bBackfillKind\b`, +`backfillKinds`, `getBackfillKind`, `BACKFILL_KINDS`, `\bBackfillLane\b`, `_KIND_ID\b`, +`BackfillStage`, `PauseBackfillButton`; must be unchanged — `BACKFILL_QUEUE` 42 (+1 comment in +the new header), `"backfill-channel"` 14, `"backfill-sweep"` 9, `settings.backfill` 40 (+1 +comment), `kindIds` 58, `sweepKinds` 24, `"worker-unit"` 1, `"auto-download-unit"` 4, and +every `backfill` under `editor/app/widget/` (34). diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,9 +3,11 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Last updated:** 2026-08-26 — editor IA slice 2 (speakers are a real stage; one runner -path) landed, and slice 1 was committed with it. See the two dated sections below. -Previously: 2026-08-24 — **`main` is green again, `feat/channel-groups` is merged, and +**Last updated:** 2026-08-26 (later) — **the vocabulary pass landed**: the registry is +`operations.ts` and says `Operation`, the three id spaces slice 2 handed it say "speakers", +"unit" has one meaning, and `transcode` is a registry entry. See the dated section below. +Earlier the same day: editor IA slice 2 (speakers are a real stage; one runner path) landed, +and slice 1 was committed with it. Previously: 2026-08-24 — **`main` is green again, `feat/channel-groups` is merged, and this file caught up with two sessions it had no entry for.** The 2026-08-12 session below landed in `7d32438` on 08-18; everything from 08-19 to 08-24 — recency ordering, the arbiter, lane guards, the pipelines band, worker tags and slots, LLM fan-out, unit @@ -137,9 +139,11 @@ nothing renders. - **The `/channels` backfill group button is the LANE, not one operation**, so it does not go through the dispatcher. - **The population aria-labels still say "backfill"** — 16 spec lines, deliberately left for - the vocabulary pass to keep them out of this slice's e2e risk. + the vocabulary pass to keep them out of this slice's e2e risk. *(Done later the same day — + they say "speakers"; see the entry above.)* -**Recommended next**, unchanged from the 08-25 list except that #6 is done: +**Recommended next**, unchanged from the 08-25 list except that #6 is done and #6 below is +now **editor IA slice 3**: 1. **GPU yield on a quiet box** — still the gate on arming the sweep, still unmeasured. 2. **Unified-ops step 1**: collapse `noDigest` into `snapshot.backfill.digest`. **Slice 4 @@ -148,13 +152,49 @@ nothing renders. 4. **Phase 6 Ollama `/ask`** — genuinely independent; a good parallel task. 5. Decide `attribution-text`'s fate on that one channel: ~194,000 chunk-level calls is a sweep-sized commitment that was never priced. -6. **The vocabulary pass** — `BackfillKind` → `Operation`, `BackfillLane` → `Lane`, - `backfillKinds.ts` → `operations.ts`, plus the three id spaces slice 2 handed it - (`StationId`, the `/actionable` section id, the population aria-labels). Registering - `transcode` waits for it. Then **editor IA slice 3** (per-operation settings). +6. **Editor IA slice 3** (per-operation settings) — the vocabulary pass it waited on is + done (below); the "Speaker work lane" fieldset moves to `/operations/<id>` on the renamed + nouns. --- +### 2026-08-26 (later) — the vocabulary pass: the registry says "operation" + +Three commits after `e0703b4`, sized so the 60-file identifier sweep is reviewable on its own. +The mapping table, the "unit" rule, the list of what deliberately still says "backfill" and +the transcode-band boundary are in `FACTS.md` ("Verified 2026-08-26 — the vocabulary pass"); +this entry is the decisions. + +1. **Mechanical rename.** `backfillKinds.ts` → `operations.ts`, ~25 `Backfill*` exports → + `Operation*`, tsc-verified, zero behaviour change. The rule: "backfill" survives where it + names the LANE and its persisted contracts, goes where it named the kind. So + `laneBackfillKinds` became `backfillLaneOperations` — the lane word moved to the front + rather than vanishing, because once `Lane` is generic "the lane's operations" must say + which lane. Persisted keys (`snapshot.backfill`, `settings.backfill`, `kindIds`, + `sweepKinds`, the job kinds) are untouched; "kind" was not chased outside the export table. +2. **The id spaces.** `StationId "backfill"` → `"speakers"` on `/channels`, with the label + **derived** (`speakersLabel`, "Derived data" under default settings) rather than the + literal "Speakers" — the same rule the stage title already follows, so a button never + claims work its lane is not doing. `/actionable`'s section id → `speakers`, count column + "reachable". `BackfillStage` → `SpeakersStage`, and its populations say "speakers" (15 spec + lines). `PauseBackfillButton.tsx` deleted (zero importers). "unit" is one word: dispatch + keeps it, cost is "cost basis", the `Figure({unit})` prop was a population label and says so. +3. **`transcode` registered**, with the registry's first `appliesTo(config)` replacing three + verbatim copies of `handling === "transcribe" && !!audioFormat`. `/operations/transcode` + is the no-console panel slice 2 built. **Its band is not registered**: a snapshot carries + no `handling`/`audioFormat`, so a pure band cannot tell "never transcodes" from + "finished" — that waits for the snapshot writer to record it (unified-ops step 1). + +**Decisions worth keeping:** +- **The station label is derived, not "Speakers".** `channel-stage-selection.spec.ts` pins + "Derived data" as the honest fallback; `channel-groups.spec.ts` now asserts the same string + on the group button. +- **`backfill*Action` names stay** — `backfillChannelAction`, `pause|resumeBackfillAction`, + `start|stopBackfillSweepAction` run or gate the LANE, and their aria-labels + (`pause backfill`, `start backfill sweep`) are asserted by specs that test the lane. +- **Plans docs were not search-replaced.** They are dated history; FACTS carries the mapping + and a line saying earlier sections are pre-rename. + ### 2026-08-25 — the editor gets its noun: `/operations`, and a nav with four groups **The backend found its organizing noun — the operation — and the UI had not.** Counted diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md @@ -60,7 +60,7 @@ and concludes the model was wrong. | **Transcode** | A per-video media operation that is simply **missing from the catalog** — it has a state per video, a lane and a dependency. Register it as an external descriptor, group `media`, `dependsOn: ["download"]`, with an `appliesTo?(channelConfig)` so a channel that never transcodes does not grow a dead row. Do it when the stage list becomes registry-derived (slice 2). | | **Social channels** | A channel whose operation set is `{fetch-posts}`. An explicit **non-goal** here: leave the short-circuit at `editor/app/channels/[slug]/page.tsx:156` alone. | | **The channel stage list** | Half-derived and that is correct. The middle (`download … backfill`) derives from `OPERATION_GROUP_ORDER`; the bookends (`configure`, `playlist` / `cleanup`, `diagnostics`, `danger`) are **channel chores**, not operations, and stay hand-listed. Say so in the code so the next reader does not "finish" the derivation. | -| **Digest's two lanes** | A **registry defect**, not a noun problem. `common/controller/arbiter.ts:118-124` special-cases `DIGEST_KIND_ID` because digest runs on its own queue. Give every kind a `laneFor(settings)` defaulting to `lane` — diarization already has one (`common/lib/backfillKinds.ts:599`) — and the special case dissolves. | +| **Digest's two lanes** | A **registry defect**, not a noun problem. `common/controller/arbiter.ts` special-cased `DIGEST_KIND_ID` because digest runs on its own queue. **Resolved in slice 2** by giving digest its own `laneFor(settings)` and resolving every operation through `laneForOperation` — but NOT by "giving every kind a `laneFor` defaulting to `lane`": `laneFor` is optional and its PRESENCE is what `backfillBatch.ts` keys the GPU idle-only rule off, so a default would enrol every operation in that rule (FACTS.md, "laneFor? is OPTIONAL"). Add one only where the lane genuinely varies. | | **The widget** | A **projection** of the board payload, not a noun. Drop "Monitor" from the primary nav; reach it from the dashboard's pipeline band, next to the thing it mirrors. | ## The nav, end state @@ -165,14 +165,14 @@ dependencies allow. Sizes are S/M/L. `instrumentation.ts:91-122`. **L, high risk — and not before the sweep has run once for a day and been watched** (STATE.md "Recommended next" #1: GPU yield on a quiet box). **L.** -**Vocabulary renames follow slice 2, mechanically and in one commit** (slice 2 has shipped, so -this is now the next mechanical pass; it also inherits from slice 2 the `StationId` rename, the -`/actionable` section id, and the stage card's population aria-labels — `backfill reachable`, -`backfill kind …`, `backfill blocked/deferred/needs re-acquiring`, `videos needing a backfill …` -— which were deliberately left spelling "backfill" to keep 16 spec lines out of that slice)**:** `BackfillKind` → -`Operation`, `BackfillLane` → `Lane`, `backfillKinds.ts` → `operations.ts`, and "unit" -disambiguated (a worker-pool unit is not a cost unit). Doing them earlier would collide with -every slice above; doing them later means writing `backfill` in surfaces that no longer say it. +**Vocabulary renames — shipped, 2026-08-26, in three commits rather than one** (a 60-file +identifier sweep is only reviewable when nothing else is in it): `BackfillKind` → `Operation`, +`BackfillLane` → `Lane`, `backfillKinds.ts` → `operations.ts`; then the id spaces slice 2 handed +forward (`StationId "backfill"` → `"speakers"` with a derived label, the `/actionable` section +id, `BackfillStage` → `SpeakersStage` and its population aria-labels) and "unit" disambiguated +(a dispatch unit keeps the word; cost is "cost basis"); then `transcode` registered. The +mapping, the rule that decided each name and the list of what deliberately still says +"backfill" are in `FACTS.md` ("Verified 2026-08-26 — the vocabulary pass"). ## Out of scope, stated so silence is not read as a decision diff --git a/plans/unified-operations-model.md b/plans/unified-operations-model.md @@ -44,8 +44,8 @@ from `snapshot.buckets[name]`. A leaf gains an `operation` selector alongside `b ## What Phases A–C already put in place -- **`BackfillLane { queueKey, contendsFor }`** on every registered operation - (`common/lib/backfillKinds.ts`). This is the arbiter's input: `queueKey` is the concurrency +- **`BackfillLane { queueKey, contendsFor }`** (now `Lane`) on every registered operation + (`common/lib/backfillKinds.ts`, now `operations.ts`). This is the arbiter's input: `queueKey` is the concurrency domain, `contendsFor` is the scarce resource. `laneYieldsToTranscription()` is the first rule derived from it, and `digestBatch` already consults it rather than re-testing the app id.