Pre-generation Sanity Check¶
Run this validation before triggering the Generation Runner on any .nbflow. It catches the bug classes that don't surface until generation runs — model mismatches, outputCount drift, dynamic variableName mismatches, dangling links, and stale per-node link refs. It also runs one advisory check — wardrobe consistency — which warns rather than aborts.
What this check catches¶
| Bug class | Symptom in production | What this check verifies |
|---|---|---|
| Wrong image model | Generations look "off" or use NanoBanana 1 features | model == "nano_banana_2" on every NanobananaAPI |
outputCount defaulted to 1 |
Only 1 candidate per gen, no gallery to pick from | outputCount == 4 on every gen node |
Dynamic variableName mismatch |
Generations are unrelated to the prompt (literal {scene} sent to NB) |
Every template placeholder has a matching Dynamic variableName |
Veo3 missing negativePrompt |
r.trim is not a function toast on PatchWork import |
negativePrompt is a string (empty OK) |
| Veo3 missing input slots | Import fails, third frame slot missing | Each Veo3 has 3 input slots |
| Object-shaped dynamic rows | r.trim is not a function on generation |
dynamicRows are flat strings |
| Template input slot name doesn't match placeholder | PatchWork removes the input on import, link destroyed | Template input slot names = placeholders |
| Dangling links | UI shows orphan edges, ref images don't reach G-Labs | Every link target slot exists on the target node |
| Stale per-node link refs (Cached Media bug, fan-out renumber bug) | UI-driven generation silently drops avatar refs | output.links and input.link match the canonical links table |
| Wardrobe drift (advisory — warns, never aborts) | Avatar's outfit changes scene-to-scene within a video | Each wardrobe_LOCKED field is extremely similar across every scene of a tab |
The validation script¶
import json
import re
import difflib
def sanity_check(nbflow_path):
data = json.load(open(nbflow_path))
for tab in data['tabs']:
nodes = {n['id']: n for n in tab['graphData']['nodes']}
# Model + outputCount on every gen node
for n in nodes.values():
if n.get('type') == 'nanobanana/NanobananaAPI':
assert n['properties'].get('model') == 'nano_banana_2', \
f'wrong model on node {n["id"]}'
assert n['properties'].get('outputCount') == 4, \
f'outputCount wrong on node {n["id"]}'
if n.get('type') == 'nanobanana/Veo3':
assert n['properties'].get('outputCount') == 4, \
f'outputCount wrong on node {n["id"]}'
# Importer-emitted files often leave negativePrompt undefined → .trim() crash
assert isinstance(n['properties'].get('negativePrompt'), str), \
f'Veo3 {n["id"]} missing negativePrompt string'
# Veo3 needs 3 input slots (prompt, start frame, end frame) with SPACES
assert len(n.get('inputs', [])) == 3, \
f'Veo3 {n["id"]} has {len(n.get("inputs",[]))} inputs, expected 3'
# Dynamic variableName matches every {placeholder} in every template
templates = {
m for n in nodes.values()
if n.get('type') == 'nanobanana/Prompt'
and n.get('properties', {}).get('templateMode')
for m in re.findall(r'\{([a-zA-Z_]\w*)\}', n['properties'].get('text', ''))
}
varnames = {
n['properties'].get('variableName')
for n in nodes.values()
if n.get('type') == 'nanobanana/Prompt'
and n.get('properties', {}).get('dynamicMode')
}
assert templates <= varnames, \
f'unmatched placeholders: {templates - varnames}'
# Dynamic rows must be flat strings (objects → r.trim() crash)
for n in nodes.values():
if n.get('type') == 'nanobanana/Prompt' and n.get('properties', {}).get('dynamicMode'):
for i, r in enumerate(n['properties'].get('dynamicRows', [])):
assert isinstance(r, str), \
f'dynamic {n["id"]} row[{i}] is {type(r).__name__}, must be str'
# Template input slot name MUST equal the placeholder in its text
# (otherwise PatchWork's _syncTemplateInputs removes the slot on import,
# destroying the connected link)
for n in nodes.values():
if n.get('type') == 'nanobanana/Prompt' and n.get('properties', {}).get('templateMode'):
placeholders = re.findall(r'\{(\w+)\}', n['properties'].get('text', ''))
input_names = [i.get('name') for i in n.get('inputs', [])]
for ph in placeholders:
assert ph in input_names, \
f'template {n["id"]} text references {{{ph}}} but no input slot named {ph!r}'
# Link validator — every link's target_slot must exist on the target node
for l in tab['graphData']['links']:
link_id, src, src_slot, tgt, tgt_slot, _ = l
target_inputs = nodes[tgt].get('inputs', [])
assert tgt_slot < len(target_inputs), \
f'dangling link {link_id} -> #{tgt} slot {tgt_slot} (only {len(target_inputs)} inputs)'
# Output.links AND input.link must match the canonical links table
# (no stale per-node refs after renumber / fan-out / variant patch)
expected_out, expected_in = {}, {}
for l in tab['graphData']['links']:
expected_out.setdefault((l[1], l[2]), []).append(l[0])
expected_in[(l[3], l[4])] = l[0]
for n in nodes.values():
for si, out in enumerate(n.get('outputs', []) or []):
assert sorted(out.get('links') or []) == sorted(expected_out.get((n['id'], si), [])), \
f'node {n["id"]} output[{si}].links stale: refs not in central links array'
for si, inp in enumerate(n.get('inputs', []) or []):
assert inp.get('link') == expected_in.get((n['id'], si)), \
f'node {n["id"]} input[{si}].link stale: ref does not match central links array'
# Wardrobe consistency — ADVISORY (warns, never aborts).
# Within a tab, each wardrobe_LOCKED field should be extremely similar
# across every scene. Compared field-by-field so the warning names WHICH
# garment drifted: 'shirt' drift is almost always a real bug; 'hat' drift
# is often an intentional indoor/outdoor change. 'explicit_excludes' is
# negative-prompt scaffolding, not a garment — skipped.
field_values = {} # field name -> [(node_id, value), ...]
for n in nodes.values():
if n.get('type') != 'nanobanana/Prompt':
continue
try:
wl = json.loads(n['properties'].get('text', '')).get('wardrobe_LOCKED')
except (json.JSONDecodeError, TypeError, AttributeError):
continue
if not isinstance(wl, dict):
continue
for field, val in wl.items():
if field == 'explicit_excludes' or not isinstance(val, str):
continue
field_values.setdefault(field, []).append((n['id'], val.lower().strip()))
for field, vals in field_values.items():
if len(vals) < 2:
continue
base_id, base = vals[0]
for nid, v in vals[1:]:
sim = difflib.SequenceMatcher(None, base, v).ratio()
if sim < 0.85:
print(f'WARDROBE DRIFT [{tab["name"]}]: "{field}" on node {nid} '
f'only {sim:.0%} similar to node {base_id} — review for consistency')
print(f"{nbflow_path}: all checks passed")
How to use it¶
Save the function above as scripts/_sanity_check.py (or copy into a one-off script), then:
Or import and call from a build/patch script:
Every assertion that fires names the specific node ID and what's wrong. Fix one at a time, rerun, repeat until clean.
The wardrobe check is advisory, not a hard gate
Every check above the wardrobe block is an assert — it aborts the run. The wardrobe-consistency block is the exception: it print()s WARDROBE DRIFT warnings and keeps going. The bar is "extremely similar," not "byte-identical" — same strictness tier as environment consistency. Because it compares field-by-field, the warning names the garment. A "shirt" drift is almost always a real bug worth fixing; a "hat" drift is usually an intentional indoor/outdoor change you can wave off after a glance. A reworded sleeve detail stays above the 0.85 threshold and won't fire; a sage shirt that became a blue shirt drops well below it and will.
When to run¶
- After the PatchWork Importer emits a fresh
.nbflow - After any fan-out script that duplicates tabs
- After any variant patcher that mutates the graph
- After any manual edit in the PatchWork web UI that saves a new file
- Before invoking the Generation Runner (always)
After fixing¶
When a check fails:
- Read the assertion message — it tells you exactly which node and which property
- Fix the property (the Node Types Reference covers what each one should be)
- Resync per-node link refs if the fix touched any links — use
manager/scripts/_lib_link_refs.py(resync_link_refs(tab)+assert_clean(workflow)) - Rerun the sanity check
Repeat until the script prints "all checks passed."
Why we have this script¶
Each line in this script corresponds to a real production bug that cost time to diagnose:
- The model-mismatch line exists because we shipped a full Generation Runner pass on NanoBanana 1 by accident — the model field wasn't set, G-Labs defaulted to NB1, nobody noticed until output was audited
- The
variableNamecheck exists because we generated 9 images that all looked like generic supermarket scenes — the literal string{scene}was being sent to NanoBanana - The link-ref sync check exists because a fan-out script left 414 stale per-node link refs, and UI-driven generation silently dropped 26 of 30 avatar reference connections
- The Veo3 input slot check exists because the PatchWork Importer was emitting 2-slot Veo3 nodes, causing
r.trim is not a functiontoasts on import - The wardrobe-consistency check exists because an avatar's outfit could drift scene-to-scene inside a single video — a sage shirt in scene 1, something else by scene 4 — and nothing caught it until a human watched the finished cut
Running this script before every generation pass is the cost of avoiding all of those bugs again.