The five review findings of 2026-10-23 for phase P5: 1. The P5.5 fallback branch (a checkpoint without flownet.* keys) died with a bare RuntimeError at the strict load: the arch always owns the flownet (keep_arch.py:882), so the state dict of the net always carries the flownet.* keys - and the GMFlow weights the arch had just loaded were discarded. The flownet tensors of the built net (the trained ones - the arch was built with flownet_path) are now merged into the checkpoint state dict before load_state_dict(..., strict=True). The load stays strict (an unexpected key still raises) and the trained flow weights survive. The stub arches of the tests behave like nn.Module now - a no-op recorder had hidden the defect. 2. P5.4's acceptance is no longer unmet: the GPU acceptance run was executed (real weights, real ComfyUI modules, RTX 4070 Ti SUPER) and is green (5 passed, exit code 0; the run log is in the audit doc section 5). KEEP_MODEL_DIR now really decides where the weights come from - it is the models root of the run (folder_paths.models_dir), the harness fixture no longer isolates the gpu_acceptance tier, and the run needs KEEP_NO_COMFY_MOCKS=1 so the real ComfyUI modules are in charge. 3. The "the flow weights are not random" assertion of the acceptance test could not distinguish random weights from trained ones. It now compares the flownet tensors with the tensors of their source (the KEEP checkpoint or the GMFlow file) bit for bit. 4. The superseded download helper load_file_from_url_comfy is gone (src/keep_paths.py only resolves paths; the download lives in src/keep_download.py since P5.2). The stale docstrings, the stale comment in the vendored facelib and the two tests that called the dead helper are fixed. 5. The risk list mentioned a pending deletion of assets/keep-preview.png that does not exist - the asset is committed and present (93279 bytes). The rule in the implementation workflow now states the truth. Checks: the gate (pytest -q -m "unit or mock_integration") is green (234 passed, 1 skipped, 1 xfailed); the opt-in acceptance run is green (5 passed); the acceptance tier still skips cleanly without its environment variables.
218 lines
7.7 KiB
Python
218 lines
7.7 KiB
Python
"""P0.4 - dependency decision tests.
|
|
|
|
Two guards, both named by the plan:
|
|
|
|
* a **grep guard**: the package must contain exactly one diffusers import, and
|
|
it must import exactly ``FeedForward`` and ``AdaLayerNorm`` (the two symbols
|
|
that P2.2 vendors from diffusers 0.11.x);
|
|
* an **xfail guard** "flownet path is wired": the architecture config that is
|
|
handed to the KEEP arch must resolve a non-``None`` ``flownet_path``.
|
|
It is marked ``xfail(strict=False)`` because it is expected to fail until
|
|
**P5.5** wires the GMFlow resolution (the flip owner recorded in the
|
|
reason string and in the audit document).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import logging
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
PACKAGE_DIR = REPO_ROOT / "modules"
|
|
SRC_DIR = REPO_ROOT / "src"
|
|
NODES_DIR = REPO_ROOT / "nodes"
|
|
UTILS_FILE = SRC_DIR / "keep_model_configs.py"
|
|
AUDIT_DOC = REPO_ROOT / "docs" / "audit" / "2026-10-22-dependency-decisions.md"
|
|
|
|
EXPECTED_DIFFUSERS_SYMBOLS = {"FeedForward", "AdaLayerNorm"}
|
|
|
|
|
|
def _package_files() -> list[Path]:
|
|
"""Every production python file of the pack (P3.2 layout)."""
|
|
files: list[Path] = []
|
|
for directory in (PACKAGE_DIR, SRC_DIR, NODES_DIR):
|
|
files.extend(sorted(directory.rglob("*.py")))
|
|
return files
|
|
|
|
|
|
def _diffusers_imports() -> list[tuple[str, int, set[str]]]:
|
|
"""Every diffusers import in the package: (file, line, imported names)."""
|
|
found = []
|
|
for path in _package_files():
|
|
try:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
except (SyntaxError, UnicodeDecodeError):
|
|
continue
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
if node.module and node.module.split(".")[0] == "diffusers":
|
|
names = {alias.name for alias in node.names}
|
|
found.append((path.relative_to(REPO_ROOT).as_posix(), node.lineno, names))
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.split(".")[0] == "diffusers":
|
|
found.append(
|
|
(path.relative_to(REPO_ROOT).as_posix(), node.lineno, {alias.name})
|
|
)
|
|
return found
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def utils_module():
|
|
"""The model configs of the package, imported the way the package does.
|
|
|
|
Since P3.2 the configs live in ``src/keep_model_configs.py``; the module
|
|
is imported through the package alias, so the *real* module is observed
|
|
(the ComfyUI seams - ``folder_paths`` and ``comfy.utils`` - are the ones
|
|
of the root conftest's mocks).
|
|
"""
|
|
import importlib
|
|
|
|
return importlib.import_module("ComfyUI_KEEP.src.keep_model_configs")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_no_diffusers_import_remains_in_the_vendored_tree():
|
|
"""P2.2 flipped this guard: **no** diffusers import may remain.
|
|
|
|
The two symbols are vendored byte-for-byte in
|
|
``modules/deps/basicsr/archs/_diffusers_compat.py``; the vendored tree
|
|
must not depend on diffusers any more.
|
|
"""
|
|
imports = _diffusers_imports()
|
|
assert imports == [], f"no diffusers import may remain, got {imports}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_vendored_compat_module_provides_the_expected_symbols():
|
|
"""The vendored module must define exactly the two planned symbols."""
|
|
path = (
|
|
REPO_ROOT
|
|
/ "modules"
|
|
/ "deps"
|
|
/ "basicsr"
|
|
/ "archs"
|
|
/ "_diffusers_compat.py"
|
|
)
|
|
assert path.is_file(), f"the vendored compat module is missing: {path}"
|
|
source = path.read_text(encoding="utf-8")
|
|
for symbol in sorted(EXPECTED_DIFFUSERS_SYMBOLS):
|
|
assert f"class {symbol}(nn.Module):" in source, symbol
|
|
# the attribution header must name the upstream project and version
|
|
assert "diffusers 0.11.0" in source, (
|
|
"the attribution header must name diffusers 0.11.0"
|
|
)
|
|
assert "Apache License" in source, (
|
|
"the attribution header must name the Apache-2.0 license"
|
|
)
|
|
|
|
# and the architecture must use the vendored copy
|
|
keep_arch = (
|
|
REPO_ROOT / "modules" / "deps" / "basicsr" / "archs" / "keep_arch.py"
|
|
).read_text(encoding="utf-8")
|
|
assert "from ._diffusers_compat import FeedForward, AdaLayerNorm" in keep_arch, (
|
|
"keep_arch.py must import the vendored classes"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_audit_doc_records_the_diffusers_decision():
|
|
text = AUDIT_DOC.read_text(encoding="utf-8")
|
|
assert "FeedForward" in text and "AdaLayerNorm" in text
|
|
assert "0.11" in text, "the diffusers version must be recorded"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_flownet_path_is_wired(monkeypatch, tmp_path):
|
|
"""P5.5 flipped this guard: the flownet path is resolved before the arch.
|
|
|
|
The P0.4 guard asserted that the **config table** carries a non-``None``
|
|
``flownet_path``. That premise was disproved by P0.4 section 2: the
|
|
shipped KEEP checkpoint carries the trained flownet weights (123
|
|
``flownet.*`` tensors), so the GMFlow file is only needed when a
|
|
checkpoint does **not** provide them - the resolution is conditional.
|
|
|
|
The guard now asserts the guarantee that actually matters: a checkpoint
|
|
**without** flownet weights gets a **resolved** (non-``None``)
|
|
``flownet_path`` in the config that is handed to the arch - the flow net
|
|
must never keep its random weights.
|
|
"""
|
|
import importlib
|
|
|
|
import torch
|
|
|
|
loader_module = importlib.import_module("ComfyUI_KEEP.src.keep_model_loader")
|
|
built: list[dict] = []
|
|
|
|
class _StubArch:
|
|
def __init__(self, **kwargs):
|
|
built.append(kwargs)
|
|
|
|
def to(self, device):
|
|
return self
|
|
|
|
def state_dict(self):
|
|
# the real arch owns the flownet (keep_arch.py:882) - the stub
|
|
# has no flownet tensors, so nothing has to be merged into the
|
|
# checkpoint state dict before the strict load
|
|
return {}
|
|
|
|
def load_state_dict(self, state_dict, strict=True):
|
|
return self
|
|
|
|
def eval(self):
|
|
return self
|
|
|
|
class _StubHelper:
|
|
def __init__(self, **kwargs):
|
|
pass
|
|
|
|
gmflow = str(tmp_path / "gmflow_sintel-0c07dcb3.pth")
|
|
monkeypatch.setattr(loader_module, "ARCH_REGISTRY", {"KEEP": _StubArch})
|
|
monkeypatch.setattr(
|
|
loader_module.torch, "load", lambda *a, **k: {"params_ema": {"w": torch.zeros(1)}}
|
|
)
|
|
monkeypatch.setattr(loader_module, "_face_restore_helper", lambda: _StubHelper)
|
|
monkeypatch.setattr(
|
|
loader_module.keep_paths, "resolve_model_root", lambda *a, **k: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
loader_module.keep_download,
|
|
"ensure_weight",
|
|
lambda entry, progress=True: str(tmp_path / "checkpoint.pth"),
|
|
)
|
|
monkeypatch.setattr(
|
|
loader_module.keep_download,
|
|
"ensure_required",
|
|
lambda entry, progress=True: str(tmp_path / "weight.pth"),
|
|
)
|
|
monkeypatch.setattr(
|
|
loader_module.keep_download,
|
|
"ensure_optional",
|
|
lambda entry, progress=True: gmflow,
|
|
)
|
|
|
|
loader_module.KEEPModelLoader().load_keep_model_pack(
|
|
"KEEP", "retinaface_resnet50"
|
|
)
|
|
|
|
assert built, "the architecture was never built"
|
|
assert built[0]["flownet_path"] is not None, (
|
|
"flownet_path is None - the GMFlow net would keep its random weights"
|
|
)
|
|
assert built[0]["flownet_path"] == gmflow
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_flownet_is_built_by_the_arch(utils_module):
|
|
"""The vendored arch builds the flownet from the config (code read check)."""
|
|
arch = REPO_ROOT / "modules" / "deps" / "basicsr" / "archs" / "keep_arch.py"
|
|
source = arch.read_text(encoding="utf-8")
|
|
assert "self.flownet = FlowGenerator(path=flownet_path)" in source
|
|
assert "flows = self.get_flow(x)" in source
|