Files
malkuthroandClaude Opus 4.7 cc6bc47866 docs+tooling: VAE user guide + versioning ref + 3 skills + preflight CI (#48)
* docs+tooling: VAE user guide + versioning ref + 3 skills + preflight CI

Documentation
- New user-guide tree: docs/user_guide/nodes/radiance_koolook_v2_3_3/
  with README, per-node pages for Easy_hdr_VAE_encode/decode, an
  encode/decode pairing guide (4 worked examples + ASCII connectivity
  diagram), and an HDR modes deep-dive (per-pixel comparison table).
  Folder name mirrors the code path under forks/radiance_koolook/versions/.
- New cross-cutting reference: docs/reference/versioning.md explains the
  three independent version axes (pack version, fork wrapper version,
  upstream pinned commit), when each one bumps, and which skill/doc
  owns it. Plus the v1/v2 node-ID suffix policy.
- Cross-links updated in docs/user_guide/README.md, docs/reference/
  README.md, docs/maintainers/README.md.

Skills (all under .claude/skills/)
- docs-sync — controlled find-and-replace for version tags across docs,
  code, and manifests; --scope and --dry-run flags.
- menu — lists all project skills with one-liners.
- preflight-release — companion doc for tools/preflight_release.py.

Pre-flight tooling
- tools/preflight_release.py — four checks, exit-code-driven for CI:
  (1) static AST extraction of NODE_CLASS_MAPPINGS keys (no torch needed),
  (2) stub-VAE dispatch test verifying all 5 rank/VAE-type branches,
  (3) drift check against ComfyUI-Manager's extension-node-map.json
      (catches the issue #44 phantom-nodes class of bug automatically),
  (4) saved-workflow regression check across tests/workflows/*.json.
- .github/workflows/ci.yml — new "Pre-flight Release" job runs checks
  1, 2, and 4 on every PR; check 3 (manager-meta) skipped pending the
  upstream issue #44 fix.
- tests/workflows/ — 2 starter fixtures (Depth-Anything3, Hires-FIX-wanT2V)
  + README documenting the contribution convention. Coverage grows as
  new fixtures are added; no code changes needed.

All four offline pre-flight checks pass on this branch. The
manager-meta check correctly identifies the 39 phantom IDs documented
in issue #44.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(preflight): satisfy ruff lint + bandit security gate

* fix(preflight): address blocking review findings

Three fixes from /review-pr feedback on this PR:

1. Sanitize tests/workflows/*.json — removed 4 unique absolute paths that
   leaked internal job/client info ('baconx/Toy-Hix/9110',
   'menneskehavn_3029', etc.). Replaced with placeholders like
   'path/to/sequence_a' and 'path/to/source_video'. Both fixtures still
   parse as valid JSON; grep for the old patterns returns zero hits.

2. Broaden the Koolook-ID heuristic regex from '^easy_[a-z]' to
   '^easy_[A-Za-z]'. The old pattern excluded 'easy_ImageBatch' (capital I
   after the underscore), which would have silently bypassed the
   workflow-fixture stability check for that node. Verified the new
   pattern correctly matches it.

3. Wrap check_dispatch() body in try/finally and track every sys.modules
   key the function injects in a list, so cleanup runs on the success
   path AND on any exception during exec_module / VAE-constructor
   execution. Previously only the three torch* stubs were popped, never
   the three _pf_pkg* entries — and even those weren't popped on
   exception paths. Verified zero leaked modules after the function
   returns.

The five dispatch test cases were factored into a helper
_check_dispatch_run_cases() so the try/finally wrapping stays focused on
module injection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(preflight): address remaining review findings

Follow-up to 7844037 — picks up all the HIGH / MEDIUM / LOW review
findings that were deferred. Pre-flight still passes locally.

HIGH
- Extract check_dispatch (~195 lines) to tools/_preflight_dispatch.py.
  Main script drops from 680 → 508 lines. Module exposes a single
  run(verbose, repo_root) -> (bool, list[str]) entry; main script keeps
  the [2/4] header + PASS/FAIL line.
- Install _HttpsOnlyRedirectHandler that re-validates each redirect
  hop's scheme. Closes the gap that urlopen would otherwise follow a
  30x to http:// or file:// silently. The Bandit B310 suppression
  comment now accurately describes the protection.
- check_static docstring now honestly states it only walks
  NODE_CLASS_MAPPINGS literal dicts and does NOT apply
  NAMESPACE_SUFFIX / SKIP_VERSION_SUFFIX logic. Notes that the workflow
  check (4) is the safety net that surfaces drift the day a non-skipped
  ID lands.
- check_workflows now FAILS when the fixtures dir exists with a README
  but contains no *.json files — previously silently SKIPPED. If
  someone deletes fixtures or typoes the dir, that's a regression in
  test coverage and should fail loudly.

MEDIUM
- check_manager_meta validates the upstream entry shape before diffing.
  Previous code silently produced an empty set if the schema changed,
  reporting every one of our IDs as 'missing'. Now SKIPs with a clear
  message when shape is unexpected.
- check_workflows accumulates total_koolook_refs inside the main loop;
  drops the redundant second-pass that re-read every fixture file just
  to compute the summary count.
- _extract_workflow_node_types uses explicit detection: a canvas-format
  file with nodes: null no longer falls through to API-format and pulls
  class_type from unrelated keys.
- check_static return type simplified from (bool, set, list) to
  (bool, set). Third element was discarded at every call site.
- check_workflows now does two-pronged Koolook-ID matching: heuristic
  (catches drift even if static missed an ID) plus actual_ids (catches
  IDs that don't fit the heuristic, e.g. a future Koolook node without
  Easy / easy_ / koolook in its name).

LOW
- ANSI escape codes named in a constants dict (_ANSI["green"] etc.)
  instead of bare string literals.
- Removed dead 'cases: list[tuple[str, callable]] = []' initializer
  (was shadowed three statements later).
- check_static's per-ID listing now gated behind --verbose, matching
  check_dispatch and check_workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:31:36 +02:00

298 lines
11 KiB
Python

"""
Stub-VAE dispatch test for the v2_3_3 wrapper, extracted from
`preflight_release.py` to keep the main script under the project's
~400-line file-size guideline.
This module:
- Builds a `FakeTensor` (`FT`) class that mimics just enough of
`torch.Tensor` to drive the wrapper's dispatch logic (no real math).
- Builds a `torch` stub module + `torch.nn` / `torch.nn.functional`
submodule stubs.
- Loads the v2_3_3 wrapper code (`color_helpers.py` + `nodes_vae.py`)
via `importlib` against the stub torch.
- Runs five test cases through `Easy_hdr_VAE_encode` / `_decode`,
asserting that the per-case dispatch path appears in `debug_info`.
- Cleans up `sys.modules` in a `finally` block — including the
`_pf_pkg*` entries — so a partial-load failure can't leak the stub
torch into anything else running in the same process.
Public surface: `run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]`.
The caller (in `preflight_release.py`) is responsible for the `[2/4] …`
header and the PASS/FAIL line; this module just executes and reports.
"""
from __future__ import annotations
import importlib.machinery
import importlib.util
import sys
import types
from pathlib import Path
# ───────────────────────────────────────────────────────────────────────
# FakeTensor stub
# ───────────────────────────────────────────────────────────────────────
class FakeTensor:
"""Tensor-shaped duck type that records its shape and supports the
handful of operations the v2_3_3 wrapper does at dispatch time."""
def __init__(self, shape):
self.shape = tuple(shape)
self.dtype = "float32"
self.device = "cpu"
@property
def ndim(self):
return len(self.shape)
@property
def T(self):
s = list(self.shape)
if len(s) >= 2:
s[-1], s[-2] = s[-2], s[-1]
return FakeTensor(s)
def clone(self):
return FakeTensor(self.shape)
def float(self):
return self
def __getitem__(self, key):
# Slicing patterns the wrapper actually uses:
# pixels[:, fi, ...] → drop dim 1, keep rest
# latent[:, :, fi, ...] → drop dim 2, keep rest
# img[..., :3] → keep leading dims, last dim = 3
if isinstance(key, tuple) and len(key) == 3 and key[2] is Ellipsis:
return FakeTensor((self.shape[0],) + self.shape[2:])
if isinstance(key, tuple) and len(key) == 4 and key[3] is Ellipsis:
return FakeTensor((self.shape[0], self.shape[1]) + self.shape[3:])
if isinstance(key, tuple) and key[0] is Ellipsis:
return FakeTensor(self.shape[:-1] + (3,))
return self
def reshape(self, *a):
return FakeTensor(a)
def to(self, **k):
return self
def __mul__(self, o): return self
def __rmul__(self, o): return self
def __pow__(self, o): return self
def __sub__(self, o): return self
def clamp(self, *a, **k): return self
def __matmul__(self, o):
return FakeTensor(self.shape[:-1] + (o.shape[-1],))
# ───────────────────────────────────────────────────────────────────────
# torch stub
# ───────────────────────────────────────────────────────────────────────
def _build_torch_stub() -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]:
"""Returns the (`torch`, `torch.nn`, `torch.nn.functional`) stub triple."""
torch_stub = types.ModuleType("torch")
torch_stub.Tensor = FakeTensor
torch_stub.stack = lambda ts, dim=0: (
lambda s: (s.insert(dim, len(ts)), FakeTensor(s))[1]
)(list(ts[0].shape))
torch_stub.cat = lambda ts, dim=0: (
lambda s: (s.__setitem__(dim, sum(x.shape[dim] for x in ts)), FakeTensor(s))[1]
)(list(ts[0].shape))
torch_stub.where = lambda c, a, b: a
torch_stub.sign = lambda x: x
torch_stub.abs = lambda x: x
torch_stub.pow = lambda x, p: x
torch_stub.tanh = lambda x: x
torch_stub.clamp = lambda x, *a, **k: x
torch_stub.tensor = lambda *a, **k: FakeTensor((3, 3))
torch_stub.float32 = "float32"
torch_stub.zeros = lambda shape, dtype=None, device=None: FakeTensor(shape)
nn_F = types.ModuleType("torch.nn.functional")
nn_F.pad = lambda x, *a, **k: x
nn_F.avg_pool2d = lambda x, *a, **k: x
nn_mod = types.ModuleType("torch.nn")
nn_mod.functional = nn_F
torch_stub.nn = nn_mod
return torch_stub, nn_mod, nn_F
# ───────────────────────────────────────────────────────────────────────
# Stub VAE classes
# ───────────────────────────────────────────────────────────────────────
class Fake2DVAE:
"""A vanilla 2D image VAE — no `latent_dim` attribute, so the wrapper
treats it as the standard SD/SDXL/Flux case."""
def encode(self, pixels):
return FakeTensor(
(1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8)
)
def decode(self, lat):
return FakeTensor(
(lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3)
)
class Fake3DVAE:
"""A 3D-aware video VAE — exposes `latent_dim = 3` so the wrapper
detects it and short-circuits the per-frame iteration on encode."""
latent_dim = 3
def encode(self, pixels):
if pixels.ndim == 5:
return FakeTensor((
1, 16,
pixels.shape[1] // 4,
pixels.shape[2] // 8,
pixels.shape[3] // 8,
))
return FakeTensor((1, 16, pixels.shape[1] // 8, pixels.shape[2] // 8))
def decode(self, lat):
if lat.ndim == 5:
return FakeTensor((
1, lat.shape[2] * 4,
lat.shape[3] * 8, lat.shape[4] * 8, 3,
))
return FakeTensor((lat.shape[0], lat.shape[2] * 8, lat.shape[3] * 8, 3))
# ───────────────────────────────────────────────────────────────────────
# Public entry point
# ───────────────────────────────────────────────────────────────────────
def run(verbose: bool, repo_root: Path) -> tuple[bool, list[str]]:
"""Run the five dispatch test cases and report.
Returns:
(success, failures)
- `success`: True if every case passed.
- `failures`: list of "<case_name>: <error>" strings (empty on success).
On success, a per-case "OK" line is printed when `verbose=True`.
Header / PASS-FAIL formatting is the caller's responsibility — this
module is the mechanical part.
"""
torch_stub, nn_mod, nn_F = _build_torch_stub()
pkg_path = repo_root / "forks" / "radiance_koolook" / "versions" / "v2_3_3"
# Track every sys.modules key we add so the finally block can pop them
# cleanly even if exec_module / constructors raise.
injected_modules: list[str] = []
try:
sys.modules["torch"] = torch_stub
injected_modules.append("torch")
sys.modules["torch.nn"] = nn_mod
injected_modules.append("torch.nn")
sys.modules["torch.nn.functional"] = nn_F
injected_modules.append("torch.nn.functional")
pkg_spec = importlib.machinery.ModuleSpec(
name="_pf_pkg", loader=None, is_package=True
)
pkg_spec.submodule_search_locations = [str(pkg_path)]
pkg = importlib.util.module_from_spec(pkg_spec)
sys.modules["_pf_pkg"] = pkg
injected_modules.append("_pf_pkg")
ch_spec = importlib.util.spec_from_file_location(
"_pf_pkg.color_helpers", str(pkg_path / "color_helpers.py")
)
ch_mod = importlib.util.module_from_spec(ch_spec)
sys.modules["_pf_pkg.color_helpers"] = ch_mod
injected_modules.append("_pf_pkg.color_helpers")
ch_spec.loader.exec_module(ch_mod)
nv_spec = importlib.util.spec_from_file_location(
"_pf_pkg.nodes_vae", str(pkg_path / "nodes_vae.py")
)
nv_mod = importlib.util.module_from_spec(nv_spec)
sys.modules["_pf_pkg.nodes_vae"] = nv_mod
injected_modules.append("_pf_pkg.nodes_vae")
nv_spec.loader.exec_module(nv_mod)
return _run_cases(nv_mod, verbose)
finally:
for mod in injected_modules:
sys.modules.pop(mod, None)
def _run_cases(nv_mod, verbose: bool) -> tuple[bool, list[str]]:
"""The actual five dispatch test cases. Receives the loaded
`nodes_vae` module — which now sees our stub torch."""
enc = nv_mod.Easy_hdr_VAE_encode()
dec = nv_mod.Easy_hdr_VAE_decode()
common_enc = dict(
source_space="Raw", hdr_mode="Passthrough",
exposure=0.0, alpha_handling="Preserve", latent_sampling="sample",
)
common_dec = dict(
target_space="Raw", exposure_adjust=0.0,
hdr_mode="Clip (SDR)", source_space="Linear",
)
def case_4d_image():
lat, dbg, _ = enc.encode(FakeTensor((2, 64, 64, 3)), Fake2DVAE(), **common_enc)
assert lat["samples"].ndim == 4 and "path=image" in dbg
def case_5d_2dvae():
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake2DVAE(), **common_enc)
assert (
lat["samples"].ndim == 5
and lat["samples"].shape[2] == 8
and "path=video-iter" in dbg
)
def case_5d_3dvae():
lat, dbg, _ = enc.encode(FakeTensor((1, 8, 64, 64, 3)), Fake3DVAE(), **common_enc)
assert "path=video-3d" in dbg
def case_5d_lat_decode():
img, dbg = dec.decode(
{"samples": FakeTensor((1, 16, 8, 8, 8))}, Fake2DVAE(), **common_dec
)
assert img.ndim == 4 and img.shape[0] == 8 and "path=video-iter" in dbg
def case_4d_lat_decode():
img, dbg = dec.decode(
{"samples": FakeTensor((2, 16, 8, 8))}, Fake2DVAE(), **common_dec
)
assert "path=image" in dbg
cases = [
("4D image, 2D-VAE", case_4d_image),
("5D + 2D-VAE iter", case_5d_2dvae),
("5D + 3D-VAE pass", case_5d_3dvae),
("5D latent decode iter", case_5d_lat_decode),
("4D latent decode pass", case_4d_lat_decode),
]
failures: list[str] = []
for name, fn in cases:
try:
fn()
if verbose:
# Caller can repaint this with color if it wants —
# here we just emit a plain line so the module stays
# color-helper-free.
print(f" OK {name}")
except Exception as exc:
failures.append(f"{name}: {type(exc).__name__}: {exc}")
return (not failures), failures