A retry should not become a new creative decision.
When a video job fails halfway through and runs again, the user still asked for the same scene. If a timestamp or retry count changes the lookup, the system pays for another clip. If the lookup ignores a model route or seed, it can return the wrong artifact with a perfectly valid URL.
I built the video generation pipeline’s cache around that split. The hash is the system’s definition of sameness.
1. The request definition gets trimmed before hashing
The compiler produces a GenerationContract: prompt inputs, selected model route, generation mode, constraints, seed, and execution metadata. Only the artifact-defining fields enter the digest.
The implementation lives in lib/scene-compiler/ast-cache.ts:
/**
* Hash the compiled AST to a 64-char hex string (SHA-256).
* Identical contracts produce identical hashes regardless of
* volatile per-request fields like timestamps or scene IDs.
*/
export function hashCompiledAST(contract: GenerationContract): string {
const content = extractHashableContent(contract)
return createHash("sha256").update(content).digest("hex")
}
/**
* Build a cache key with the `ast:` prefix for Supabase lookup.
*/
export function buildCacheKey(contract: GenerationContract): string {
return `ast:${hashCompiledAST(contract)}`
}
Abstract Syntax Tree (AST) hashing is the mechanism; classification is the design. The hashable content includes sourceInputs.prompt, sourceInputs.imageUrl, sorted referenceImageUrls, chosenModel, chosenEndpoint, generationMode, sorted constraints, and seed ?? null. Volatile fields such as compiledAt, sceneId, idempotencyKey, and retries stay out.
The cost is ongoing ownership. Every new field in the request shape needs a decision: artifact identity or run history. Ambiguity becomes either wasted generation or false reuse.
2. Normalization removes accidental difference
Constraints are structured values, and array order can reflect construction path rather than meaning. I normalize them before serialization:
function sortConstraints(
constraints: SceneConstraint[]
): Array<{ type: string; target: string; value: unknown }> {
return constraints
.map((c) => ({ type: c.type, target: c.target, value: c.value }))
.sort((a, b) => {
const typeCompare = a.type.localeCompare(b.type)
if (typeCompare !== 0) return typeCompare
return a.target.localeCompare(b.target)
})
}
That rule makes two request definitions with the same constraint set hash together even if they were assembled in a different order. The tradeoff is explicit: ordering cannot carry semantic weight here. If priority later depends on position, this function has to change before the data model does.
3. The two bugs sit on opposite sides
Hash too much, and every retry misses. compiledAt lets the clock break reuse. retries turns attempt two into a separate artifact. idempotencyKey belongs to transport safety, so it should not alter creative identity.
Hash too little, and stale output looks correct. Prompt-only reuse ignores chosenModel, chosenEndpoint, generationMode, and seed. That failure is worse than a miss: the pipeline receives a real video URL and continues with the wrong clip.
Tests pin the promises: metadata changes such as compiledAt, sceneId, and idempotencyKey preserve hashCompiledAST, while buildCacheKey must match ^ast:[a-f0-9]{64}$. The same file also defines provenance-aware steering hashes with a separate steer: prefix, so namespacing is part of the storage contract.
Retries keep a stable artifact identity, and different routes and seeds stay isolated. A content-addressable lookup is only as correct as the equality rule behind it; write that rule in the language of the generated thing, then let the hash enforce it.
