# Concept Lab decisions Append only. Never edit an old entry: add a new one with `supersedes:` instead. An edited history means the next person re-litigates a settled argument without knowing they are doing it, and the path looking straighter than it was is precisely the damage. Format: `DEC-CL-nnnn · STATUS · date · title`, then Decision / Alternatives / Consequences. --- ## DEC-CL-0001 · ACCEPTED · 2026-08-15 · Fresh subsystem inside MAINodes, named `concept_lab/` **Decision.** Functional-conditioning work lands as a new package inside ComfyUI-MAINodes rather than as an extension of any existing reference-format or adapter-format code. The name is `concept_lab/`. **Alternatives.** `concept_surgery/` (the source bundle's first suggestion, and it carries the ancestor program's vocabulary) — not chosen; it reads sharper than the work is, on a public node pack. `functional_conditioning/` — most accurate, longest imports, unwieldy node names. Operator chose `concept_lab/` to sit alongside Motion Lab and Voice Lab: a place you work rather than an operation you perform. **Consequences.** Backend-specific formats are compiler *targets*, not core types. Node display names will read "MAI Concept ...". --- ## DEC-CL-0002 · ACCEPTED · 2026-08-15 · The functional delta is the primary object **Decision.** The primary scientific object is the matched change in model function caused by a conditioning intervention, not the content of the source asset and not a similarity between latents. **Consequences.** Source-latent similarity alone can never certify a semantic factor. Every factor claim has to reach an effect on the target stream. --- ## DEC-CL-0003 · ACCEPTED · 2026-08-15 · Model-neutral core, H3 as first backend **Decision.** Core data and factor APIs know nothing model-specific. Segment names, block counts, sampler shapes and layout rules live behind `backends/`. H3 is first because MAINodes already has H3 tooling and H3 has a native image/video/audio reference path. **Consequences.** `types.py` treats segment names as opaque strings. The second backend should not require rewriting the first one's evidence. --- ## DEC-CL-0004 · ACCEPTED · 2026-08-15 · Three arms, because a reference changes more than its content **Decision.** First reference experiments capture three arms: actual reference `R`, shape-matched control `N`, and no reference `0`. `Delta_content = R - N` is the primary object; `N - 0` and `R - 0` are kept as diagnostics. **Why.** Verified against the installed ComfyUI: `PackedLayout` (`comfy/ldm/minimax/model.py:300`) advances its cursor past every reference block before placing the target streams, and references also enter the Qwen3-VL presentation path. So a two-arm on/off difference mixes semantic content, the presence of a reference, and a moved time origin for the target stream. **Consequences.** `N` is a structural control, not a metaphysical zero. Several control strata must be tried and the sensitivity reported before `R - N` is called canonical. `backends/h3.py:plan_arms` returns the strata rather than picking one. --- ## DEC-CL-0005 · ACCEPTED · 2026-08-15 · Capture the target stream, not only the reference rows **Decision.** v0 captures include target-video DiT state after selected blocks. Reference-row state is secondary diagnostic evidence. **Why.** The question is not how the reference is represented. It is how the reference changes what gets generated. --- ## DEC-CL-0006 · ACCEPTED · 2026-08-15 · Ids are computed over an explicit identity payload **Decision.** Every id is a hash of a named identity payload, never of the whole record. Adding a field to a manifest must not move ids that already exist on disk. If a new field genuinely belongs to a capture's identity, it joins `identity()` **and** `PROTOCOL_VERSION` is bumped in the same commit, deliberately, with an entry here. **Also decided.** Floats are rounded to 12 significant digits before hashing, so a sigma arriving as float32 and the same sigma arriving as float64 name one condition rather than two. **Alternatives.** Hashing the whole record — rejected: every bank on disk goes stale the next time anyone adds a note field, for no scientific reason. Ignoring floats' representation — rejected: an id that disagrees about whether two identical conditions are identical is an id that lies. **Consequences.** `tests/test_concept_lab_contracts.py` check 2 asserts both directions: identity fields move the id, notes and timestamps and status do not. --- ## DEC-CL-0007 · ACCEPTED · 2026-08-15 · Same id with different content is refused **Decision.** Writing a record under an existing id with different bytes raises. Writing identical content is idempotent. **Why.** A capture id names a condition. If the same condition produced different bytes, one of the two runs is not what its manifest says, and silently keeping the newer one destroys the only evidence that something is wrong. --- ## DEC-CL-0008 · ACCEPTED · 2026-08-15 · Two-root workspace, neither root in the repo **Decision.** Manifests default under the ComfyUI output directory (`output/h3_concept`), tensors default to `/mnt/weights/ai/concept-space/tensors` on a box that has it, falling back to `/tensors`. Both are overridable per call and by `H3_CONCEPT_INDEX_DIR` / `H3_CONCEPT_TENSOR_DIR`. **Why.** Manifests are for reading and diffing and belong where a human browses. Tensors are scientific evidence, can be large, and belong on the array. Neither belongs in a public repository, because a concept pack can encode a likeness and the failure mode is somebody running `git add -A`. --- ## DEC-CL-0009 · ACCEPTED · 2026-08-15 · Imported evidence keeps its origin and never merges **Decision.** An imported space lands under `imported//` with the origin stamped on each record. Imported records are never re-exported under this space's id. Importing your own export back into itself is refused. **Why.** The whole subsystem turns measurements into claims. Somebody else's measurement informing your work is good; becoming indistinguishable from your own is how a claim loses its scope. --- ## DEC-CL-0010 · ACCEPTED · 2026-08-15 · Packs are private by default; export names what it held back **Decision.** `ConceptPackManifest.visibility` defaults to `private`. Export excludes private packs unless explicitly asked, and reports every exclusion with its reason. Source assets are referenced by content hash and never copied into a pack. **Consequences.** The MCP surface can export the shareable set and can see what was withheld; releasing a private pack is a human keystroke through the CLI. A model cannot prove who is asking. --- ## DEC-CL-0011 · ACCEPTED · 2026-08-15 · Three surfaces, one verb layer **Decision.** `api.py` holds one implementation of every operation and returns plain JSON-able dicts. `cli.py`, `mcp.py` and a future `nodes.py` are thin clients with no rules in them. No tensors, ComfyUI types or torch objects cross the api boundary. **Why.** It is what lets an agent drive the subsystem over a pipe, lets the tests run with no GPU, and stops the three surfaces drifting into three implementations. **Consequences.** When a verb eventually touches tensors it returns references, not handles. If that becomes painful, it gets decided here rather than worked around in one surface. --- ## DEC-CL-0012 · ACCEPTED · 2026-08-15 · Unbuilt verbs refuse and name their task **Decision.** A verb whose machinery does not exist raises `NotYetImplemented` carrying the task id that blocks it. It never returns an empty or placeholder result. **Why.** The output of this subsystem is evidence. A quiet nothing gets recorded and later read as a measurement. --- ## DEC-CL-0013 · ACCEPTED · 2026-08-15 · No ComfyUI nodes until there is something to run **Decision.** `concept_lab` registers no nodes and the pack's `__init__.py` is untouched. Nodes arrive at T12, wrapping proven verbs. **Why.** Existing MAINodes behaviour and defaults must not move for additive alpha work. And a half-built research surface in a UI is how ten unstable knobs become somebody's saved workflow. **Consequences.** The guarded-import line that adds `concept_lab.nodes` to the pack's loader is a one-liner when the time comes; the loader already does exactly this for `window_loop`, `window_expand` and `timeline.nodes`. --- ## DEC-CL-0014 · ACCEPTED · 2026-08-15 · Held-out, adversarial and null splits are not optional **Decision.** A corpus carries four splits and `corpus_status` reports the missing ones as warnings. No factor ships on extraction-set scores. **Status of the enforcement.** The warnings exist; a *containment* check (no source hash appearing in two splits) does not yet, and is proposed in the review brief. Recorded here so the gap is visible rather than assumed closed. --- ## DEC-CL-0015 · ACCEPTED · 2026-08-15 · Process launch is an identity dimension of a capture; arms of one comparison share one launch **Decision.** `FunctionalCaptureManifest` carries a `process` fingerprint and `process["launch_id"]` joins `identity()`. The launch id is minted once per interpreter (`types.process_fingerprint`). Host, pid, start time and Python version stay OUT of identity: a manifest must be re-readable on another machine without its id moving. Corollary, and it is a protocol constraint rather than a nicety: R, N and 0 are three separate forwards, so all arms of one comparison — and the replicates that make a delta — must be captured inside one process launch. A capture interrupted and resumed in a new process yields an arm 0 that is not comparable row-for-row to the R and N from the old one. `capture_plan` prints this in `protocol_notes` and stamps the current `process_launch_id`. **Why.** On this rig the H3 pipeline is bit-identical WITHIN a process and merely close ACROSS processes: per-process kernel autotuning puts a ~41-48 dB PSNR floor between two cold-process renders at the same seed (`.claude/skills/h3-latent-mechanics/SKILL.md` §7 in ComfyUI-ModelCatalog). Before this entry, `identity()` excluded tensor hashes while `Space.put` compared canonical JSON that includes `TensorRef.sha256`, so two honest reruns at one seed in two processes produced the SAME capture id and DIFFERENT bytes, and `put` accused the second of not being what its manifest said. The first legitimate replicate anyone captured would have hit it. **Alternatives.** A mandatory explicit `replicate` set by the caller — rejected: it defaults silently to 0, so the failure returns the first time someone forgets, which is exactly the population that needs the guard. A tolerance-based collision check ("close enough bytes are the same capture") — rejected: within a launch the pipeline is exact, so a tolerance hides a real defect in the case where the check is most informative. **Consequences.** `PROTOCOL_VERSION` bumps to `concept_lab/0.2.0` (DEC-CL-0006's rule: a field entering identity bumps the protocol, deliberately). DEC-CL-0007's rationale is amended below. T3 will refuse `alignment="exact_rows"` when `FunctionalDeltaManifest.same_process()` is False; the two `*_process_launch_id` fields are there for it now, as non-identity provenance. The cold-process floor gets MEASURED in E0 rather than assumed: the ~41-48 dB figure was taken on decoded renders, not on captured block states, and nobody has read the block-state number yet. --- ## DEC-CL-0007 · AMENDMENT (2026-08-15) · rationale narrowed, decision unchanged The refusal stands: same id with different bytes still raises. The reason it is correct is narrower than the original entry said. WITHIN one process launch the pipeline is bit-identical, so different bytes under one id mean one of the runs is not what its manifest says. ACROSS launches two honest reruns differ by kernel autotuning alone, and calling that a defect would have refused the first real replicate — which is why the launch id is now part of a capture's identity (DEC-CL-0015) and those two runs are two captures. `Space.put`'s message now says both halves: "Same id, different bytes. Within one process launch this means the id does not describe the condition; if these came from different launches the capture is missing its process fingerprint (DEC-CL-0015)." --- ## DEC-CL-0016 · ACCEPTED · 2026-08-15 · Corpora, tap specs and plans are definitions and want version control (amends DEC-CL-0008's rationale, not its default) **Decision.** The default index root does not move (DEC-CL-0008 stands, and it is the operator's call). But `Space.status()` reports `index_root_tracked` and warns when the index root is not inside a git work tree, and `corpus_new`, `corpus_add`, `corpus_status` and `capture_plan` carry the same warning. The recommendation is to point `H3_CONCEPT_INDEX_DIR` at a directory inside a PRIVATE repository, or to copy `corpora/` and `plans/` into one. **Why.** Manifests are not one kind of thing. Captures and deltas are RESULTS and can be re-measured from a corpus plus a stack. Corpora, tap specs and plans are DEFINITIONS: they encode which contrasts somebody chose to shoot, and no tensor reconstructs them. The ancestor program's F30 is the paid-for version of this: the definitions behind four findings lived only under a gitignored output directory (`docs/concept_lab/FABLE_SYNTHESIS_2026-08-15.md` §3.8). **Alternatives.** Moving the default index root into a repo — not taken here; DEC-CL-0008 settled the default deliberately and a public node pack is the wrong place for it to land by accident. Silence — rejected: the loss is invisible until the day someone wants to re-run the experiment. **Consequences.** Status and the corpus verbs warn. Whether the default moves is the operator's decision, and this entry does not pre-empt it. --- ## DEC-CL-0017 · ACCEPTED · 2026-08-15 · Split containment is enforced, and lineage counts as containment (closes DEC-CL-0014's open gap) **Decision.** `ConceptCorpus.containment()` lists every `content_hash` and every `source_id` that appears in more than one split, with the splits and row ids. `corpus_status` reports it and warns per collision; `capture_plan` REFUSES, naming the value and the splits, unless `allow_containment=True` is passed — and then the plan carries `containment_overridden` so the exception is in the record rather than in somebody's memory. `ConceptCorpusRow.source_id` is new and enters the row identity. **Why.** DEC-CL-0014 recorded this as a visible gap. A content hash alone does not close it: two crops of one take are different bytes and the same observation, so held-out means nothing if a lineage id is not checked too. Refusal rather than a warning at this one point, because plan time is the last moment when the fix is free. **Consequences.** A corpus written before this entry has no `source_id` anywhere, which reads as "not recorded" and never as "no collision". Absence is skipped, not treated as a shared value. --- ## DEC-CL-0018 · ACCEPTED · 2026-08-15 · The capture tap ships as two thin alpha nodes now (amends DEC-CL-0013's timing, not its rule) **Decision.** Capture must run INSIDE the ComfyUI process — the states live for microseconds inside the DiT block loop and the launch is part of the condition (DEC-CL-0015) — so the seam needs a node to exist. Two ship now, `MAIConceptCaptureArm` and `MAIConceptCaptureFlush`, under the category `MAI/concept (alpha)`. They hold no rules: the Arm node parses its strings into a `TapSpec`, reads a stack fingerprint off the live `ModelPatcher`, opens a `TapSession` and installs it on a CLONE; the Flush node calls `finalize()`. Every refusal comes from `concept_lab/`. DEC-CL-0013's rule stands and is what makes this safe: existing MAINodes behaviour and defaults do not move. The registration is the one-liner that entry predicted, in the same guarded loader tuple as `window_loop`, `window_expand` and `timeline.nodes`, so a broken alpha module cannot take the pack down with it. **Alternatives.** A monkeypatched sampler — rejected: `ModelPatcher`'s `add_wrapper_with_key` / `set_model_patch_replace` are the sanctioned seam, and the true-clock precedent is a monkeypatch we would rather not repeat. Waiting for T12 — rejected: nothing else in the subsystem can produce a capture, so waiting means the data layer keeps being tested against itself. **Consequences.** E0's gates are run THROUGH these nodes: a baseline render, a tap-installed-disabled render and a tap-enabled render at one seed in one process must decode to pixel-identical frames. If the pass-through gate fails, the nodes are pulled, not patched around. Until those gates run live, the nodes are alpha and say so in their DESCRIPTION. **Also decided here (mechanical, same change).** `TapSpec.store` becomes a comma-joined subset of `("stats", "frame_mean", "sketch", "full")` and is canonicalized to that order, because one recorded forward is expensive and the four reductions answer different questions off the same rows. Single words stay legal and keep their meaning, so no id already on disk moves. --- ## DEC-CL-0019 · ACCEPTED · 2026-08-15 · Capture identity is measured at the model boundary, not declared; the tap refuses before its first byte and streams into a partial dir **Decision.** Three parts, one rule. 1. A capture's `ConditionVariant` is built by the tap on its FIRST diffusion-model call, from what the model was handed: the text-encoder output (`sha256` of `context` through float32) as the first `ConditioningSource`, then one source per `minimax_payload["refs"]` block and one per `keyframes` block, hashed over their latents in packed order. `variant.name` stays a LABEL and stays out of identity; `variant.prompt` stays `None` (the human text is not reachable at this seam, and the encoder output the model consumed is the stronger identity). Noise-aug levels, `audio_scale`, the token-tag digest and the payload seed ride in the text source's `preprocess`, which `ConditioningSource.identity()` already covers, so no schema changed and `PROTOCOL_VERSION` does not move. 2. The payload seed must equal the seed the Arm node was given. The node cannot read the sampler's seed, so it is copied by hand; a mismatch now raises naming both numbers instead of mislabelling the capture. 3. `_ensure_capture_id()` refuses BEFORE the first write when the id is already on disk (manifest present, or a non-partial tensor directory), naming the existing variant, replicate, launch and paths. Tensors stream into `/.partial/` while `TensorRef.path` records the final `/...`; `finalize()` files the manifest through `api.capture_record` and only then renames the directory. `api.capture_exists()` is the stdlib door the tap knocks on. `Space.doctor()` reports leftover `*.partial` directories as `unfinished_capture` problems. **Why.** Both halves are the reported-vs-consumed family the ancestor program kept paying for, and both were caught live rather than reasoned about. `MAIConceptCaptureArm` built `ConditionVariant(name, arm)` with `sources=()` and `prompt=None`, and `ConditionVariant.identity()` is `{arm, sources, prompt}`, so every R cell hashed to one id and every N cell to another regardless of which plate the model actually saw: 12 of 15 E0.5 cells collided. Worse, the same-id refusal lived in `capture_record`, which runs at the END, while `h3_tap` had already streamed tensors into `//`. A collision therefore overwrote the earlier capture's tensors before anything detected it, and E0 replicate 0's tensors were destroyed that way. A refusal that fires after the damage is not a refusal. **Alternatives.** Putting `variant_name` into identity — rejected: it makes the label load-bearing, so a typo becomes a new condition and a copied node silently merges two. Declaring sources from the node's inputs — rejected: that is the reported number again, and the plate a node points at is not necessarily the latent the model got. Buffering tensors until finalize — rejected: a capture's worth of block states is exactly what T4 refuses to hold in VRAM. Deleting the partial dir on failure — rejected: it is the evidence that something failed. **How to apply.** Arms of one comparison differ by what they feed the model, not by what they are called; two arms with different plates now get different ids automatically, and two arms with the same plate and the same label get the same id and the second one is refused. An honest rerun of a condition inside one launch increments `replicate`. A `*.partial` directory in the tensor root is wreckage: `space doctor` lists it, the operator deletes it, nothing else touches it. **Consequences.** `tests/test_concept_lab_tap.py` grew sections 7-9 (identity from context and refs, refuse-before-writing and partial/promote, render fingerprint). The render fingerprint is evidence and not identity (DEC-CL-0006 stands): `MAIConceptCaptureFlush` hashes frame 0 and the whole decoded batch as uint8 into the manifest notes, so E0's pass-through gate is a hash comparison rather than two humans watching two videos. --- ## DEC-CL-0020 · ACCEPTED · 2026-08-15 · The first injection is additive at the tap, in frame_mean form, with its controls built in **Decision.** The first injection is additive at the tap, in `frame_mean` (and optionally separable frame + patch) form, on the target-video rows, with `zero` / `time_shuffle` / `sign_flip` controls built in; it is an instrument for E0.5's premise test, not the compiler. Mechanically: `MAIConceptInjectDelta` loads a delta pack (a `.safetensors` of `b_s/frame_mean` `[latent_t, hidden]` maps plus an optional `patch_mean` `[frame_rows, hidden]`, with a sidecar JSON carrying `latent_t`, `frame_rows`, `hidden`, the captured steps and the source capture ids) and, at the post-block point where `TapSession._record` reads, adds `alpha * d_frame[t]` to every row of latent frame `t` of the video segment, in place in the block's OWN output dict. `separable` adds `alpha * (d_frame[t] + d_patch[p] - grand)`, the rank-2 map with the shared grand mean counted once. `captured_only` fires on the pack's steps; `nearest_all` fires every step off the nearest captured entry, ties to the earlier one. Step index, layout stash and `cond_or_uncond` handling are the tap's, shared rather than copied (`h3_tap.schedule_and_sigma` / `step_from_sigma`, `TapSession._layout_signature`). **Why.** The capture side can be right and mean nothing: a delta that separates R from N in a bank is a statistic until something is generated with it. Additive-at-the-tap is the smallest thing that spends the delta where it was measured, so a null result indicts the delta rather than a translation layer. The controls ship WITH it rather than after it because the two questions the first read has to answer are both control questions: `zero` runs every line of the patch with zeroes (if that render moves, the plumbing is the effect, not the delta), and `time_shuffle` keeps the delta's energy while destroying its timing (if that renders like `none`, the timing content is not what is doing the work). A control invented after a promising render is a control chosen to survive it. **Alternatives.** Replacing rather than adding (set the rows' frame means to the pack's) — rejected for the first read: it destroys the run's own content and makes a null result unattributable. Injecting through the conditioning (a new ref block) — rejected: that is a different experiment about the packer, and it cannot address a chosen block or step. Waiting for a compiler that maps a factor to a schedule — rejected: the compiler's first input is exactly the measurement this node produces. A distinct patch NAME so a tap and an inject could share a block — rejected on the source: the block loop reads only `patches_replace["dit"]` (comfy/ldm/minimax/model.py:643-655), so a patch under any other name never fires, and comfy keeps ONE callable per (name, block, index) (model_patcher.py:93-112), so a second install on one block silently replaces the first. The injector therefore refuses on block overlap and says to put the tap at a later block, where it records the injected state. **How to apply.** One pack, one arm per render, all arms in one launch (DEC-CL-0015 binds injections too). Read `none` against `zero` before reading `none` against anything else. The geometry guard is (latent_t, frame_rows) recomputed off the live layout the way `TapSession._write` computes them, checked before any block fires, because the manifests carry segments and a position hash rather than the packed signature tuple; text_len and audio_t are free to differ, so a delta may be spent under a different prompt. `alpha` is the dose and it is not calibrated: the report logs the applied norms per (block, step) on the ComfyUI console at the last step, and that is the number to quote. **Consequences.** `concept_lab/backends/h3_inject.py` is the second and last file in the subsystem that imports torch; `types.py`, `space.py` and `api.py` stay stdlib-only. `h3_tap`'s step arithmetic moved to two module functions so both sides read one implementation; `TapSession` behaviour is unchanged and its suite is untouched and green. `tests/test_concept_lab_inject.py` is the new offline suite (pack reading, zero/frame/separable arithmetic, controls, step mapping, geometry refusals, cond_only, tap+inject on one clone). The node is ALPHA and has NOT been run live: E0.5's premise test is the first run, and if the `zero` arm is not bit-identical to an unpatched render the node gets pulled rather than patched around. --- ## DEC-CL-0021 · ACCEPTED · 2026-08-15 · Injection gains a full-state mode and a spatial-shuffle control **Decision.** `MAIConceptInjectDelta` gains `mode='full'`, which adds the whole stored block state (`b_s/full`, `[latent_t * frame_rows, hidden]` in the video segment's packed row order) row for row instead of a reduction, and `control='space_shuffle'`, which permutes the frame_rows axis with one seeded permutation shared by every frame. `frame_mean` injection was measured to act as a timing-blind global bias (E0.5, 2026-08-15), so the object injected must be the object measured to agree. **Why.** E0.5 spent a frame map and got a global push: `/mnt/work/ai/apps/ComfyUI-ModelCatalog/docs/concept_lab/E05_RESULTS_2026-08-15.md`. A frame map has already averaged the spatial axis away, so the arm has no "where" to be wrong about, and `time_shuffle` is the only control the object can even fail. The full state is the object the tap files and the object the cosines were computed on, and `space_shuffle` is the control that asks whether its placement is doing the work. A null on the reduction indicts the reduction, not the delta, and that ambiguity is what this closes. **Alternatives.** Reading the reduction harder (more blocks, more steps, bigger alpha) — rejected: it varies the dose of a quantity already known to be timing-blind. Holding the pack's full states in RAM the way the reductions are held — rejected: ~160 MB per (block, step) at production geometry. They are read one key at a time on first use and cached only in the model's own device and dtype, only for the (block, step) pairs a session selected, which is at most `blocks x steps` of them. **How to apply.** `space_shuffle` in `mode='frame'` REFUSES: frame_mean has no space axis and the arm would render identically to `none`, which is a control that cannot fail and therefore is not one. `mode='full'` refuses at session build if any selected (block, step) has no `full` tensor. Sidecar keys are unchanged; a pack may carry reductions, full states, or both, and the shape guard is `(latent_t, frame_rows, hidden)` as before. **Consequences.** `tests/test_concept_lab_inject.py` grows section 10 (full mode: exact add on video rows only, laziness, both shuffles as permutations of the axis they name, fp16 packs applied at the model's dtype); the pack reader now enumerates keys through `safe_open` rather than `load_file`, so a full pack's bytes are never all resident. The node is still ALPHA and the full mode has NOT been run live.