Interactive Guessing: UI trigger + Phase-3 learning & dual auto-cap

UI: Batch Training tab gains Mode (Regular / Interactive Guessing),
Direction, Range, and a Learning toggle; Arm sets refiner.guess_* +
batch_learning; auto-disarm clears guess_mode; rating list shows each
rung's [x factor]; Submit passes the learn flag.

Learning (batch_training.py): submit honors learn=false (pure generation,
no training). Guess batches detected via guess_factor -> _learn_guess_ceiling
finds the furthest-pushed acceptable rung before quality broke and stores a
per-key safe ceiling (absolute spread + factor) via _store_guess_ceiling.

Dual auto-cap (refine_v2, normal runs only): soft pre-limit on
absolute_strength + hard clamp of the output conditioning's video-channel
spread to the learned-safe value in _v2_finalize_conditioning. Skipped
during the guessing ladder itself. Helpers conditioning_spread /
scale_conditioning_spread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-04 15:35:58 +03:00
co-authored by Claude Opus 4.8
parent 7a6821930a
commit c8e4f2c404
4 changed files with 182 additions and 12 deletions
+6
View File
@@ -4,6 +4,12 @@
### Added
**Interactive Guessing** — a new Batch Training mode. Instead of varying the seed, it freezes *everything* (including the noise seed) and sweeps the **conditioning's spread** (its "sigma") on video channels along a **linear ramp** across the N rungs — up to amplify toward overbake, down to dampen — so you can see exactly where it breaks. Each rung records its factor. When you rate the ladder, it learns your **safe steering ceiling** for that key and **auto-caps future steering**: a soft pre-limit on absolute/relative steer strength plus a hard clamp on the output conditioning's spread (the guarantee). Audio is never touched (reuses the LTXAV video/audio channel split). The Batch Training tab gains **Mode** (Regular / Interactive Guessing), **Direction**, **Range**, and a **Learning** toggle — Learning off (either mode) means pure generation that teaches nothing. Combined with the new IMAGES output (all batch videos come out the Chain Sampler's IMAGES port), you can now generate N slight variations and just watch them without rating.
### Changed
The Chain Sampler's **IMAGES output now returns every batch video** concatenated, not just the last one — for both regular batches and Interactive Guessing.
**Latent normalization stacks on the Hybrid and Distilled samplers too.** The same video-only anti-overbake normalization from the Normalizing sampler is now an opt-in `normalize_strength` / `normalize_start_sigma` on the Hybrid Euler 2S (CONST/RF path) and Distilled Flow samplers, so you can keep their features (AB2, Heun, velocity bias, rescue) *and* get the latent-health correction. Default off (no behaviour change); audio is never touched. Exposed on both nodes and in their Studio panels.
**FunPack Normalizing Sampler** — a new `SAMPLER` (selectable in Studio alongside Hybrid Euler 2S / Distilled Flow / KSampler) built for distilled few-step LTXAV at CFG=1, where the model is already extracted and there's no redundant CFG computation to exploit. It runs a clean deterministic 1st-order euler base (the "ddim look" + clean audio — no AB2/Heun/ancestral) and adds **video-only latent normalization**: each step it tracks the video latent's robust spread, anchors a reference once x0 is meaningful (`normalize_start_sigma`), and gently compresses any later step whose spread inflates past it (`normalize_strength`), countering the overbaking / oversaturation / colour-drift that distilled latents fall into. Benefit-only (never amplifies, no-op when the latent is healthy), ~zero overhead (a few masked reductions), and audio-safe by construction — the audio stream rides plain euler and the normalization never touches it (whole-latent for single-stream LTXV). Velocity bias + reactive rescue are available too, video-only. Node `FunPackNormalizingSampler` + Studio sampler panel.
+97 -1
View File
@@ -141,6 +141,92 @@ def _train_batch(key, manifest, batch_dir, ratings):
return trained, axis_note
def _store_guess_ceiling(key, factor, spread, direction):
"""Write the learned Interactive-Guessing safe ceiling into the refiner state (global) for
`key`, preserving everything else. guess_safe_spread is the absolute cap the refiner clamps
future output conditioning to; guess_safe_factor is for display."""
try:
try:
from .conditioning import refinement_state_path
except ImportError:
from conditioning import refinement_state_path
path = refinement_state_path(key, "clip", prefix="refine_v2")
data = {}
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
data.setdefault("version", 2)
g = data.setdefault("global", {})
g["guess_safe_factor"] = float(factor)
if spread is not None:
g["guess_safe_spread"] = float(spread)
g["guess_direction"] = str(direction)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f)
return True
except Exception as e:
print(f"[FunPack guess] store ceiling failed: {e}")
return False
def _learn_guess_ceiling(key, manifest, batch_dir, ratings):
"""Interactive Guessing learning: from the rated ladder, find the safe steering ceiling — the
furthest-pushed rung still rated acceptable before quality broke — and store it (absolute
spread + factor) so the refiner auto-caps future steering. Reward >= 0 = acceptable."""
try:
from .conditioning import conditioning_spread
except ImportError:
try:
from conditioning import conditioning_spread
except Exception:
conditioning_spread = lambda c: None
rated = []
for item in manifest.get("items", []):
gf = item.get("guess_factor")
if gf is None:
continue
label = ratings.get(str(item.get("id")))
if not label:
continue
prof = _profile_for(label)
if not prof or prof.get("skip_learning"):
continue
rated.append((float(gf), float(prof.get("reward", 0.0)), item.get("cond")))
if not rated:
return "Interactive Guessing: no rated rungs — nothing learned."
up = sum(1 for f, _, _ in rated if f > 1.0) >= sum(1 for f, _, _ in rated if f < 1.0)
broke = [f for f, r, _ in rated if r < 0.0]
ok = [(f, c) for f, r, c in rated if r >= 0.0]
if up:
fail = min(broke) if broke else float("inf")
candidates = [(f, c) for f, c in ok if f < fail]
safe = max(candidates, key=lambda x: x[0]) if candidates else None
else:
fail = max(broke) if broke else float("-inf")
candidates = [(f, c) for f, c in ok if f > fail]
safe = min(candidates, key=lambda x: x[0]) if candidates else None
if safe is None:
# even the base broke — cap at base (1.0), no headroom
_store_guess_ceiling(key, 1.0, None, "up" if up else "down")
return "Interactive Guessing: every rung broke — capped steering to base (×1.00)."
safe_factor, safe_cond_file = safe
safe_spread = None
if safe_cond_file:
cond_path = os.path.join(batch_dir, safe_cond_file)
if os.path.exists(cond_path):
try:
with torch.inference_mode(False):
safe_spread = conditioning_spread(torch.load(cond_path, map_location="cpu"))
except Exception:
safe_spread = None
_store_guess_ceiling(key, safe_factor, safe_spread, "up" if up else "down")
return (f"Interactive Guessing: safe up to ×{safe_factor:.2f}"
+ (f" (spread {safe_spread:.3f}) — future steering auto-capped." if safe_spread
else " — future steering auto-capped."))
if PromptServer is not None and web is not None:
@PromptServer.instance.routes.get("/funpack/batch/list")
@@ -164,6 +250,7 @@ if PromptServer is not None and web is not None:
"seed": it.get("seed"),
"preview": it.get("preview"),
"variant": it.get("variant"),
"guess_factor": it.get("guess_factor"),
"prompt": it.get("prompt"),
"rating": it.get("rating"),
} for it in manifest.get("items", [])]
@@ -187,13 +274,22 @@ if PromptServer is not None and web is not None:
key = str(body.get("key", "")).strip()
stamp = str(body.get("stamp", "")).strip()
ratings = body.get("ratings", {}) or {}
learn = bool(body.get("learn", True))
if not key or not stamp:
return web.json_response({"error": "key and stamp required"}, status=400)
batch_dir = os.path.join(_key_dir(key), stamp)
if not os.path.exists(os.path.join(batch_dir, "batch.json")):
return web.json_response({"error": "batch not found"}, status=404)
manifest = _load_manifest(batch_dir)
trained, note = _train_batch(key, manifest, batch_dir, {str(k): v for k, v in ratings.items()})
ratings = {str(k): v for k, v in ratings.items()}
is_guess = any(it.get("guess_factor") is not None for it in manifest.get("items", []))
if not learn:
trained, note = 0, "learning off — generated only, nothing learned."
elif is_guess:
note = _learn_guess_ceiling(key, manifest, batch_dir, ratings)
trained = 1 if "safe" in (note or "") else 0
else:
trained, note = _train_batch(key, manifest, batch_dir, ratings)
# Submit clears the batch regardless (rated material is consumed).
try:
shutil.rmtree(batch_dir, ignore_errors=True)
+31 -4
View File
@@ -11517,6 +11517,17 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
global_state.setdefault("phrase_memory", {})
global_state.setdefault("axis_conditioning_memory", {})
global_state.setdefault("negative_prompt_memory", {})
# Interactive-Guessing dual auto-cap (learned safe ceiling). Applied on NORMAL runs only —
# never during the guessing ladder itself, which deliberately sweeps the spread.
_guess_spread_cap = None
if not guess_mode:
_gsf = float(global_state.get("guess_safe_factor", 0) or 0)
if _gsf > 0:
absolute_strength = min(float(absolute_strength), _gsf) # (a) soft strength pre-limit
_gss = float(global_state.get("guess_safe_spread", 0) or 0)
if _gss > 0:
_guess_spread_cap = _gss # (b) output-spread clamp (the guarantee)
global_state.setdefault("lora_weight_memory", {})
global_state.setdefault("preferred_context_memory", {})
global_state.setdefault("intent_alignment_memory", {})
@@ -12323,7 +12334,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
_concept_strength, _current_final,
guess_mode=guess_mode, guess_direction=guess_direction, guess_range=guess_range)
return (
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength),
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap),
status,
training_info,
loss_graph,
@@ -12341,7 +12352,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
repair_feedback, current_family_slot, _vf_for_memory, _concept_dir,
_concept_strength, _current_final)
return (
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength),
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength, spread_cap=_guess_spread_cap),
status + enhancement_status,
training_info,
loss_graph,
@@ -12701,15 +12712,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return out
def _v2_finalize_conditioning(self, conditioning_list, refinement_key, value_guidance,
steer_mode, absolute_strength):
steer_mode, absolute_strength, spread_cap=None):
"""Single output hook for both steering modes. Relative = per-key VF ascend (current
behaviour). Absolute = global taste pull. Both = layer them."""
behaviour). Absolute = global taste pull. Both = layer them. Finally, if Interactive
Guessing has learned a safe-spread ceiling, clamp the output conditioning's video-channel
spread to it (the backstop half of the dual auto-cap)."""
mode = str(steer_mode or "relative").lower()
out = conditioning_list
if mode in ("relative", "both"):
out = self._v2_ascend_conditioning(out, refinement_key, apply=value_guidance)
if mode in ("absolute", "both"):
out = self._v2_apply_absolute(out, float(absolute_strength))
if spread_cap and spread_cap > 0:
capped = []
clamped_any = False
for entry in out:
if isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[0], torch.Tensor):
sp = conditioning_spread(entry[0])
if sp and sp > spread_cap:
entry = (scale_conditioning_spread(entry[0], spread_cap / sp), entry[1])
clamped_any = True
capped.append(entry)
out = capped
if clamped_any:
print(f"[FunPackRefiner] Interactive-Guessing cap: output conditioning spread "
f"clamped to learned-safe {spread_cap:.3f}.")
return out
+48 -7
View File
@@ -559,23 +559,62 @@ function openBatchPanel(node) {
function renderConfig() {
const settings = getSettings(node);
const n = parseInt(settings?.refiner?.batch_variants || 1, 10) || 1;
const rf = settings.refiner = settings.refiner || {};
const n = parseInt(rf.batch_variants || 1, 10) || 1;
body.append(el("div", "funpack-studio-hint",
"A batch is a set of generations made in one run with everything frozen except the seed (and shortcut options, if your prompt has any). Set the size and Arm it, then press ComfyUI's Generate. When it finishes, reopen this window to rate each one."));
"A batch is a set of generations made in one run. Regular = everything frozen except the seed (and shortcut options). Interactive Guessing = freeze EVERYTHING including the seed and sweep the conditioning strength up or down across the batch, so you can see exactly where it breaks. Set it up, Arm, then press ComfyUI's Generate. All videos come out the Chain Sampler's IMAGES output; reopen this window to rate them."));
const sizeInput = numInput(n > 1 ? n : 10, 2, 64, 1);
body.append(row("Batch size", sizeInput));
body.append(el("div", "funpack-studio-hint", n > 1 ? `Armed: the next Generate produces ${n}.` : "Not armed — Generate runs a single normal generation."));
if (!rf.batch_mode) rf.batch_mode = "Regular";
const modeSelect = selectEl(["Regular", "Interactive Guessing"], rf.batch_mode);
modeSelect.addEventListener("change", () => { rf.batch_mode = modeSelect.value; saveSettings(node, settings); render(); });
body.append(row("Mode", modeSelect));
let dirSelect = null, rangeInput = null;
if (rf.batch_mode === "Interactive Guessing") {
if (!rf.guess_direction) rf.guess_direction = "up";
dirSelect = selectEl(["up", "down"], rf.guess_direction);
body.append(el("div", "funpack-studio-hint", "up = amplify the conditioning's spread toward overbake (find your safe ceiling). down = dampen it (find how flat is too flat)."));
body.append(row("Direction", dirSelect));
if (rf.guess_range === undefined) rf.guess_range = 1.0;
rangeInput = numInput(rf.guess_range, 0.1, 2.0, 0.1);
body.append(el("div", "funpack-studio-hint", "How far the last rung goes: up → 1.0+range (e.g. 1.0 = up to 2.0×). down → 1.0−range (capped above 0). The ladder ramps linearly from the untouched base to this."));
body.append(row("Range", rangeInput));
}
if (rf.batch_learning === undefined) rf.batch_learning = true;
const learnToggle = toggleEl(!!rf.batch_learning, "Learn from ratings on Submit");
learnToggle.inp.addEventListener("change", () => { rf.batch_learning = learnToggle.inp.checked; saveSettings(node, settings); });
body.append(el("div", "funpack-studio-hint", rf.batch_mode === "Interactive Guessing"
? "On: rating the ladder learns your safe-spread ceiling and auto-caps future steering for this key. Off: pure generation, nothing learned."
: "On: rating trains the value function + repair memory. Off: pure generation — make variations without teaching anything."));
body.append(row("Learning", learnToggle.wrap));
body.append(el("div", "funpack-studio-hint", n > 1
? `Armed (${rf.batch_mode}): the next Generate produces ${n}.`
: "Not armed — Generate runs a single normal generation."));
const actions = el("div", "");
actions.style.cssText = "display:flex;gap:6px;margin-top:8px;";
const armBtn = btn(n > 1 ? "Re-arm" : "Arm batch");
armBtn.addEventListener("click", () => {
const v = Math.max(2, Math.min(64, parseInt(sizeInput.value, 10) || 0));
const s = getSettings(node); s.refiner = s.refiner || {}; s.refiner.batch_variants = v; saveSettings(node, s);
const s = getSettings(node); s.refiner = s.refiner || {};
s.refiner.batch_variants = v;
s.refiner.batch_mode = modeSelect.value;
s.refiner.guess_mode = (modeSelect.value === "Interactive Guessing");
if (dirSelect) s.refiner.guess_direction = dirSelect.value;
if (rangeInput) s.refiner.guess_range = Math.max(0.1, Math.min(2.0, parseFloat(rangeInput.value) || 1.0));
s.refiner.batch_learning = learnToggle.inp.checked;
saveSettings(node, s);
refreshBatchButton(node); render();
});
const offBtn = btn("Disarm", "secondary");
offBtn.addEventListener("click", () => {
const s = getSettings(node); s.refiner = s.refiner || {}; s.refiner.batch_variants = 1; saveSettings(node, s);
const s = getSettings(node); s.refiner = s.refiner || {};
s.refiner.batch_variants = 1; s.refiner.guess_mode = false;
saveSettings(node, s);
refreshBatchButton(node); render();
});
actions.append(armBtn, offBtn);
@@ -590,7 +629,7 @@ function openBatchPanel(node) {
data.items.forEach((it) => {
const r = el("div", "");
r.style.cssText = "display:flex;align-items:center;gap:6px;margin:2px 0;";
const vtag = (it.variant != null) ? `[v${it.variant}] ` : "";
const vtag = (it.guess_factor != null) ? `[×${it.guess_factor}] ` : ((it.variant != null) ? `[v${it.variant}] ` : "");
const lab = it.prompt ? it.prompt : (it.preview || it.id);
const name = el("span", "", `#${it.index ?? 0} ${vtag}${lab} (seed ${it.seed})`);
name.title = it.preview || it.id;
@@ -616,7 +655,8 @@ function openBatchPanel(node) {
if (Object.keys(ratings).length === 0) { errorEl.textContent = "Rate at least one generation first."; return; }
submitBtn.disabled = true;
try {
const res = await api.fetchApi("/funpack/batch/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, stamp: data.stamp, ratings }) });
const learn = (getSettings(node)?.refiner?.batch_learning !== false);
const res = await api.fetchApi("/funpack/batch/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, stamp: data.stamp, ratings, learn }) });
const out = await res.json();
if (out.error) throw new Error(out.error);
errorEl.textContent = `Submitted — trained on ${out.trained}.${out.info ? " " + out.info : ""}`;
@@ -2127,6 +2167,7 @@ app.registerExtension({
const s = getSettings(n); // one-shot: disarm after the batch ran
if (s?.refiner && parseInt(s.refiner.batch_variants || 1, 10) > 1) {
s.refiner.batch_variants = 1;
s.refiner.guess_mode = false;
saveSettings(n, s);
}
}