Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions designbot/01-composer-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# The Composer Contract

Rules for the model that **assembles** a generation request. These never reach the image
model. If you are writing the renderer-facing prompt, you want a per-type file instead.

The contract exists because assembly is where the expensive bugs live. An image model
given a coherent prompt fails gracefully — it produces something a bit off. An image model
given a *self-contradicting* prompt fails confidently: it picks one side, ignores the
other, and returns something that looks deliberate. Nobody files a bug against output that
looks deliberate.

---

## 1. Assembly order is fixed

Emit blocks in this order. Order is load-bearing: multimodal models weight early tokens
more heavily, and identity constraints buried under a spec body get overridden by the
spec.

1. **Identity / character block** — only if the request involves a locked character
2. **Reference-attribute block** — only if references carry per-reference notes
3. **Type instruction** — "Render this `<type>` specification as a high-fidelity image"
4. **Payload** — the JSON spec or the user's prose prompt
5. **Quality locks** — negative constraints, from the per-type file

Burrow's `buildRecipePrompt` follows 1 → 3 → 4. The camera clause is injected at the
*front* of the prompt for exactly this reason (`injectCameraAngle` prepends rather than
appends). Treat that as the precedent: anything that must not be overridden goes early.

## 2. Mutual exclusion — the rules that matter most

These pairs must never appear in the same payload. This table is the distilled form of our
worst production bug.

| A | B | Why they cannot co-occur |
|---|---|---|
| Blending preamble ("contribute ONE attribute, ignore character/costume") | Character-fidelity block ("render the character EXACTLY") | Direct contradiction. The blend instruction tells the model to discard identity; the fidelity block tells it to preserve identity. Result: wrong or blended characters. |
| Character-fidelity | Character-preservation | Different base assumptions. *Fidelity* means "references define the character, synthesize new." *Preservation* means "the attached image IS the work, modify it." Emitting both leaves the model guessing which image is canonical. |
| "Uniform flat color, no gradients" | Any brand spec that defines an intended gradient | We shipped this. The instruction contradicted the brand's own documented belly→back gradient. Always read the brand spec before emitting a color-uniformity constraint. |
| Multi-image / variant-sheet language | Single-image request | Produces a contact sheet of variants instead of one image. |
| Label or field names from the spec | The rendered payload | Spec labels leak into the image as literal text. Strip keys; emit values. |

**When the request genuinely needs both sides** — e.g. "keep the character, but pull the
background from this other reference" — do not emit both blocks. Emit the fidelity block,
and scope the blend instruction to the *specific attribute* and the *specific image index*:
"Image 3: extract ONLY the background palette. Ignore its character, pose, and
composition." Burrow's `buildBlendingPreamble` does this correctly at the per-reference
level; the bug was that the global preamble fired alongside it.

## 3. Precedence when something still conflicts

Resolve in this order, highest wins:

1. **Character identity** — species, anatomy, signature features. Never negotiable.
2. **Brand palette and quality locks** — the hexes and the prohibitions.
3. **Explicit user instruction** for this request.
4. **Type defaults** from the per-type file.
5. **Model preference** — whatever it would have done anyway.

If a user instruction conflicts with character identity, the user is asking for a
different character. Say so; do not silently produce a hybrid.

## 4. Reference budget

The API slot budget is **4 images**. Everything below follows from that.

- **Sort by weight before you slice.** Never let insertion order decide. Burrow shipped a
bug where dropped (100) and gallery (80) references filled all four slots and pushed the
canonical mascot out entirely — the code took the first four in insertion order.
- **Weights must be distinct.** A reference pool where every entry sits at the same weight
is not a ranking, and the slice becomes arbitrary. Burrow's live config has six mascot
references all at weight 70, which means two of them — deterministically the last two —
never reach the model. Nobody noticed, because output was merely slightly worse.
- **Suggested bands.** Explicit user selection 100 · canonical brand reference 90 ·
session pins 80 · vocabulary auto-attach 70 · general style pool 50.
- **Re-apply boosts after any recalculation.** If a layer rebuilds weights from stored
values, caller-applied boosts are silently lost. Either re-boost downstream or move the
boost into the stored field. Do not assume a boost survives a merge.
- **Log what got dropped.** A silent top-N truncation reads as "we used your references."

## 5. Guard the data boundary

Types describe schema *intent*. They do not describe what the database actually returns
after years of nullable columns and enum drift. Three production crashes in Burrow shared
this exact shape: the type claimed a value was present and in-enum, `tsc` saw nothing, and
it blew up at runtime.

Before composing, at the load boundary:

- **Coerce nullable arrays to `[]`.** `colors`, `vocabulary`, `assetProfiles`,
`referenceImages` are all nullable in practice. Burrow's live `colors` is empty, and a
downstream `.map` crashed production.
- **Guard enum-keyed lookups.** Never index a config object with a value that came from
the database without a fallback. Live data contains `mascotView: "left-side"`, which is
not a member of the `MascotView` union — it crashed a `VIEW_CONFIG[x].label` lookup.
- **Treat missing as missing, not as default.** If the canonical reference is absent, say
so and degrade explicitly. Do not quietly substitute the highest-weighted alternative
and present the result as on-brand.

## 6. What the composer must not do

- **Do not paraphrase hex values.** Emit `#6242e0`, never "a deep violet." Paraphrase is
how palettes drift.
- **Do not invent facts to fill a gap.** If the brand config has no voice guidelines, the
request goes out without voice guidelines. An invented rule becomes canon the moment
someone reads the output and believes it.
- **Do not narrate.** "I will now render…" ends up in the image as text.
- **Do not emit more than one type file's rules.** They carry competing palettes and
composition defaults; a model holding all of them averages them.

## 7. Pre-flight checklist

Before dispatch, verify:

- [ ] Exactly one character block (fidelity **or** preservation **or** neither)
- [ ] No blend preamble if a character block is present, unless scoped per-image-index
- [ ] Color-uniformity constraints checked against the brand spec's own gradients
- [ ] References sorted by weight, sliced to 4, with distinct weights and drops logged
- [ ] Nullable config arrays coerced; enum lookups guarded
- [ ] Quality locks appended from exactly one per-type file
- [ ] No spec keys, no narration, no paraphrased hexes in the payload
162 changes: 162 additions & 0 deletions designbot/02-source-material-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Authoring Source Material

How to add a reference image, a vocabulary keyword, or an asset profile so that it
actually changes output. This is the file to read before uploading anything.

---

## The problem this file exists to solve

Burrow's schema is excellent and almost entirely unpopulated. Measured against the live
`brand_config` on 2026-08-04:

| Field | Schema supports | Live value |
|---|---|---|
| `mascot_prompt` | free text | **2,073 chars** |
| `reference_images` | rich per-ref metadata | 6 refs, **all metadata null** |
| `colors` | `BrandColor[]` | **0 entries** |
| `vocabulary` | `BrandKeyword[]` | **0 entries** |
| `asset_profiles` | `AssetProfile[]` | **0 entries** |
| `identity` · `voice` · `color_scales` | full objects | **null** |

One prose blob is carrying the entire brand. Every structured field that prompt assembly
already reads — and it does read them, `buildStyleWeightedReferences` copies nine metadata
fields onto every weighted reference — arrives empty.

**That is the opportunity.** These fields are already wired end to end. Populating them is
pure source-material work with no code change and no deploy.

---

## A reference is inert until it has four things

Uploading an image gets you a slot in the pool. It does not get you influence. Every
reference needs:

**1. A distinct weight.** Not the default. If everything is 70, the top-4 slice is
arbitrary and the last entries never reach the model. Pick from the bands in
`01-composer-contract.md` §4 and make sure no two references in the same category tie.

**2. `isCanonical` set on exactly one reference per subject.** This is the fallback the
resolver uses when no explicit default is configured (`getDefaultReference` looks for a
canonical before falling back to highest-weight). Leaving it null everywhere means the
fallback path is guesswork. Live config: null on all six.

**3. A real `description`.** It ships to the model as semantic context — this is the field
that tells the renderer *what it is looking at*. Live config: empty string on all six.
"Mascot Dibs Refrence Front" is a filename, not a description. (It is also misspelled, and
that misspelling is now load-bearing in the data.)

**4. A `promptInjection`, if the reference implies a rule.** Free text appended when this
reference is used — e.g. `"maintaining the flat vector style with bold uniform outlines
and no visible fur texture"`. This is where a reference stops being a picture and starts
being a constraint. Live config: null on all six.

Then, per category, fill the matching characteristics object — `mascotCharacteristics`,
`diagramCharacteristics`, `sceneCharacteristics`, `objectCharacteristics`,
`textureCharacteristics`. The two that earn their keep fastest:

- **`alwaysInclude`** — features that must appear. For Dibs: oversized pink ears, large
dark circular eyes, pink tail with orange tuft.
- **`neverInclude`** — features to avoid. This is a negative constraint attached to the
asset rather than to the prompt, which means it survives every prompt rewrite.

## Enum discipline

Off-enum values crash the app and silently disable conditioning. Both are live right now.

`MascotView` accepts exactly: `front` · `side` · `back` · `three-quarter` · `expression` ·
`action` · `other`.

Live data violates this three ways:

- `"left-side"` is **not a member** — it crashed a `VIEW_CONFIG[x].label` lookup in
production.
- `"Front Third"` is tagged `front`; it is a **three-quarter** view.
- `"Back Third"` is tagged `back`; also **three-quarter**.

The mislabels do not crash anything. They just quietly mean the model never receives a
correctly-labeled three-quarter reference, so three-quarter renders are conditioned on
front and back images. That is the more dangerous failure of the two, because it has no
symptom.

**Rule: pick the enum member, or extend the enum in code first. Never invent a value at
data-entry time.**

## Vocabulary and alias mining

The matcher (`matchVocabulary`) is deliberately simple: case-insensitive, **word-boundary**
regex against `keyword` plus `aliases`. It will not match substrings, so "Dibs" correctly
fails to fire on "distribution" — good. But it also means it is **exactly as good as the
aliases you write, and no better.**

For every keyword, list the phrases a teammate would actually type:

```jsonc
{
"keyword": "Dibs",
"aliases": ["dibs", "the mascot", "our mascot", "the jerboa", "the character"],
"referenceIds": ["<canonical-front>", "<three-quarter>", "<expression>"],
"category": "mascot",
"autoInclude": true,
"promptPrefix": "the brand mascot character"
}
```

Four things worth knowing:

- **`autoInclude` defaults to true.** Omitting it means the references attach. Set it
`false` explicitly if you want the keyword to add context without pulling images.
- **`promptPrefix` is wrapped as `[Context: …]` and prepended.** Keep it to a noun phrase.
It is not a sentence and not an instruction.
- **Aliases are the whole ballgame.** A teammate who types "make the worm wave" gets
nothing unless "the worm" is an alias. Write aliases for how people talk, not for how
the asset is named.
- **`promptPrefixes` can be dropped by the caller.** `matchVocabulary` returns both an
`enhancedPrompt` and a bare `referenceIds` list; assembly paths that consume only the
IDs discard the prefix. Verify your path uses `enhancedPrompt` if you rely on prefixes.

## Asset profiles

Use a profile when several references share one identity — the six views of one character
are the canonical case. Author the characteristics **once** on the profile and link the
references by `profileId`; `applyProfile` merges profile characteristics onto each ref.

This is strictly better than repeating characteristics per reference, because it makes the
identity single-sourced. When the character's canon changes, one row changes.

## Mapping to `brand_config` columns

| Package concept | Column | Notes |
|---|---|---|
| Character identity prose | `mascot_prompt` | Already rich. Keep, but promote its facts into structured fields. |
| Palette | `colors` | Empty. Fill from `sandworm/design/palette.md`. |
| Color ramps | `color_scales` | Null. Sandworm's 7 families × 15 stops map directly onto `ColorScale`. |
| Semantic roles | `semantic_colors` | Null. Sandworm's light/dark token sets map onto `SemanticColorMapping`. |
| Brand name, tagline, logos | `identity` | Null. |
| Voice do/don't | `voice` | Null. Sandworm `DESIGN.md` § Do's and Don'ts is the source. |
| Keywords | `vocabulary` | Empty. |
| Shared character facts | `asset_profiles` | Empty. |

**Three columns in the TypeScript type have no database column at all:**
`defaultDiagramRef`, `defaultSceneRef`, `defaultObjectRef`. The type declares them;
`brand_config` has only `default_mascot_ref` and `default_texture_ref`.

This matters directly for diagrams — our first-priority output type — because **you
cannot currently set a default diagram reference.** Setting it appears to work in the type
system and vanishes on write. That is the same double-silent-drop shape that disabled
vocabulary and asset profiles before migration 0003: a field present in the UI and the
type, but missing from both the DB column set and the PUT allowlist. No error, no
persistence, `tsc` clean.

Adding those three columns is a small migration and it unblocks diagram defaults. Until
then, diagram references must be attached explicitly per request.

## Before you commit a change here

- [ ] Weight is distinct within its category
- [ ] Exactly one `isCanonical` per subject
- [ ] `description` is a description, not a filename
- [ ] Enum values are real members
- [ ] Aliases cover how people actually talk
- [ ] Any new rule traces to shipped code or an explicit brand decision
Loading