#!/usr/bin/env tsx // The one-core Phase 1 measurement: every live number a lane could move, // printed deterministically so two runs can be diffed. // // WHY THIS IS A SCRIPT AND NOT A TEST. Phase 1 rewires dispatch over a corpus // of 78,000 videos that no fixture reproduces. The claim each slice makes is // "no live number moved", and the only way to check it is to read the real // `transcripts/` before and after and diff two files. A test would have to // carry the expected numbers, and they change every time a sweep runs. // // STRICTLY READ-ONLY. It opens `settings.json` and the 68 // `channels//snapshot.json` and writes nothing anywhere. It must never be // made to regenerate a snapshot: that is a WRITE into production data, and the // numbers it would then "verify" would be the ones it just produced. // // NEVER BOOT AN EDITOR FOR THIS. computeLeafPending is called in-process, // offline; instrumentation.ts is not loaded, so no runner, sweep or scheduler // is armed against the live corpus. // // LANE COVERAGE WAS DRIVEN BY THE FILE UNTIL SLICE 1.3, and now it is driven by // the SANITIZED, MIGRATED settings — all four lanes. The reason it was the file // is that slice 1.1 only ADDED two lanes to the in-memory default, so printing // them would have been noise rather than evidence. Slice 1.3 migrates the two // sweeps onto those lanes, which makes them real config with a real provenance, // and the whole claim of the slice is what they migrated TO. // // THE FILE'S LANES ARE STILL PRINTED FIRST, in the order they were printed // before, and the migrated ones are appended. That is deliberate: it keeps every // pre-existing line byte-identical and in place, so the before/after diff is // PURE ADDITION and can be read at a glance. // // The pause flags are the four PauseLane values, which have all existed since // pauseGates.ts landed. `digest.sweepEnabled` and `backfill.sweepEnabled` are // read off the RAW FILE now rather than off the settings type, which no longer // has them: they are the migration's INPUT, and the line stays until an operator // cleans the retired keys out of settings.json. // // Usage, from anywhere in the repo: // pnpm --filter yt-dlp-transcript-common exec tsx ../plans/tools/phase1-numbers.ts import { readFile } from "node:fs/promises"; import { getPaths } from "../../common/lib/paths"; import { getSettings } from "../../common/lib/settings"; import { isGateHeld, type PauseLane } from "../../common/lib/pauseGates"; import { listChannelConfigs, readChannelSnapshot, } from "../../common/controller/channels"; import { computeLeafPending } from "../../common/controller/autoRunner"; import { LANES, type AutoQueueKind } from "../../common/lib/autoQueueTypes"; import { flattenLeaves } from "../../common/jobs/autoQueuePolicy"; // Every count field on OperationSnapshotEntry, in a fixed order. `partial` is // here alongside the four the plan names because it is part of OperationCounts // and IS summed into the reachable set — a slice that moved it would otherwise // move `ids.length` with no visible cause. const COUNT_FIELDS = [ "missing", "stale", "partial", "deferred", "blocked", "missingInput", "eligible", ] as const; // The buckets the two existing lanes draw from, plus the opt-in one. Not // selectableBucketsForKind(): this list must not change when that function does, // or the diff would report a code change as a data change. const BUCKETS = [ "partialDownloads", "downloadedNoTranscript", "failedListed", "downloadedAutoSubsOnly", ] as const; const PAUSE_LANES: ReadonlyArray = [ "backfill", "digest", "download", "transcription", ]; type Row = Record; function add(into: Row, from: Row): void { for (const [k, v] of Object.entries(from)) into[k] = (into[k] ?? 0) + v; } function printRow(prefix: string, row: Row): void { for (const key of Object.keys(row).sort()) { console.log(`${prefix} ${key} = ${row[key]}`); } } // THE OUTPUT IS A DIFF, so nothing but this script may write to it. // // `computeLeafPending` on the digest lane prices its candidates, and the recency // index logs a `[recency] dating N of M undated candidates …` line whose numbers // move between runs on the same corpus. One nondeterministic line in a file // whose entire purpose is to be compared byte for byte would make every future // slice's evidence unreadable, so diagnostics from common/ are dropped here // rather than the log being deleted from a library that is right to have it. function silenceLibraryLogs(): void { const real = console.log.bind(console); console.log = (...args: unknown[]) => { if (typeof args[0] === "string" && args[0].startsWith("[")) return; real(...(args as [])); }; } async function main(): Promise { silenceLibraryLogs(); const paths = getPaths(); const settings = getSettings(); // The raw file, for the two lines below that name keys the settings TYPE no // longer has, and for "which lanes does the file itself spell". const raw = JSON.parse(await readFile(paths.settingsFile, "utf8")) as { autoQueue?: Record; digest?: Record; backfill?: Record; }; console.log("# one-core phase 1 — live numbers"); console.log(`# settingsFile: ${paths.settingsFile}`); console.log(`# channelsDir: ${paths.channelsDir}`); console.log(""); // --- flags --------------------------------------------------------------- console.log("## flags"); // THE FOUR RETIRED PAUSE FIELDS ARE NOT PRINTED, because they do not exist. // `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the // inverted `backfill.enabled` were deleted by S0-pause; the loop below prints // the same four answers off the key that replaced them. Nothing under plans/ // is in a tsconfig, so tsc never caught these — they would have printed // `undefined` for every lane on every corpus, under the old names, beside the // right answer. // // The two `sweepEnabled` reads stay: they come off the RAW file, are retired // keys this script exists to report on, and are read as `=== true` rather // than dereferenced. for (const lane of PAUSE_LANES) { console.log(`held ${lane} = ${isGateHeld(settings, lane)}`); } console.log(`digest.sweepEnabled = ${raw.digest?.sweepEnabled === true}`); console.log(`digest.remoteEnabled = ${settings.digest.remoteEnabled}`); console.log(`backfill.sweepEnabled = ${raw.backfill?.sweepEnabled === true}`); console.log(""); // --- lanes --------------------------------------------------------------- // The file's own lanes first, in the order this script has always printed // them, then whatever the migration filled in. Additions only. const fileLanes = Object.keys(raw.autoQueue ?? {}).sort(); const laneOrder: AutoQueueKind[] = [ ...(fileLanes as AutoQueueKind[]), ...LANES.filter((l) => !fileLanes.includes(l)), ]; console.log("## lanes (from settings.json autoQueue)"); for (const lane of laneOrder) { const policy = settings.autoQueue[lane]; if (!policy) { console.log(`${lane} MISSING FROM SANITIZED SETTINGS`); continue; } console.log(`${lane} enabled = ${policy.enabled}`); console.log(`${lane} order = ${policy.order ?? "listed"}`); console.log(`${lane} maxWorkers = ${policy.maxWorkers}`); console.log(`${lane} replaceAutoSubs = ${policy.replaceAutoSubs === true}`); // THE LEAF SHAPES, on one line, because the tree is what a sweep's scope // migrated INTO and "enabled: false" alone would not say what it was armed // on. Matches only: ids and weights are the sanitizer's business. console.log( `${lane} leaves = ${JSON.stringify( flattenLeaves(policy.root).map((l) => l.match), )}`, ); } console.log(""); console.log("## computeLeafPending (per lane, per leaf, sorted by leaf id)"); for (const lane of laneOrder) { const pending = await computeLeafPending(lane as AutoQueueKind, paths); let total = 0; for (const leafId of Object.keys(pending.counts).sort()) { console.log(`${lane} leaf ${leafId} = ${pending.counts[leafId]}`); total += pending.counts[leafId]; } console.log(`${lane} TOTAL = ${total}`); console.log( `${lane} nextUp = ${pending.nextUp ? `${pending.nextUp.leafId}/${pending.nextUp.videoId}` : "none"}`, ); } console.log(""); // --- snapshots ----------------------------------------------------------- const configs = await listChannelConfigs(paths); const slugs = configs.map((c) => c.slug).sort(); console.log(`## snapshots (${slugs.length} channels)`); const opTotals: Record = {}; const bucketTotals: Row = {}; let undownloadedTotal = 0; let missingSnapshots = 0; for (const slug of slugs) { const snap = await readChannelSnapshot(paths, slug); if (!snap) { console.log(`${slug} NO SNAPSHOT`); missingSnapshots += 1; continue; } const undownloaded = snap.undownloadedIds?.length ?? 0; undownloadedTotal += undownloaded; console.log(`${slug} undownloadedIds.length = ${undownloaded}`); const buckets = (snap.buckets ?? {}) as Record; for (const name of BUCKETS) { const n = buckets[name]?.length ?? 0; bucketTotals[name] = (bucketTotals[name] ?? 0) + n; console.log(`${slug} bucket ${name} = ${n}`); } const backfill = snap.backfill ?? {}; for (const op of Object.keys(backfill).sort()) { const entry = backfill[op] as unknown as Record; const row: Row = {}; for (const field of COUNT_FIELDS) { row[field] = typeof entry[field] === "number" ? (entry[field] as number) : 0; } row["ids.length"] = Array.isArray(entry.ids) ? entry.ids.length : 0; printRow(`${slug} op ${op}`, row); opTotals[op] ??= {}; add(opTotals[op], row); } } console.log(""); console.log("## totals"); console.log(`channels = ${slugs.length}`); console.log(`missingSnapshots = ${missingSnapshots}`); console.log(`undownloadedIds.length = ${undownloadedTotal}`); for (const name of [...BUCKETS].sort()) { console.log(`bucket ${name} = ${bucketTotals[name] ?? 0}`); } for (const op of Object.keys(opTotals).sort()) { printRow(`op ${op}`, opTotals[op]); } } main().then( () => process.exit(0), (err) => { console.error(err); process.exit(1); }, );