Pattern catalog: pinned library fingerprints as hint-only recipe evidence

Ported from PR #15: a SHA256-pinned catalog of 49 generic library and
platform fingerprints (carousel/consent/chat/commerce vendors) with an
O(1) signature index. Catalog hits strengthen recipe recognition (small
confidence deltas, marquee->logo-cloud, carousel false-grid skip,
lightbox->gallery, commerce prior) and are recorded in the manifest as
pattern evidence with node references - they never override captured
geometry, which remains gated by the existing plan-agreement checks.
The integrity pin is unconditional: a catalog/lock mismatch always
throws (the fork's env-var soft-warning mode is removed).

515 tests pass (10 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 14:25:58 -07:00
co-authored by Claude Fable 5
parent 7e8863fc7e
commit f991f469cf
8 changed files with 504 additions and 11 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* Pattern index: O(1) signature lookup over the frozen pattern catalog
* (data/pattern-catalog.json). Matches known widget/platform/animation
* signatures against the IR's diagnostic evidence (srcClass, tags, attrs)
* and produces deterministic PatternHints consumed as a generation artifact
* (patterns.json) and as an ADDITIVE evidence source for recipe recognition.
*
* Determinism contract: the catalog is FROZEN data pinned by sha256
* (data/pattern-catalog.lock). Same catalog + same IR => byte-identical hints.
* There is no learning layer here — hints are catalog-only by construction.
* The pin is UNCONDITIONAL: a catalog/lock mismatch (or a missing lock) always
* throws, so pinned evidence can never drift silently.
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { IR, IRNode, IRChild } from "../normalize/ir.js";
export type PatternMatchSpec = {
/** exact class-token equality (lowercase) */
classTokens?: string[];
/** class-token prefix, e.g. "elementor-", "wp-block-" */
classPrefixes?: string[];
/** element tag names, e.g. custom elements like "lottie-player" */
tags?: string[];
/** attribute presence, e.g. "data-aos" */
attrNames?: string[];
/** id-attribute prefix, e.g. "shopify-section" */
idPrefixes?: string[];
};
export type PatternDef = {
id: string;
kind: string;
flags: string[];
match: PatternMatchSpec;
};
export type PatternCatalog = {
version: number;
description?: string;
patterns: PatternDef[];
};
export type PatternMatch = {
id: string;
kind: string;
flags: string[];
/** how many IR nodes matched at least one signature of this pattern */
count: number;
/** first few matching node ids, pre-order (bounded sample for diagnostics) */
cids: string[];
};
export type PatternHints = {
catalogVersion: number;
catalogHash: string;
/** matched patterns, sorted by id for determinism */
matches: PatternMatch[];
/** union of flags across matches, sorted */
flags: string[];
/** platform_* flags with the prefix stripped, sorted (e.g. ["elementor","wordpress"]) */
platforms: string[];
/** no deferred-interactive / motion-lib / counter / nav-toggle signatures and a small tree —
* callers may skip optional capture/inference stages for such pages */
simpleStatic: boolean;
};
const CATALOG_PATH = fileURLToPath(new URL("../../data/pattern-catalog.json", import.meta.url));
const LOCK_PATH = fileURLToPath(new URL("../../data/pattern-catalog.lock", import.meta.url));
const SAMPLE_CIDS = 5;
/** simpleStatic requires the whole tree under this node count (matches "small landing page"). */
const SIMPLE_STATIC_MAX_NODES = 1500;
const SIMPLE_STATIC_BLOCKERS = ["deferred_interactive", "motion_lib", "counter", "nav_toggle"];
type CompiledIndex = {
catalog: PatternCatalog;
hash: string;
byClassToken: Map<string, PatternDef[]>;
classPrefixes: Array<[string, PatternDef]>;
byTag: Map<string, PatternDef[]>;
byAttrName: Map<string, PatternDef[]>;
idPrefixes: Array<[string, PatternDef]>;
};
let cached: CompiledIndex | null = null;
function addTo<K>(map: Map<K, PatternDef[]>, key: K, def: PatternDef): void {
const list = map.get(key);
if (list) list.push(def);
else map.set(key, [def]);
}
export function loadPatternIndex(): CompiledIndex {
if (cached) return cached;
const raw = readFileSync(CATALOG_PATH, "utf8");
const hash = createHash("sha256").update(raw).digest("hex");
const catalog = JSON.parse(raw) as PatternCatalog;
if (!Array.isArray(catalog.patterns)) throw new Error("pattern catalog: missing patterns[]");
const idx: CompiledIndex = {
catalog,
hash,
byClassToken: new Map(),
classPrefixes: [],
byTag: new Map(),
byAttrName: new Map(),
idPrefixes: [],
};
for (const p of catalog.patterns) {
for (const t of p.match.classTokens ?? []) addTo(idx.byClassToken, t.toLowerCase(), p);
for (const pre of p.match.classPrefixes ?? []) idx.classPrefixes.push([pre.toLowerCase(), p]);
for (const t of p.match.tags ?? []) addTo(idx.byTag, t.toLowerCase(), p);
for (const a of p.match.attrNames ?? []) addTo(idx.byAttrName, a.toLowerCase(), p);
for (const pre of p.match.idPrefixes ?? []) idx.idPrefixes.push([pre, p]);
}
cached = idx;
return idx;
}
/** Enforce the frozen-catalog pin. UNCONDITIONAL: a missing lock or a hash
* mismatch always throws — pinned evidence must never drift silently, so the
* generator refuses to run against an unpinned/edited catalog. Regenerate the
* lock deliberately after a catalog edit with `--write-lock`. */
export function assertPinnedCatalog(): void {
const idx = loadPatternIndex();
if (!existsSync(LOCK_PATH)) {
throw new Error("pattern catalog lock missing (data/pattern-catalog.lock)");
}
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
if (pinned !== idx.hash) {
throw new Error(
`pattern catalog hash mismatch: pinned ${pinned.slice(0, 12)}… actual ${idx.hash.slice(0, 12)}`,
);
}
}
function isElement(c: IRChild): c is IRNode {
return (c as IRNode).id !== undefined;
}
/** Match ONE node's own evidence (srcClass tokens, tag, attr names, id prefix)
* against the catalog. Shared by the page-level hint scan and per-candidate
* recipe scoring. Returns matched defs, deduped, in catalog order. */
export function matchCatalogNode(node: Pick<IRNode, "tag" | "attrs" | "srcClass">): PatternDef[] {
const idx = loadPatternIndex();
const hits = new Set<PatternDef>();
if (node.srcClass) {
for (const tok of node.srcClass.toLowerCase().split(/\s+/)) {
if (!tok) continue;
for (const def of idx.byClassToken.get(tok) ?? []) hits.add(def);
for (const [pre, def] of idx.classPrefixes) if (tok.startsWith(pre)) hits.add(def);
}
}
for (const def of idx.byTag.get(node.tag.toLowerCase()) ?? []) hits.add(def);
for (const attr of Object.keys(node.attrs)) {
for (const def of idx.byAttrName.get(attr.toLowerCase()) ?? []) hits.add(def);
}
const id = node.attrs.id;
if (id) for (const [pre, def] of idx.idPrefixes) if (id.startsWith(pre)) hits.add(def);
return idx.catalog.patterns.filter((p) => hits.has(p));
}
/** Single deterministic pre-order walk of the IR: tokenize srcClass, check tag /
* attr / id signatures, accumulate per-pattern counts + a bounded cid sample.
* Asserts the frozen-catalog pin first (throws on mismatch). */
export function resolvePatternHints(ir: IR): PatternHints {
const idx = loadPatternIndex();
assertPinnedCatalog();
const counts = new Map<string, { def: PatternDef; count: number; cids: string[] }>();
const hit = (def: PatternDef, cid: string, seen: Set<string>) => {
if (seen.has(def.id)) return; // one hit per node per pattern
seen.add(def.id);
let e = counts.get(def.id);
if (!e) { e = { def, count: 0, cids: [] }; counts.set(def.id, e); }
e.count++;
if (e.cids.length < SAMPLE_CIDS) e.cids.push(cid);
};
const visit = (node: IRNode) => {
const seen = new Set<string>();
for (const def of matchCatalogNode(node)) hit(def, node.id, seen);
for (const c of node.children) if (isElement(c)) visit(c);
};
visit(ir.root);
const matches: PatternMatch[] = [...counts.values()]
.map((e) => ({ id: e.def.id, kind: e.def.kind, flags: e.def.flags, count: e.count, cids: e.cids }))
.sort((a, b) => a.id.localeCompare(b.id));
const flags = [...new Set(matches.flatMap((m) => m.flags))].sort();
const platforms = flags
.filter((f) => f.startsWith("platform_"))
.map((f) => f.slice("platform_".length))
.sort();
const simpleStatic =
ir.doc.nodeCount < SIMPLE_STATIC_MAX_NODES && !flags.some((f) => SIMPLE_STATIC_BLOCKERS.includes(f));
return { catalogVersion: idx.catalog.version, catalogHash: idx.hash, matches, flags, platforms, simpleStatic };
}
// `--write-lock` maintenance entry: refresh the pin after a deliberate catalog edit.
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href && process.argv.includes("--write-lock")) {
const idx = loadPatternIndex();
writeFileSync(LOCK_PATH, idx.hash + "\n");
console.log("pinned pattern catalog:", idx.hash);
}