dzmdd's picture
Upload folder using huggingface_hub
e488ce7 verified
|
Raw History Blame Contribute Delete
28.4 kB
# 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 `<index root>/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/<origin space id>/`
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 `<tensor_dir>/<id>.partial/` while `TensorRef.path` records the final
`<id>/...`; `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
`<tensor_dir>/<id>/`. 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<block>_s<step>/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<block>_s<step>/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.