Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 0241df1b65171770f5c1d1bfe198a04e74b9e69e
parent 002d49d8be4effcc1823ab8f0c2f02939db3380b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:02:15 -0400

tags: a rule can exclude channels, and the exclusion wins

"Every channel except the one that floods the tag" was only expressible as an
allow-list of every other channel, which the operator then had to keep in sync
by hand as channels were added. `channelsExclude` on a rule is applied after
`channels` and beats it: a slug in both is excluded, because naming a channel
to skip is the more specific statement. It reaches the compiler and the one
`ruleApplies` every kind already routes through, so needsCaptionCues and
needsChatCues predict it for free.

curatedTagsPreview.ts had a second copy of that predicate, and the copy stopped
predicting the evaluation it guards the moment this field existed; it now calls
the exported one instead of restating it. The /tags preview drops a channel
from its scan set only when EVERY rule excludes it — one rule's exclusion is
not the tag's.

TRAP: the exclude list is part of hashCuratedRules, so every stored
curatedRulesHash is now stale and the FIRST build-index after this re-derives
the whole corpus once (~80 s, out of LMDB, no cue re-reads). The build log's
"curated tags: rules <hash8> (changed)" line is the operator's signal that the
pass was this migration and not a rule they edited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/curatedTagsIndex.test.ts | 21+++++++++++++++++++++
Mcommon/controller/curatedTagsIndex.ts | 9+++++++++
Mcommon/controller/curatedTagsPreview.ts | 0
Mcommon/lib/curatedTags.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/curatedTags.ts | 35++++++++++++++++++++++++++++++-----
Meditor/app/tags/actions.ts | 18++++++++++++++++--
Meditor/app/tags/components/EditorTagsClient.tsx | 35++++++++++++++++++++++++++++++-----
Meditor/e2e/tags.spec.ts | 42+++++++++++++++++++++++++++++++++++++++++-
8 files changed, 233 insertions(+), 13 deletions(-)

diff --git a/common/controller/curatedTagsIndex.test.ts b/common/controller/curatedTagsIndex.test.ts @@ -144,6 +144,27 @@ test("hashCuratedRules ignores presentation, reacts to derivation", () => { ]), base, ); + // An exclusion changes which records the rule derives, so it is in the hash + // — and the empty list it replaces must not be, or the field's arrival would + // re-derive the corpus a second time. + assert.notEqual( + hashCuratedRules([ + { + ...COLLAB_RULE, + rules: [{ ...COLLAB_RULE.rules![0], channelsExclude: ["mommaocco"] }], + }, + ]), + base, + ); + assert.equal( + hashCuratedRules([ + { + ...COLLAB_RULE, + rules: [{ ...COLLAB_RULE.rules![0], channelsExclude: [] }], + }, + ]), + base, + ); // A disabled rule is not part of the derivation at all. assert.equal( hashCuratedRules([ diff --git a/common/controller/curatedTagsIndex.ts b/common/controller/curatedTagsIndex.ts @@ -64,6 +64,14 @@ function sha1(s: string): string { // The shape of the vocabulary that can change a record. Presentation fields // (label, colour, group, hidden) are deliberately absent: relabelling a tag // must not re-page 30,000 videos. +// +// ONE-TIME COST WHEN THIS SHAPE CHANGES: the hash is stored per build, so +// adding a field to it (channelsExclude, 2026-09-22) makes every stored +// `curatedRulesHash` stale and the first build-index after the change +// re-derives the whole corpus once — ~80 s, read back out of LMDB with no cue +// re-read. The log line below says `rules <hash8> (changed)` when that happens; +// that sentence is the operator's signal that the pass was the migration and +// not a rule they edited. export function hashCuratedRules(defs: CuratedTagDef[]): string { const shape = defs.map((def, i) => [ def.id, @@ -75,6 +83,7 @@ export function hashCuratedRules(defs: CuratedTagDef[]): string { r.kind, r.pattern, [...(r.channels ?? [])].sort(), + [...(r.channelsExclude ?? [])].sort(), r.dateFrom ?? null, r.dateTo ?? null, ]), diff --git a/common/controller/curatedTagsPreview.ts b/common/controller/curatedTagsPreview.ts Binary files differ. diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts @@ -432,6 +432,92 @@ test("channel scope and date range are applied before any regex", () => { assert.deepEqual(evaluateTagRules({ ...hit, uploadDate: undefined }, [scoped]), []); }); +test("an excluded channel never fires, and beats the allow-list", () => { + // The shape this exists for: every channel EXCEPT the one that floods the + // tag, with no allow-list to keep in sync as channels are added. + const wide: CuratedTagDef = { + id: "wide", + label: "Wide", + rules: [ + { + id: "r1", + kind: "metadata", + pattern: "elfpire", + channelsExclude: ["mommaocco"], + enabled: true, + }, + ], + }; + const hit = { ...base, title: "elfpire" }; + assert.deepEqual(evaluateTagRules(hit, [wide]), ["wide"]); + assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "mommaocco" }, [wide]), []); + + // A slug in BOTH lists is excluded — the exclusion is the more specific + // statement, so it wins rather than the allow-list re-admitting the channel. + const both: CuratedTagDef = { + ...wide, + rules: [ + { + ...wide.rules![0], + channels: [base.channelSlug, "mommaocco"], + channelsExclude: ["mommaocco"], + }, + ], + }; + assert.deepEqual(evaluateTagRules(hit, [both]), ["wide"]); + assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "mommaocco" }, [both]), []); + // And the allow-list still bounds the rule on its own. + assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "other" }, [both]), []); + + // The exclusion applies to the cue kinds too — they route through the same + // ruleApplies, which is exactly what needsCaptionCues/needsChatCues predict. + const compiled = compileTagRules([ + { + id: "cap", + label: "Cap", + rules: [ + { + id: "r1", + kind: "caption", + pattern: "elfpire", + channelsExclude: ["mommaocco"], + enabled: true, + }, + ], + }, + ]); + const captionCues = [{ text: "and then elfpire said" }]; + assert.deepEqual(evaluateCompiledRules({ ...base, captionCues }, compiled), ["cap"]); + assert.deepEqual( + evaluateCompiledRules( + { ...base, channelSlug: "mommaocco", captionCues }, + compiled, + ), + [], + ); +}); + +test("sanitizeTagsConfig cleans channelsExclude like channels, and omits it when empty", () => { + const read = (raw: object) => + (sanitizeTagsConfig({ + tags: [{ id: "t", rules: [{ kind: "metadata", pattern: "x", ...raw }] }], + }).tags[0].rules ?? [])[0]; + + const rule = read({ channelsExclude: ["a", " ", 3, " b "] }); + assert.deepEqual(rule.channelsExclude, ["a", "b"]); + // Idempotent: sanitizing an already-sanitized rule changes nothing. + assert.deepEqual(read(rule), rule); + + // An empty (or all-junk, or absent) exclude emits no key at all, so a rule + // nobody touched round-trips through the store byte-identically. + for (const raw of [{}, { channelsExclude: [] }, { channelsExclude: [" ", 7] }]) { + assert.equal( + Object.prototype.hasOwnProperty.call(read(raw), "channelsExclude"), + false, + ); + } +}); + test("a disabled rule never fires, and neither does a broken one", () => { const defs = sanitizeTagsConfig({ tags: [ diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts @@ -47,6 +47,11 @@ export type CuratedTagRule = { pattern: string; // Channel slugs this rule may fire on. Absent or empty = every channel. channels?: string[]; + // Channel slugs this rule may NEVER fire on, applied AFTER `channels`. A slug + // in both lists is excluded: naming a channel to skip is the more specific + // statement, and the shape this exists for — "every channel except that one" + // — needs no allow-list at all. Absent or empty = exclude nothing. + channelsExclude?: string[]; // Inclusive upload-date bounds, compared digit-wise so both "20260101" and // "2026-01-01" work. null/absent = unbounded. dateFrom?: string | null; @@ -164,6 +169,16 @@ function tryCompile(pattern: string): { re: RegExp } | { error: string } { } } +// A rule's channel scope, either side of it. Shared so the allow-list and the +// exclude list can never drift into sanitizing differently. +function channelList(v: unknown): string[] { + return Array.isArray(v) + ? v + .filter((c): c is string => typeof c === "string" && c.trim() !== "") + .map((c) => c.trim()) + : []; +} + function coerceRule(raw: unknown, index: number): CuratedTagRule | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record<string, unknown>; @@ -173,11 +188,8 @@ function coerceRule(raw: unknown, index: number): CuratedTagRule | null { // A rule with no pattern cannot mean anything; drop it. if (pattern.trim() === "") return null; const id = trimmedString(r.id) ?? `r${index + 1}`; - const channels = Array.isArray(r.channels) - ? r.channels - .filter((c): c is string => typeof c === "string" && c.trim() !== "") - .map((c) => c.trim()) - : []; + const channels = channelList(r.channels); + const channelsExclude = channelList(r.channelsExclude); const dateFrom = dateBound(r.dateFrom); const dateTo = dateBound(r.dateTo); const rule: CuratedTagRule = { @@ -186,6 +198,7 @@ function coerceRule(raw: unknown, index: number): CuratedTagRule | null { pattern, enabled: r.enabled !== false, // default true ...(channels.length > 0 ? { channels } : {}), + ...(channelsExclude.length > 0 ? { channelsExclude } : {}), ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), }; @@ -430,6 +443,8 @@ export type CompiledTagRule = { re: RegExp; // null = every channel. channels: Set<string> | null; + // null = exclude nothing. Applied after `channels` — see ruleApplies. + channelsExclude: Set<string> | null; dateFrom: string | null; dateTo: string | null; }; @@ -473,6 +488,10 @@ export function compileTagRules(defs: CuratedTagDef[]): CompiledTagRules { rule.channels && rule.channels.length > 0 ? new Set(rule.channels) : null, + channelsExclude: + rule.channelsExclude && rule.channelsExclude.length > 0 + ? new Set(rule.channelsExclude) + : null, dateFrom: dateBound(rule.dateFrom), dateTo: dateBound(rule.dateTo), }; @@ -502,6 +521,12 @@ export function ruleApplies( input: { channelSlug: string; uploadDate?: string }, ): boolean { if (rule.channels && !rule.channels.has(input.channelSlug)) return false; + // The exclude wins over the allow-list on purpose: a slug in both is + // excluded, so "this whole group, minus the one that floods it" is one rule + // rather than an allow-list the operator has to keep in sync by hand. + if (rule.channelsExclude && rule.channelsExclude.has(input.channelSlug)) { + return false; + } if (rule.dateFrom || rule.dateTo) { const date = (input.uploadDate ?? "").replace(/\D/g, ""); if (!date) return false; diff --git a/editor/app/tags/actions.ts b/editor/app/tags/actions.ts @@ -160,9 +160,23 @@ export async function previewTagRuleAction(input: { // "every channel", and then so does the tag. const rules = input.def.rules ?? []; const unscoped = rules.some((r) => !r.channels || r.channels.length === 0); - const channels = unscoped - ? [] + const scoped = unscoped + ? allSlugs : Array.from(new Set(rules.flatMap((r) => r.channels ?? []))); + // A channel leaves the scan set only when EVERY rule excludes it — one rule's + // exclusion is not the tag's, and the preview has to scan what the build + // would. (ruleApplies gates each rule per video regardless, so this is about + // not reading channels nothing can match, never about correctness.) + const excluded = new Set( + (rules[0]?.channelsExclude ?? []).filter((slug) => + rules.every((r) => (r.channelsExclude ?? []).includes(slug)), + ), + ); + const kept = scoped.filter((s) => !excluded.has(s)); + // An empty list means "every slug" to previewTagRule, so a tag that excluded + // everything must not fall through to a full scan — but it also cannot scan + // nothing, so hand it the scoped set and let ruleApplies reject each video. + const channels = kept.length > 0 ? kept : scoped; const result = previewTagRule(paths, { def: input.def, channels, diff --git a/editor/app/tags/components/EditorTagsClient.tsx b/editor/app/tags/components/EditorTagsClient.tsx @@ -38,7 +38,11 @@ const KINDS: { value: CuratedTagRuleKind; label: string; hint: string }[] = [ { value: "caption", label: "Caption", hint: "the transcript's cue text" }, ]; -type RuleRow = CuratedTagRule & { key: string; channelsText: string }; +type RuleRow = CuratedTagRule & { + key: string; + channelsText: string; + channelsExcludeText: string; +}; type TagRow = { key: string; @@ -71,6 +75,7 @@ function toRow(def: CuratedTagDef): TagRow { ...r, key: mkKey(), channelsText: (r.channels ?? []).join(", "), + channelsExcludeText: (r.channelsExclude ?? []).join(", "), })), }; } @@ -89,16 +94,20 @@ function toDef(row: TagRow): CuratedTagDef { ...(row.rules.length > 0 ? { rules: row.rules.map((r) => { - const channels = r.channelsText - .split(",") - .map((c) => c.trim()) - .filter(Boolean); + const slugs = (text: string) => + text + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + const channels = slugs(r.channelsText); + const channelsExclude = slugs(r.channelsExcludeText); return { id: r.id.trim() || "rule", kind: r.kind, pattern: r.pattern, enabled: r.enabled !== false, ...(channels.length > 0 ? { channels } : {}), + ...(channelsExclude.length > 0 ? { channelsExclude } : {}), ...(r.dateFrom ? { dateFrom: r.dateFrom } : {}), ...(r.dateTo ? { dateTo: r.dateTo } : {}), } satisfies CuratedTagRule; @@ -615,6 +624,21 @@ function TagCard({ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground" /> </label> + {/* Applied AFTER the allow-list, and it wins: a slug in both is + excluded. "Every channel except that one" is then one rule + with an empty Channels box. */} + <label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground"> + Except (comma-separated; wins over Channels) + <input + value={rule.channelsExcludeText} + onChange={(e) => + patchRule(rule.key, { channelsExcludeText: e.target.value }) + } + list="tag-rule-channels" + aria-label="rule exclude channels" + className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground" + /> + </label> <label className="flex w-28 flex-col gap-1 text-[11px] text-muted-foreground"> From <input @@ -670,6 +694,7 @@ function TagCard({ pattern: "", enabled: true, channelsText: "", + channelsExcludeText: "", }, ], }) diff --git a/editor/e2e/tags.spec.ts b/editor/e2e/tags.spec.ts @@ -36,7 +36,7 @@ type TagsFile = { label: string; color?: string; hidden?: boolean; - rules?: { id: string; kind: string }[]; + rules?: { id: string; kind: string; channelsExclude?: string[] }[]; }[]; assignments: Record< string, @@ -170,6 +170,46 @@ test("a rule previews against the index, and Pin writes the operator's provenanc ); }); +test("an excluded channel is saved on the rule and drops out of the preview", async ({ + page, +}) => { + await resetData("curated-tags-channel"); + await withIndex(page); + await addTag(page, "synthetic", "Synthetic", { pattern: "small synthetic" }); + + await page.goto("/tags"); + const card = page.getByTestId("tag-card").first(); + await card.getByRole("button", { name: "preview synthetic" }).click(); + await expect(page.getByTestId("preview-row")).toHaveCount(1); + + // Excluding the only channel in the fixture is the whole assertion: the + // preview has to predict the build, and the build will not fire this rule + // there any more. + const rule = card.getByTestId("tag-rule").first(); + await rule.getByLabel("rule exclude channels").fill(SLUG); + await card.getByRole("button", { name: "preview synthetic" }).click(); + await expect(page.getByTestId("preview-row")).toHaveCount(0); + await expect(page.getByTestId("tag-preview")).toContainText("0 matches"); + + await page.getByRole("button", { name: "save tags" }).click(); + await expect(page.getByTestId("tags-saved")).toBeVisible(); + expect((await tagsFile()).tags[0].rules?.[0].channelsExclude).toEqual([SLUG]); + + // Cleared, it leaves no key behind — a rule nobody scoped round-trips as it + // was written. + await page.goto("/tags"); + await page + .getByTestId("tag-rule") + .first() + .getByLabel("rule exclude channels") + .fill(""); + await page.getByRole("button", { name: "save tags" }).click(); + await expect(page.getByTestId("tags-saved")).toBeVisible(); + expect((await tagsFile()).tags[0].rules?.[0]).not.toHaveProperty( + "channelsExclude", + ); +}); + // THE COMPRESSION REGRESSION, from both ends. // // buildIndex writes the index compressed; a reader that opens it without