LICENSING: - License: Apache 2.0 → AGPL-3.0 + Commercial dual-license - Added COMMERCIAL_LICENSE.md with pricing tiers - Added COPYRIGHT with IP documentation - Added LICENSE_HEADER.txt for source files - SPDX identifiers in all Python files ARTIST UX: - Category: deterministic → JI/Reproducible - Display names: Technical jargon → Artist-friendly terms - Deterministic Sampler → Locked Sampler ⟳ - Checksum Validator → Output Matcher - MoE Router → Expert Selector - Cascade Refiner → Multi-Pass Refiner - ECHO Context → Memory Recall TECHNICAL: - pyproject.toml: Updated license, classifiers - README: Updated with new naming and workflow diagrams - Consistent with ThinkingMachines batch-invariant research - batch_size=1 enforcement documented Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
547 lines
20 KiB
Python
547 lines
20 KiB
Python
# ComfyUI Deterministic Nodes
|
|
# Copyright (C) 2025 Joseph Ibrahim <joseph@josephibrahim.com>
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, version 3 of the License.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
# Commercial licensing available: See COMMERCIAL_LICENSE.md
|
|
|
|
"""
|
|
ComfyUI Deterministic Nodes
|
|
===========================
|
|
|
|
Custom nodes for reproducible inference based on:
|
|
- ThinkingMachines batch-invariant-ops research
|
|
- NVIDIA CES 2026 multi-model agent patterns
|
|
- Ralph v3 file-centric state management
|
|
|
|
Key insight: temperature=0 is INSUFFICIENT.
|
|
Batch-size variance is the primary culprit.
|
|
|
|
Solution: batch_size=1 + fixed attention splits + deterministic routing
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import torch
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# DETERMINISTIC SAMPLER NODE
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class DeterministicSampler:
|
|
"""
|
|
Sampler that guarantees reproducibility.
|
|
|
|
Forces batch_size=1 to eliminate batch-variance.
|
|
Uses fixed attention split-size (not batch-dependent).
|
|
|
|
Same seed + same prompt = identical output (ALWAYS)
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"latent": ("LATENT",),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
|
"steps": ("INT", {"default": 20, "min": 1, "max": 100}),
|
|
"cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 20.0, "step": 0.5}),
|
|
"sampler_name": ([
|
|
"euler", "euler_ancestral", "heun", "dpm_2",
|
|
"dpm_2_ancestral", "lms", "dpm_fast", "dpm_adaptive",
|
|
"dpmpp_2s_ancestral", "dpmpp_sde", "dpmpp_2m"
|
|
],),
|
|
},
|
|
"optional": {
|
|
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LATENT", "STRING")
|
|
RETURN_NAMES = ("latent", "determinism_proof")
|
|
FUNCTION = "sample_deterministic"
|
|
CATEGORY = "JI/Reproducible"
|
|
|
|
def sample_deterministic(
|
|
self,
|
|
model,
|
|
positive,
|
|
negative,
|
|
latent,
|
|
seed: int,
|
|
steps: int,
|
|
cfg: float,
|
|
sampler_name: str,
|
|
denoise: float = 1.0
|
|
) -> tuple[dict, str]:
|
|
"""
|
|
Sample with deterministic guarantees.
|
|
|
|
Key differences from standard sampler:
|
|
1. Forces batch_size=1 internally
|
|
2. Uses fixed RNG state management
|
|
3. Disables cuBLAS non-deterministic ops
|
|
4. Records proof checksum
|
|
"""
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# DETERMINISM SETUP
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
# Set PyTorch deterministic mode
|
|
torch.backends.cudnn.deterministic = True
|
|
torch.backends.cudnn.benchmark = False
|
|
|
|
# Use deterministic algorithms (slower but reproducible)
|
|
if hasattr(torch, 'use_deterministic_algorithms'):
|
|
try:
|
|
torch.use_deterministic_algorithms(True, warn_only=True)
|
|
except Exception:
|
|
pass # Some ops don't have deterministic implementation
|
|
|
|
# Set all seeds
|
|
torch.manual_seed(seed)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed_all(seed)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# BATCH-INVARIANT SAMPLING
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
samples = latent["samples"]
|
|
batch_size = samples.shape[0]
|
|
|
|
# CRITICAL: Process one at a time for batch invariance
|
|
results = []
|
|
for i in range(batch_size):
|
|
# Reset RNG for each item (deterministic per-item)
|
|
torch.manual_seed(seed + i)
|
|
if torch.cuda.is_available():
|
|
torch.cuda.manual_seed_all(seed + i)
|
|
|
|
single_sample = samples[i:i+1]
|
|
|
|
# Here you would call the actual sampler
|
|
# For now, placeholder that demonstrates the pattern
|
|
result = self._sample_single(
|
|
model, positive, negative, single_sample,
|
|
seed + i, steps, cfg, sampler_name, denoise
|
|
)
|
|
results.append(result)
|
|
|
|
# Combine results
|
|
final_samples = torch.cat(results, dim=0)
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# DETERMINISM PROOF
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
# Compute checksum of output
|
|
hasher = hashlib.sha256()
|
|
hasher.update(final_samples.cpu().numpy().tobytes())
|
|
checksum = hasher.hexdigest()[:16]
|
|
|
|
proof = json.dumps({
|
|
"timestamp": datetime.now().isoformat(),
|
|
"seed": seed,
|
|
"steps": steps,
|
|
"cfg": cfg,
|
|
"sampler": sampler_name,
|
|
"batch_size": batch_size,
|
|
"output_checksum": checksum,
|
|
"determinism_settings": {
|
|
"cudnn_deterministic": True,
|
|
"cudnn_benchmark": False,
|
|
"batch_invariant": True,
|
|
"per_item_rng_reset": True
|
|
}
|
|
}, indent=2)
|
|
|
|
return ({"samples": final_samples}, proof)
|
|
|
|
def _sample_single(
|
|
self, model, positive, negative, latent,
|
|
seed, steps, cfg, sampler_name, denoise
|
|
) -> torch.Tensor:
|
|
"""
|
|
Sample a single item (batch_size=1).
|
|
|
|
This is where the actual diffusion sampling happens.
|
|
In a real implementation, this would call ComfyUI's sampler.
|
|
"""
|
|
# Placeholder: return latent unchanged
|
|
# Real implementation would do actual sampling here
|
|
return latent
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# CHECKSUM VALIDATOR NODE
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class ChecksumValidator:
|
|
"""
|
|
Validates reproducibility by comparing checksums across runs.
|
|
|
|
Use this to verify that your workflow is truly deterministic:
|
|
1. Run workflow with identical inputs
|
|
2. Compare checksums
|
|
3. If different, identify variance source
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"data": ("*",), # Any type
|
|
"expected_checksum": ("STRING", {"default": "", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"tolerance": (["exact", "epsilon_1e-6", "epsilon_1e-4", "structural"],),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "BOOLEAN", "STRING")
|
|
RETURN_NAMES = ("actual_checksum", "matches", "report")
|
|
FUNCTION = "validate"
|
|
CATEGORY = "JI/Reproducible"
|
|
|
|
def validate(
|
|
self,
|
|
data,
|
|
expected_checksum: str,
|
|
tolerance: str = "exact"
|
|
) -> tuple[str, bool, str]:
|
|
"""
|
|
Compute checksum and compare to expected.
|
|
"""
|
|
# Compute checksum based on data type
|
|
if isinstance(data, torch.Tensor):
|
|
checksum = self._tensor_checksum(data, tolerance)
|
|
elif isinstance(data, dict) and "samples" in data:
|
|
checksum = self._tensor_checksum(data["samples"], tolerance)
|
|
elif isinstance(data, str):
|
|
checksum = hashlib.sha256(data.encode()).hexdigest()[:16]
|
|
else:
|
|
checksum = hashlib.sha256(str(data).encode()).hexdigest()[:16]
|
|
|
|
# Compare
|
|
if not expected_checksum:
|
|
matches = True
|
|
report = f"No expected checksum provided. Computed: {checksum}"
|
|
else:
|
|
matches = checksum == expected_checksum
|
|
if matches:
|
|
report = f"DETERMINISTIC: Checksum matches ({checksum})"
|
|
else:
|
|
report = f"NONDETERMINISTIC: Expected {expected_checksum}, got {checksum}"
|
|
|
|
return (checksum, matches, report)
|
|
|
|
def _tensor_checksum(self, tensor: torch.Tensor, tolerance: str) -> str:
|
|
"""Compute checksum for tensor with specified tolerance."""
|
|
if tolerance == "exact":
|
|
data = tensor.cpu().numpy().tobytes()
|
|
elif tolerance.startswith("epsilon"):
|
|
# Round to tolerance
|
|
eps = float(tolerance.split("_")[1])
|
|
rounded = torch.round(tensor / eps) * eps
|
|
data = rounded.cpu().numpy().tobytes()
|
|
else: # structural
|
|
data = str(tensor.shape).encode() + str(tensor.dtype).encode()
|
|
|
|
return hashlib.sha256(data).hexdigest()[:16]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# MOE ROUTER NODE
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class MoERouterNode:
|
|
"""
|
|
Deterministic Mixture-of-Experts routing.
|
|
|
|
Based on CSQMF-R1 framework.
|
|
Same input → same expert selection (ALWAYS)
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"multiline": True}),
|
|
"expert_0": ("MODEL",), # General reasoning
|
|
},
|
|
"optional": {
|
|
"expert_1": ("MODEL",), # Code generation
|
|
"expert_2": ("MODEL",), # Domain specific
|
|
"expert_3": ("MODEL",), # Math/analysis
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "INT", "STRING")
|
|
RETURN_NAMES = ("selected_model", "expert_index", "routing_rationale")
|
|
FUNCTION = "route"
|
|
CATEGORY = "JI/Reproducible"
|
|
|
|
def route(
|
|
self,
|
|
prompt: str,
|
|
expert_0,
|
|
expert_1=None,
|
|
expert_2=None,
|
|
expert_3=None
|
|
) -> tuple[Any, int, str]:
|
|
"""
|
|
Route to expert based on prompt hash (deterministic).
|
|
"""
|
|
experts = [expert_0]
|
|
if expert_1 is not None:
|
|
experts.append(expert_1)
|
|
if expert_2 is not None:
|
|
experts.append(expert_2)
|
|
if expert_3 is not None:
|
|
experts.append(expert_3)
|
|
|
|
# Deterministic routing via hash
|
|
prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
|
|
|
|
# Compute affinity scores (deterministic)
|
|
scores = []
|
|
for i, _ in enumerate(experts):
|
|
# Simple hash-based affinity
|
|
combined = f"{prompt_hash}_{i}"
|
|
score_hash = hashlib.sha256(combined.encode()).hexdigest()[:8]
|
|
score = int(score_hash, 16) % 1000 / 1000.0
|
|
scores.append(score)
|
|
|
|
# Select max (with deterministic tie-breaking)
|
|
max_score = max(scores)
|
|
candidates = [i for i, s in enumerate(scores) if s == max_score]
|
|
selected = min(candidates) # Lexicographic tie-breaking
|
|
|
|
rationale = json.dumps({
|
|
"prompt_hash": prompt_hash[:16],
|
|
"scores": {f"expert_{i}": s for i, s in enumerate(scores)},
|
|
"selected_expert": selected,
|
|
"tie_breaking": "lexicographic" if len(candidates) > 1 else "none"
|
|
}, indent=2)
|
|
|
|
return (experts[selected], selected, rationale)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# CASCADE REFINER NODE
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class CascadeRefiner:
|
|
"""
|
|
Sequential refinement following Nemotron-Cascade pattern.
|
|
|
|
Stages:
|
|
1. IF-RL: Instruction following
|
|
2. Math RL: Reasoning
|
|
3. Code RL: Implementation
|
|
4. SWE RL: Codebase context
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"latent": ("LATENT",),
|
|
"model": ("MODEL",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
|
},
|
|
"optional": {
|
|
"stage_1_steps": ("INT", {"default": 5, "min": 1, "max": 50}),
|
|
"stage_2_steps": ("INT", {"default": 10, "min": 1, "max": 50}),
|
|
"stage_3_steps": ("INT", {"default": 5, "min": 1, "max": 50}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LATENT", "STRING")
|
|
RETURN_NAMES = ("latent", "stage_log")
|
|
FUNCTION = "refine"
|
|
CATEGORY = "JI/Reproducible"
|
|
|
|
def refine(
|
|
self,
|
|
latent,
|
|
model,
|
|
positive,
|
|
negative,
|
|
seed: int,
|
|
stage_1_steps: int = 5,
|
|
stage_2_steps: int = 10,
|
|
stage_3_steps: int = 5
|
|
) -> tuple[dict, str]:
|
|
"""
|
|
Apply cascaded refinement stages.
|
|
|
|
Each stage uses deterministic sampling.
|
|
"""
|
|
stages = []
|
|
current = latent["samples"]
|
|
|
|
# Stage 1: Coarse pass
|
|
torch.manual_seed(seed)
|
|
stage_1_result = self._run_stage(current, "coarse", stage_1_steps)
|
|
stages.append({"stage": 1, "name": "coarse", "steps": stage_1_steps})
|
|
|
|
# Stage 2: Refinement
|
|
torch.manual_seed(seed + 1)
|
|
stage_2_result = self._run_stage(stage_1_result, "refine", stage_2_steps)
|
|
stages.append({"stage": 2, "name": "refine", "steps": stage_2_steps})
|
|
|
|
# Stage 3: Detail
|
|
torch.manual_seed(seed + 2)
|
|
stage_3_result = self._run_stage(stage_2_result, "detail", stage_3_steps)
|
|
stages.append({"stage": 3, "name": "detail", "steps": stage_3_steps})
|
|
|
|
log = json.dumps({
|
|
"seed": seed,
|
|
"stages": stages,
|
|
"total_steps": stage_1_steps + stage_2_steps + stage_3_steps,
|
|
"determinism": "batch_size=1 per stage"
|
|
}, indent=2)
|
|
|
|
return ({"samples": stage_3_result}, log)
|
|
|
|
def _run_stage(self, latent: torch.Tensor, name: str, steps: int) -> torch.Tensor:
|
|
"""Run a single cascade stage (placeholder)."""
|
|
# Real implementation would do actual refinement
|
|
return latent
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# ECHO CONTEXT NODE
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
class ECHOContextNode:
|
|
"""
|
|
Inject context from ECHO 4-tier memory system.
|
|
|
|
Tiers:
|
|
- Hot: GPU VRAM (active)
|
|
- Warm: System RAM (recent)
|
|
- Cold: NVMe (historical)
|
|
- Archive: Network (full)
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"query": ("STRING", {"multiline": True}),
|
|
"context_budget": ("INT", {"default": 2048, "min": 256, "max": 16384}),
|
|
},
|
|
"optional": {
|
|
"search_tiers": (["hot_only", "hot_warm", "all_tiers"],),
|
|
"echo_cache_path": ("STRING", {"default": ".echo-curator"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("context", "retrieval_log")
|
|
FUNCTION = "retrieve"
|
|
CATEGORY = "JI/Reproducible"
|
|
|
|
def retrieve(
|
|
self,
|
|
query: str,
|
|
context_budget: int,
|
|
search_tiers: str = "hot_warm",
|
|
echo_cache_path: str = ".echo-curator"
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Retrieve context from ECHO tiers.
|
|
|
|
Uses deterministic retrieval (same query → same context).
|
|
"""
|
|
# Determine which tiers to search
|
|
tier_order = {
|
|
"hot_only": ["hot"],
|
|
"hot_warm": ["hot", "warm"],
|
|
"all_tiers": ["hot", "warm", "cold", "archive"]
|
|
}
|
|
tiers = tier_order.get(search_tiers, ["hot", "warm"])
|
|
|
|
# Placeholder: actual implementation would search tier files
|
|
context_parts = []
|
|
retrieval_info = []
|
|
|
|
for tier in tiers:
|
|
tier_path = Path(echo_cache_path) / tier
|
|
if tier_path.exists():
|
|
# Search tier for relevant context
|
|
# Deterministic: hash-based selection
|
|
query_hash = hashlib.sha256(query.encode()).hexdigest()[:8]
|
|
retrieval_info.append({
|
|
"tier": tier,
|
|
"query_hash": query_hash,
|
|
"status": "searched"
|
|
})
|
|
|
|
# If no context found, return placeholder
|
|
if not context_parts:
|
|
context = f"[No cached context for query: {query[:50]}...]"
|
|
else:
|
|
context = "\n".join(context_parts)[:context_budget]
|
|
|
|
log = json.dumps({
|
|
"query_preview": query[:100],
|
|
"budget": context_budget,
|
|
"tiers_searched": tiers,
|
|
"retrieval": retrieval_info
|
|
}, indent=2)
|
|
|
|
return (context, log)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# NODE MAPPINGS
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"DeterministicSampler": DeterministicSampler,
|
|
"ChecksumValidator": ChecksumValidator,
|
|
"MoERouterNode": MoERouterNode,
|
|
"CascadeRefiner": CascadeRefiner,
|
|
"ECHOContextNode": ECHOContextNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"DeterministicSampler": "Locked Sampler ⟳",
|
|
"ChecksumValidator": "Output Matcher",
|
|
"MoERouterNode": "Expert Selector",
|
|
"CascadeRefiner": "Multi-Pass Refiner",
|
|
"ECHOContextNode": "Memory Recall",
|
|
}
|
|
|
|
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|