Sandpies Claude Opus 5 commited on
Commit
1529fa4
·
1 Parent(s): 37dbe80

Make the compatibility claim testable instead of aspirational

Browse files

Everyone runs a different ComfyUI, and Core's parameter ORDER has already moved
once -- master reorders MiniMaxH3ReferenceToVideo. Three things now stand
between that and a user's traceback.

**No positional Core call is left.** The two H3 nodes were converted when a
user hit "multiple values for argument 'ref_image_size'"; the other six were
not, and were the same failure waiting: MiniMaxH3SigmaShift, KSamplerSelect,
BasicScheduler, BasicGuider, RandomNoise, SamplerCustomAdvanced. All now go
through _core_call by keyword, so they also inherit its error message, which
names the node, the argument and what the installed Core actually takes.

**check_core_calls.py watches all nine nodes, not three.** It resolves each
one through the module that defines it, so nodes_custom_sampler is covered
alongside nodes_minimax_h3. Eleven call sites, every one order-independent and
name-checked against the Core on this machine.

**check_core_matrix.py checks the Core on everyone else's.** It reads the floor
from pyproject, enumerates every ComfyUI release at or above it plus today's
master, and binds all eleven call sites against each version's real signatures
-- node present, nothing positional, every keyword real, every required
parameter supplied. Signatures are read with ast, never imported: it downloads
code from the internet and parsing is not running.

Falsified by lowering the floor to 0.30.0, where it fails on v0.30.0, v0.31.0,
v0.32.0 and v0.33.1 with "MiniMaxH3AddGuide does not exist here" and passes on
v0.34.0 and master -- deriving the floor independently of the reasoning that
set it. Network-bound, so it is not in check_all.py.

Verified statically and by signature binding, not by a render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLXmbwfdXirQ5oFreXPcMi

.gitignore CHANGED
@@ -20,3 +20,4 @@ htc_llm.json
20
  # dead ends and machine specifics, and one of them is always the live queue.
21
  # The root HANDOVER.md was already ignored; these are the same thing, dated.
22
  docs/HANDOVER_*.md
 
 
20
  # dead ends and machine specifics, and one of them is always the live queue.
21
  # The root HANDOVER.md was already ignored; these are the same thing, dated.
22
  docs/HANDOVER_*.md
23
+ .core_matrix_cache/
h3_ref_chain.py CHANGED
@@ -2879,9 +2879,17 @@ class HandTieClips:
2879
  sampler = base_sigmas = None
2880
  sigma_cache = {}
2881
  if not dry:
2882
- model = _result(MiniMaxH3SigmaShift.execute(model, float(shift_video), float(shift_audio)))[0]
2883
- sampler = _result(KSamplerSelect.execute(sampler_name))[0]
2884
- base_sigmas = _result(BasicScheduler.execute(model, scheduler, int(steps), 1.0))[0]
 
 
 
 
 
 
 
 
2885
  sigma_cache = {int(steps): base_sigmas}
2886
 
2887
  print(
@@ -3587,23 +3595,30 @@ class HandTieClips:
3587
 
3588
  _offload_text_encoder(clip, model)
3589
 
3590
- guider = _result(BasicGuider.execute(model, cond))[0]
 
 
3591
  if shot.get("seed") is not None:
3592
  shot_seed = int(shot["seed"])
3593
  else:
3594
  shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
3595
  hop_steps = int(shot.get("steps") or steps)
3596
  if hop_steps not in sigma_cache:
3597
- sigma_cache[hop_steps] = _result(
3598
- BasicScheduler.execute(model, scheduler, hop_steps, 1.0))[0]
 
 
3599
  hop_sigmas = sigma_cache[hop_steps]
3600
  if hop_steps != int(steps) or shot.get("seed") is not None:
3601
  print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
3602
  f"steps={hop_steps}", flush=True)
3603
- noise = _result(RandomNoise.execute(shot_seed))[0]
3604
- sampled = _result(SamplerCustomAdvanced.execute(
3605
- noise, guider, sampler, hop_sigmas, latent
3606
- ))[0]
 
 
 
3607
 
3608
  imgs, audio = _decode_av(vae, audio_vae, sampled)
3609
  imgs = imgs.contiguous().cpu()
 
2879
  sampler = base_sigmas = None
2880
  sigma_cache = {}
2881
  if not dry:
2882
+ model = _result(_core_call(
2883
+ MiniMaxH3SigmaShift, "the sigma shift",
2884
+ model=model, shift_video=float(shift_video),
2885
+ shift_audio=float(shift_audio)))[0]
2886
+ sampler = _result(_core_call(
2887
+ KSamplerSelect, "the sampler",
2888
+ sampler_name=sampler_name))[0]
2889
+ base_sigmas = _result(_core_call(
2890
+ BasicScheduler, "the sigma schedule",
2891
+ model=model, scheduler=scheduler, steps=int(steps),
2892
+ denoise=1.0))[0]
2893
  sigma_cache = {int(steps): base_sigmas}
2894
 
2895
  print(
 
3595
 
3596
  _offload_text_encoder(clip, model)
3597
 
3598
+ guider = _result(_core_call(
3599
+ BasicGuider, "the guider",
3600
+ model=model, conditioning=cond))[0]
3601
  if shot.get("seed") is not None:
3602
  shot_seed = int(shot["seed"])
3603
  else:
3604
  shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
3605
  hop_steps = int(shot.get("steps") or steps)
3606
  if hop_steps not in sigma_cache:
3607
+ sigma_cache[hop_steps] = _result(_core_call(
3608
+ BasicScheduler, "the sigma schedule",
3609
+ model=model, scheduler=scheduler, steps=hop_steps,
3610
+ denoise=1.0))[0]
3611
  hop_sigmas = sigma_cache[hop_steps]
3612
  if hop_steps != int(steps) or shot.get("seed") is not None:
3613
  print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
3614
  f"steps={hop_steps}", flush=True)
3615
+ noise = _result(_core_call(
3616
+ RandomNoise, "the noise source",
3617
+ noise_seed=shot_seed))[0]
3618
+ sampled = _result(_core_call(
3619
+ SamplerCustomAdvanced, "the sampler",
3620
+ noise=noise, guider=guider, sampler=sampler,
3621
+ sigmas=hop_sigmas, latent_image=latent))[0]
3622
 
3623
  imgs, audio = _decode_av(vae, audio_vae, sampled)
3624
  imgs = imgs.contiguous().cpu()
tools/check_core_calls.py CHANGED
@@ -31,7 +31,20 @@ COMFY = os.path.dirname(os.path.dirname(HERE))
31
  sys.path.insert(0, COMFY)
32
 
33
  FAIL = []
34
- WATCHED = {"MiniMaxH3ReferenceToVideo", "MiniMaxH3AddGuide", "MiniMaxH3ImageToVideo"}
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
  def ck(name, cond, detail=""):
@@ -41,7 +54,11 @@ def ck(name, cond, detail=""):
41
 
42
 
43
  def core_params(cls_name):
44
- from comfy_extras import nodes_minimax_h3 as core
 
 
 
 
45
  cls = getattr(core, cls_name, None)
46
  if cls is None:
47
  return None
 
31
  sys.path.insert(0, COMFY)
32
 
33
  FAIL = []
34
+ # Every Core node this pack calls, and the module that defines it. The H3 three
35
+ # are the ones whose order actually moved; the sampler five are the same failure
36
+ # class waiting to happen, and cost nothing to watch.
37
+ WATCHED = {
38
+ "MiniMaxH3ReferenceToVideo": "comfy_extras.nodes_minimax_h3",
39
+ "MiniMaxH3AddGuide": "comfy_extras.nodes_minimax_h3",
40
+ "MiniMaxH3ImageToVideo": "comfy_extras.nodes_minimax_h3",
41
+ "MiniMaxH3SigmaShift": "comfy_extras.nodes_minimax_h3",
42
+ "KSamplerSelect": "comfy_extras.nodes_custom_sampler",
43
+ "BasicScheduler": "comfy_extras.nodes_custom_sampler",
44
+ "BasicGuider": "comfy_extras.nodes_custom_sampler",
45
+ "RandomNoise": "comfy_extras.nodes_custom_sampler",
46
+ "SamplerCustomAdvanced": "comfy_extras.nodes_custom_sampler",
47
+ }
48
 
49
 
50
  def ck(name, cond, detail=""):
 
54
 
55
 
56
  def core_params(cls_name):
57
+ import importlib
58
+ try:
59
+ core = importlib.import_module(WATCHED[cls_name])
60
+ except (ImportError, KeyError):
61
+ return None
62
  cls = getattr(core, cls_name, None)
63
  if cls is None:
64
  return None
tools/check_core_matrix.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prove every Core call works on EVERY ComfyUI in the range we advertise.
2
+
3
+ check_core_calls.py checks the Core installed on this machine. That proves the
4
+ pack does not depend on parameter order, which is what broke a user once, but it
5
+ cannot prove the call works on the build somebody else is running -- and
6
+ everybody is running something different. pyproject.toml claims
7
+ `requires-comfyui = ">=0.34.0"` with no upper bound. This is what makes that
8
+ claim testable rather than aspirational.
9
+
10
+ For every ComfyUI release at or above the declared floor, plus today's master,
11
+ it fetches the modules that define the nodes this pack calls, reads their
12
+ `execute` signatures, and checks each call site against them:
13
+
14
+ * the node exists in that version at all
15
+ * nothing is passed positionally
16
+ * every keyword passed is a real parameter there
17
+ * every parameter that has no default there is one we supply
18
+
19
+ Signatures are read with `ast`, never imported -- this downloads code from the
20
+ internet, and parsing it is not the same as running it.
21
+
22
+ Network, so it is not in check_all.py. Run it when Core moves, when the floor
23
+ changes, or before a release.
24
+
25
+ D:\\ComfyUI\\venv\\Scripts\\python.exe tools\\check_core_matrix.py
26
+ ... --refresh ignore the cache and re-fetch
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import ast
31
+ import io
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+
42
+ # comfyanonymous/ComfyUI redirected to Comfy-Org/ComfyUI; the numeric id is
43
+ # stable across the rename and does not 301.
44
+ REPO = "https://api.github.com/repositories/589831718"
45
+ RAW = "https://raw.githubusercontent.com/Comfy-Org/ComfyUI/{ref}/{path}"
46
+ CACHE = os.path.join(HERE, ".core_matrix_cache")
47
+
48
+ MODULES = {
49
+ "comfy_extras.nodes_minimax_h3": "comfy_extras/nodes_minimax_h3.py",
50
+ "comfy_extras.nodes_custom_sampler": "comfy_extras/nodes_custom_sampler.py",
51
+ }
52
+
53
+ FAILS = []
54
+
55
+
56
+ def ck(label, ok, detail=""):
57
+ print(" %-4s %-46s %s" % ("ok" if ok else "FAIL", label, detail))
58
+ if not ok:
59
+ FAILS.append(label)
60
+
61
+
62
+ def fetch(url, cache_key, refresh=False):
63
+ path = os.path.join(CACHE, cache_key)
64
+ if not refresh and os.path.exists(path):
65
+ return io.open(path, encoding="utf-8").read()
66
+ with urllib.request.urlopen(url, timeout=60) as r:
67
+ body = r.read().decode("utf-8")
68
+ os.makedirs(os.path.dirname(path), exist_ok=True)
69
+ io.open(path, "w", encoding="utf-8", newline="").write(body)
70
+ return body
71
+
72
+
73
+ def parse_version(tag):
74
+ m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", tag or "")
75
+ return tuple(int(x) for x in m.groups()) if m else None
76
+
77
+
78
+ def floor_from_pyproject():
79
+ s = io.open(os.path.join(HERE, "pyproject.toml"), encoding="utf-8").read()
80
+ m = re.search(r'^requires-comfyui\s*=\s*"([^"]+)"', s, re.M)
81
+ if not m:
82
+ return None, ""
83
+ spec = m.group(1)
84
+ return parse_version(spec.lstrip(">=<!~ ")), spec
85
+
86
+
87
+ def signatures(src):
88
+ """{name: (param_names, required_names)} read without importing."""
89
+ out = {}
90
+ tree = ast.parse(src)
91
+ for cls in [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]:
92
+ for fn in cls.body:
93
+ if not (isinstance(fn, ast.FunctionDef) and fn.name == "execute"):
94
+ continue
95
+ a = fn.args
96
+ names = [x.arg for x in (a.posonlyargs + a.args)
97
+ if x.arg not in ("cls", "self")]
98
+ ndef = len(a.defaults)
99
+ req = names[:len(names) - ndef] if ndef else list(names)
100
+ names += [x.arg for x in a.kwonlyargs]
101
+ req += [x.arg for x, d in zip(a.kwonlyargs, a.kw_defaults)
102
+ if d is None]
103
+ # Indexed by the class name, because that is what this pack imports.
104
+ out[cls.name] = (names, req)
105
+ return out
106
+
107
+
108
+ def call_sites():
109
+ """Every watched Core call in the pack, with the keywords it passes."""
110
+ import check_core_calls as C
111
+ src = io.open(os.path.join(HERE, "h3_ref_chain.py"), encoding="utf-8").read()
112
+ sites = []
113
+ for node in ast.walk(ast.parse(src)):
114
+ if not isinstance(node, ast.Call):
115
+ continue
116
+ f, owner, args = node.func, None, None
117
+ if (isinstance(f, ast.Attribute) and f.attr == "execute"
118
+ and isinstance(f.value, ast.Name)):
119
+ owner, args = f.value.id, node.args
120
+ elif isinstance(f, ast.Name) and f.id == "_core_call" and node.args:
121
+ first = node.args[0]
122
+ if isinstance(first, ast.Name):
123
+ # _core_call(Node, "what", **kw) -- the first two are the
124
+ # wrapper's own, everything after would be positional to Core.
125
+ owner, args = first.id, node.args[2:]
126
+ if owner in C.WATCHED:
127
+ sites.append((owner, node.lineno, len(args or []),
128
+ [k.arg for k in node.keywords if k.arg]))
129
+ return sites
130
+
131
+
132
+ def main():
133
+ refresh = "--refresh" in sys.argv
134
+ floor, spec = floor_from_pyproject()
135
+ if floor is None:
136
+ print("pyproject.toml declares no requires-comfyui; nothing to verify")
137
+ return 1
138
+ print("pyproject declares requires-comfyui = %r\n" % spec)
139
+
140
+ sites = call_sites()
141
+ print("%d call site(s) across %d node(s)"
142
+ % (len(sites), len({s[0] for s in sites})))
143
+
144
+ rels = json.loads(fetch(REPO + "/releases?per_page=100", "releases.json",
145
+ refresh=True))
146
+ tags = sorted({r["tag_name"] for r in rels
147
+ if parse_version(r["tag_name"])
148
+ and parse_version(r["tag_name"]) >= floor},
149
+ key=parse_version)
150
+ refs = tags + ["master"]
151
+ print("%d version(s) at or above the floor, plus master\n" % len(tags))
152
+
153
+ for ref in refs:
154
+ sigs, missing = {}, None
155
+ for path in MODULES.values():
156
+ try:
157
+ src = fetch(RAW.format(ref=ref, path=path),
158
+ os.path.join(ref, path.replace("/", "_")),
159
+ refresh=refresh or ref == "master")
160
+ except urllib.error.HTTPError as e:
161
+ missing = "%s (HTTP %d)" % (path, e.code)
162
+ continue
163
+ sigs.update(signatures(src))
164
+ if missing:
165
+ ck("%s: has the modules this pack imports" % ref, False, missing)
166
+ continue
167
+
168
+ bad = []
169
+ for owner, line, npos, kws in sites:
170
+ if owner not in sigs:
171
+ bad.append("%s @:%d does not exist here" % (owner, line))
172
+ continue
173
+ names, req = sigs[owner]
174
+ if npos:
175
+ bad.append("%s @:%d passes %d positionally" % (owner, line, npos))
176
+ unknown = [k for k in kws if k not in names]
177
+ if unknown:
178
+ bad.append("%s @:%d passes %s; this version takes %s"
179
+ % (owner, line, unknown, names))
180
+ unfilled = [r for r in req if r not in kws]
181
+ if unfilled:
182
+ bad.append("%s @:%d omits required %s" % (owner, line, unfilled))
183
+ ck("%s: every call binds" % ref, not bad,
184
+ "%d site(s)" % len(sites) if not bad else "")
185
+ for b in bad:
186
+ print(" %s" % b)
187
+
188
+ if FAILS:
189
+ print("\nCORE MATRIX: %d version(s) this pack claims to support and "
190
+ "does not." % len(FAILS))
191
+ return 1
192
+ print("\nCORE MATRIX: every call site binds on every version at or above "
193
+ "the declared floor, and on master.")
194
+ return 0
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())