# Pipeline contract — **AS-BUILT (2026-07-31)**

This started as the build agreement between five parallel agents. It is now the
as-built description of what actually exists and what the interfaces really are;
where the original agreement and the code diverged, **the code won and this file
was corrected**. Source design:
`.agents/plans/clothing-pipeline-unification-2026-07-31.md`.

Corrections against the original (2026-07-31 pre-build) text are flagged
**[as-built]** so anyone holding the old version can see what moved.

## File ownership (parallel build — do not edit outside your lane)

| Owner | Files |
|---|---|
| A (orchestrator) | `clothing/garment.py` |
| B (lint gates) | `clothing/gates/g2_census.py`, `clothing/gates/g8_catalog_lint.py` |
| C (pose gates) | `clothing/gates/g5_posed_sweep.py` (G3 = its `--rest` mode) |
| D (MD template) | `tools/tailor/draft_garment.py`, `tools/tailor/qc_placement.py`, `clothing/gates/g1_drape.py` |
| E (game side) | `ariki-game/tools/targeted_reimport.sh`, `ariki-game/tools/clothing_motion_qa.sh`, `ariki-game/src/Testing/ClothingTestBed.cs` (env override only) |

**Nobody** touches: `clothing/garment_pipeline.py`, `configs/pari.json`, `configs/piupiu.json`
(weight-fix agent owns those), `configs/piupiu_sb.json`, `.agents/wiki/**`. New test/demo
configs go in `clothing/configs/tests/`. No commits, no pushes.

## Config resolution

- `garment.py` resolves `extends` (single parent, path relative to `configs/`): deep-merge
  child over parent; **arrays and scalars replace whole**, objects merge per-key.
- The resolved config is written to `work/<name>/resolved.json`. Every stage and gate is
  invoked with that file, never the raw config. `<name>` comes from the child config.

### Path absolutization **[as-built]**

Before `resolved.json` is written, `garment.py` rewrites these fields from relative to
**absolute**, resolving against the `clothing/` directory (the same base
`garment_pipeline.py` uses as `HERE`). Already-absolute values are untouched:

```
source · body · parts.*.texture · md.avatar_fbx · md.zfab · md.texture
md.snapshot · md.presim_snapshot · md.export_dir · export.out_dir
```

This is not cosmetic. The three consumer families disagree about what a relative path
is relative to:

| Consumer | Base for a relative path |
|---|---|
| `garment_pipeline.py` | `HERE` = `clothing/` (`garment_pipeline.py:265`, `:1005`) |
| `tools/tailor/draft_garment.py` | the **config file's own directory**, then repo root |
| `clothing/gates/g1_drape.py` (`_resolve`) | the **config file's own directory**, then repo root, then cwd |

`resolved.json` lives in `work/<name>/`, so `"texture": "../tools/tailor/textures/piupiu.png"`
resolves correctly for the pipeline and to the non-existent `clothing/work/tools/...` for
the other two. Absolutizing at the resolver makes all three agree. Asserted by
`garment.py --selftest`. **Config authors keep writing relative paths** — they are
relative to `clothing/`, as they always were.

## Stage list (orchestrator)

```
draft drape publish | census prepare fit reduce bake skin export | register import verify
+---- MD bridge ----+ +-------- Blender headless -------------+ +---- ariki-game ----+
```

- Blender stages shell to (unchanged CLI):
  `"$BLENDER" --background --python clothing/garment_pipeline.py -- --config work/<name>/resolved.json --stage <s>`
  where `BLENDER` defaults to `C:/Program Files/Blender Foundation/Blender 5.1/blender.exe`
  (env `BLENDER` or `--blender` overrides).
- MD stages: `python tools/tailor/draft_garment.py --config work/<name>/resolved.json --emit work/<name>/md_script.py --stage <draft|drape|publish>`
  then `python tools/md_bridge.py --file work/<name>/md_script.py --timeout <n>`;
  orchestrator pings first (`md_bridge.py --ping`) and fails fast with the human
  instruction ("click Plugin > TinqsMDBridge in MD") when no session.
  **[as-built]** `draft_garment.py` does accept `--stage` (`draft|drape|publish|all`,
  default `all`) and additionally `--timeout-hint`, which prints the MD-bridge timeout
  this garment needs instead of emitting.
- `register`: emit-not-edit — write `work/<name>/register.cs.txt` containing the exact
  `Add(...)` line(s) + `BaseDirFor` case if the set's folder is new. Data comes from
  `catalog` + `export` + `parts[].slot`. **[as-built]** the `BaseDirFor` res:// path is
  derived from where `export.out_dir` sits **inside the game repo**, not from its
  basename; an out_dir outside the repo emits a placeholder plus a loud note.
- `import`: agent E's `targeted_reimport.sh` — see the CLI below.
- `verify`: agent E's `clothing_motion_qa.sh` — see the CLI below.
- `bake` is skipped unless the resolved config sets `"bake": true` (matches today's
  "optional, slow" reality).

### Agent E's real CLIs **[as-built]**

The original contract sketched these as `bash <script> <args...>`. The built interfaces:

```bash
# import (gate G7)
bash <game>/tools/targeted_reimport.sh --report <work>/qc/g7.json <asset paths...>
#   assets accept repo-relative, absolute, or res:// paths
#   also: --dry-run (show which .godot/imported entries would be deleted, then stop)
#   exit 0 reimported+verified · 2 verification failed · 3 could not evaluate

# verify (gate G6)
BED_SET=<Set> BED_GENDER=<0|1> \
bash <game>/tools/clothing_motion_qa.sh \
    --clips "Idle Walk Dance" --frames 3 \
    [--bless <baseline-dir> | --diff <baseline-dir>] \
    --report <work>/qc/g6.json \
    <out-dir>
#   exit 0 pass · 2 outfit load error or a diffed frame over --max-diff · 3 could not spawn
```

- `BED_GENDER` is `0 = Male`, `1 = Female` (`ClothingTestBed.cs:27`). `garment.py` derives
  it from `export.gender` (leading "F" → 1).
- Baselines live at `<animation>/clothing/baselines/<slug>/`; capture out-dir is
  `<game>/.game-cli/clothing-qa/<slug>`. `<slug>` is `export.set` lowercased (agent E's own
  example: `BED_SET=Kapahaka` → `.../kapahaka`). Override with the config's optional
  `verify` block: `slug`, `baseline_dir`, `out_dir`, `clips`, `frames`.
- **Bless flow.** `garment.py` picks the mode from the filesystem, not from a flag nobody
  remembers:
  - baseline dir exists → `--diff` (regression tripwire).
  - `GARMENT_BLESS=1` in the environment, or `--bless-baseline` on the command line →
    `--bless` (freeze the captured frames as the baseline).
  - neither → run with **no** `--bless`/`--diff`: the hard signal (outfit load errors on
    `/console` + engine stdout) still applies, the frames are captured as evidence, and a
    prominent banner tells you to look at them and then re-run with `GARMENT_BLESS=1`.
    Blessing before looking freezes a defect as the thing every future run is compared to.
- Both scripts speak the gate exit-code contract and write their own `qc/*.json`, so
  `garment.py` treats an exit 2 from `import`/`verify` as a **gate** failure (honouring
  `--no-gate-stop`), not a tool crash.

## Gate contract

- Plain-python gates: `python clothing/gates/<gate>.py --config <resolved.json> --work work/<name>`
- Blender gates: `"$BLENDER" --background --python clothing/gates/<gate>.py -- --config <resolved.json> --work work/<name>` (+ gate-specific flags)
- **[as-built] which is which:** `g5_posed_sweep.py` is the **only** Blender gate (it
  imports `bpy` and opens `20_fit.blend`/`50_skin.blend`). `g1`, `g2`, `g8` are plain
  python (stdlib, plus PIL for g1). Overridable per gate in the resolved config:
  `"gates": {"g5": {"kind": "plain"}}`.
- Exit codes: **0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate)**.
- Every gate writes `work/<name>/qc/<gate_id>.json`:

```json
{ "gate": "g5", "pass": false, "checked_at_stage": "skin",
  "metrics": { "...": 0 }, "failures": [ {"part": "...", "detail": "...", "frame": "Walk[Walk_Fwd_Loop]@13"} ],
  "artifacts": ["qc/g5_frame_012.png"] }
```

**[as-built] `failures[].frame` is a STRING label**, not an int — `"rest"`,
`"Walk[Walk_Fwd_Loop]@13"`, `"Synthetic:arm_raise_70"`. A bare frame number is ambiguous
once more than one clip is sampled.

**[as-built]** every entry in G5's `metrics.per_frame` also carries
`"source": "rest" | "clip" | "synthetic"`, and `metrics` splits the labels into `clips`
and `synthetic` (+ `synthetic_frames` count) — a mixed sweep (`pose_source: "both"`,
i.e. `--qc deep`) is then readable without parsing label strings.

### Orchestrator gate map **[as-built]**

| Stage | Gate | Invocation | Runs when | Report file |
|---|---|---|---|---|
| `drape` | G1 | plain | `expect.bands` present | `qc/g1.json` |
| `census` | G2 | plain | `expect.islands` present | `qc/g2.json` |
| `fit` | **G3** | Blender, `g5_posed_sweep.py --rest` | `expect.penetration` present | **`qc/g3.json`** |
| `skin` | G5 | Blender, `g5_posed_sweep.py` | `expect.penetration` present | `qc/g5.json` |
| `register` | G8 | plain | always | `qc/g8.json` |
| `import` | G7 | E's script | always (stage IS the gate) | `qc/g7.json` |
| `verify` | G6 | E's script | always (stage IS the gate) | `qc/g6.json` |

The `fit` row is the one that trips people: **there is no `g3_*.py`**. G3 is
`g5_posed_sweep.py --rest`, it sets `"gate": "g3"` / `"checked_at_stage": "fit"` in its
report, and it writes **`qc/g3.json`**, not `qc/g5.json`. Gate failure stops the run
unless `--no-gate-stop`.

## QC modes **[as-built]**

`garment.py --qc light|deep`. A mode is a **profile**: a small dict of sampling/cost
overrides merged into the resolved config **at resolve time**, before `resolved.json` is
written. Gates stay mode-unaware — they receive the same file they always did, with
different numbers in it. Owned by `garment.py` (`QC_PROFILES`, `apply_qc_mode()`).

| | `light` (default) | `deep` |
|---|---|---|
| `expect.penetration.clips` | `["Walk"]` | `["Idle", "Walk", "Dance"]` |
| `expect.penetration.frames_per_clip` | 2 | 8 |
| `expect.penetration.anim_pack` | `[UAL2.glb]` | `[UAL1.glb, UAL2.glb]` |
| `expect.penetration.pose_source` | `auto` | `both` (clips **+** synthetic extremes) |
| `expect.penetration.max_render_frames` | 2 | 12 |
| `verify.clips` / `verify.frames` | `"Walk"` / 1 | `"Idle Walk Dance"` / 3 |
| gate failure | **reports**, run continues | **stops** the run |

`light` uses UAL2 alone because `Walk` aliases onto **UAL2's** `Walk_Fwd_Loop`; the
second pack costs ~7 s of import and contributes nothing when Idle/Dance are not
sampled. Measured on `configs/tests/piupiu_sb_test.json` `--from census --to export`:
**33.3 s light vs 42.6 s deep** end to end, all of the delta in G5 (9.9 s vs 19.1 s).

### Which keys a profile may touch

**Only these** (`QC_PROFILE_KEYS` in `garment.py`):

```
expect.penetration : clips  frames_per_clip  anim_pack  pose_source  max_render_frames
verify             : clips  frames
(top level)        : gate_stop
```

Anything else in a config's `qc.<mode>` block is **dropped with a warning** —
`sanitize_qc_profile()`. A profile can therefore never move a THRESHOLD: `depth_mm`,
`max_verts`, `max_verts_rest`, `gape.*` budgets, `expect.islands`, `expect.bands`,
`expect.budget` belong to the config author in both modes. Switching modes changes how
hard the pipeline looks, never what counts as a defect.

`expect.penetration` is only touched when it **already exists** — its presence is what
arms G3/G5 (see the gate map), so a profile must not conjure a gate the author never
asked for. A `verify` block is created if missing (harmless: the stage runs only when
named).

### Precedence

```
builtin QC_PROFILES[mode]  ->  config `qc.<mode>` block  ->  CLI
```

for the cost knobs above, applied **over** whatever the config's own `expect`/`verify`
says for those keys — a mode that could not override the config as written would not be
a mode. Everything outside the whitelist keeps the config's value. A config that wants
to keep its own value for one cost knob lists it in `qc.pin`:

```jsonc
"qc": {
  "default_mode": "deep",                 // used when --qc is omitted
  "pin": ["clips", "verify.frames"],      // shorthand = expect.penetration.<key>
  "light": { "expect": { "penetration": { "frames_per_clip": 4 } },
             "gate_stop": true },
  "deep":  { "expect": { "penetration": { "clips": ["Idle", "Walk", "Dance", "Haka"] } } }
}
```

Mode selection: `--qc` > `qc.default_mode` > `light`.

### Stopping, and the reminder

`light` implies `--no-gate-stop` (failures are reported, the run finishes, exit code is
still 2). **`--gate-stop`** forces stop-on-failure inside light; `--no-gate-stop` forces
continue inside deep; the two flags together are an error. CLI beats the profile either
way. A run with `--qc` omitted prints
`QC mode: light -- run --qc deep before sign-off` at the end of the summary.

### Recorded in `resolved.json`

`_resolved.qc_mode` carries the applied mode and the effective knob values, so a QC
report can always be traced to how hard it looked:

```jsonc
"_resolved": { "qc_mode": {
  "mode": "light", "source": "default",        // or "--qc" / "config qc.default_mode"
  "gate_stop": false, "gate_stop_source": "profile",
  "applied": { "expect.penetration.clips": ["Walk"], "...": "..." },
  "pinned": [], "notes": [] } }
```

The same values are printed in the run banner.

## `expect` block schema

Contract baseline (unchanged — a config carrying only these keys still works):

```jsonc
"expect": {
  "islands": { "count": 7, "mapped": { "0": {"verts": [4000, 6000], "z": [0.90, 1.35]} } },
  "penetration": {
    "max_verts": 0, "depth_mm": 1.0,             // garment verts inside body
    "gape": { "<part>": {"band_z": [0.95, 1.30], "max_exposed_verts": 0} },
    "clips": ["Idle", "Walk"],                   // g5 posed mode; pack clips by name
    "frames_per_clip": 6
  },
  "bands": { "<band-name>": {"top_m": 1.31, "bottom_m": 1.05, "tol_m": 0.03} },
  "budget": { "<part>": {"tris": 2200, "min_iou": 0.985} }
}
```

Each gate's docstring is the authority on its own extensions. As built:

### `expect.islands` (G2) **[as-built]**

Extra keys beyond the baseline: `require_all_mapped` (bool; unmapped non-trivial islands
fail instead of warn) and, per mapped island, `tris`, `z_min`, `z_max`, `z_span`, `x`.
`mapped` keys may be an island index **or** a part name (when that part maps exactly one
island). Every range accepts `[lo, hi]` (either endpoint may be `null`), a bare number
(exact match), or `{"min":, "max":}`.

**UNITS: census units, i.e. pre-scale source-mesh units, NOT metres.** `align.scale_z`
is applied later in `prepare`, so the piupiu census reads `z 5.184..11.126`, not
`0.52..1.11`. Read the numbers off `work/<name>/census.json` rather than converting.

G2 also always cross-checks that every index in `parts[*].islands` exists in
`census.json`, with or without an `expect` block — that is the check that catches an MD
re-export silently renumbering islands.

### `expect.penetration` (G3/G5) **[as-built]**

All optional, all defaulted:

| Key | Default | What |
|---|---|---|
| `anim_pack` | both `anim/UAL1.glb` and `anim/UAL2.glb` | GLB(s) to pull clips from. Both, because the game binds idle/dance from UAL1 but aliases "walk" onto UAL2's `Walk_Fwd_Loop`. |
| `clip_aliases` | built-in map mirroring the game's bindings | `{"Idle": "Idle_Loop", ...}`; then fuzzy match (shortest containing name wins). |
| `pose_source` | `"auto"` | `auto` \| `clips` \| `synthetic` \| `both`. `auto` = clips, falling back to synthetic extremes when nothing resolves. **[as-built]** `both` = the clips **and then** the synthetic extremes (what `--qc deep` sets); it degrades to synthetic-only if no clip resolves. **The gate never passes for lack of animation.** |
| `ignore_parts` | `[]` | Parts to skip. `"type": "bodyshell"` parts are always skipped — they *are* the body. |
| `max_render_frames` | 6 | Cap on failing-frame QA PNGs (budget is spread across clips). 0 disables. |
| `max_verts_rest` | = `max_verts` | Separate, looser rest-pose budget in full mode. |

`gape.<Part>` entries: `band_z` **required** (`[lo, hi]`, rest-pose world Z); optional
`band_x`, `band_y`, `facing` + `facing_min`, `ray_mm` (50), `gap_mm` (25; a hit farther
than this counts as exposed — ballooned fabric), `max_exposed_verts` (0),
`max_exposed_delta` (threshold on posed-minus-rest exposure; when both are present a
frame must satisfy both), `check_rest` (false), `parts` (which garment parts count as
cover). Band vertices are selected **once in rest pose** and the same indices re-tested
every frame, because anatomy is stable and posed Z is not.

### `expect.bands` + `expect.drape` (G1) **[as-built]**

`expect.bands` is what schedules the gate. Per band: `top_m`, `bottom_m`, `tol_m`
(+ per-edge `top_tol_m` / `bottom_tol_m`), `from` (`"cover"` default = rows where the
garment covers ≥ `min_row_cover` of the body's width, i.e. the band proper; `"extent"` =
the loose mask, which also picks up straps, ties and fringes), `band_index`.

`expect.drape` carries the measurement parameters: `snapshot`, `height_m` (1.777),
`landmarks`, `min_row_cover` (0.40), `min_row_extent` (0.05), `scale`, `z_range`, and
**`mask`**. G1 is image-based because MD exposes no mesh introspection to Python
(`GetClothPositions()` returns nothing), so it measures the rendered viewport.

**The `mask` block is effectively required in practice.** Its default mode `"auto"` is a
crude skin/background heuristic that counts baked-in underwear and shaded skin as
garment. Any garment with a generated texture — i.e. all of ours — needs
`"mask": {"mode": "colors", "colors": [[173, 37, 39]], "tol": 30}`
(or `"mode": "sat"`). Snapshot resolution order: `--snapshot` > `expect.drape.snapshot` >
`md.snapshot`.

## Shared facts

- Body GLBs: `ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb`
  (+ `_SkirtRig` variant). 65-bone Quaternius skeleton, Lena height 1.777 m.
- Anim packs for posed sweeps: `ariki-game/assets/quaternius/anim/` (UAL GLBs) — verified
  to be the **same 65-bone skeleton** as the derived-body RIG (exact name-set match), so
  G5 assigns actions directly with no retarget. Rigs with EXTRA bones (piupiu_sb's 16
  skirt bones) are fine: unanimated bones ride their parents.
- Blender 4.4+/5.x **slotted actions**: an action with no bound `action_slot` animates
  nothing, silently. G5 binds the slot explicitly; anything else posing a rig must too.
- Existing checkpoints for testing: `clothing/work/piupiu/`, `work/piupiu_sb/`, `work/pari/`
  — `50_skin.blend` contains `BODY`, `RIG`, `GARM_*`, `BODY_SHELL`.
- Known-bug ground truth for G5 validation: pari neckline gapes at sternum in Walk/Dance;
  a thigh punches through the piupiu skirt. A correct G5 FAILS on these checkpoints.
  **[as-built]** the piupiu thigh poke reproduces headlessly and G5 catches it. The pari
  neckline gape does **not** reproduce in the Blender checkpoint — it is not present in
  the skinned mesh G5 measures, so its origin is in-game (attach/material/LOD), which
  makes it G6 territory rather than G5.
- Game agent API (when a bed is spawned): `/health /screenshot /navigate /state /scene /ui /console`.
  **[as-built]** `GD.Print` does **not** feed `/console`; `ClothingTestBed.ReportLoad`
  had to be added before load failures surfaced there at all.
- Python for plain gates: system `python` (3.12 on this box; PIL yes, numpy no).
  `python3` is a broken Windows Store stub — **do not use it**. Only stdlib + PIL; check
  before importing, degrade gracefully.
