Refiner V2 + Studio: add Absolute / Relative steer mode

Relative (default, unchanged) = per-prompt steering. Absolute = a keyless,
prompt-agnostic "global taste" prior that pulls conditioning toward generally-
liked directions regardless of the prompt; Both layers them.

- New keyless Absolute store (FUNPACK_ABSOLUTE_KEY): pooled liked/disliked
  direction + global value function, learned across every rated generation.
- Absolute rating semantics: a rating means "details I like in general" (not
  prompt adherence) — reward-driven liked/bad-dir storage, axis attribution
  bypassed. Perfect = "more of this everywhere".
- _v2_finalize_conditioning replaces the per-key ascend hook on both returns;
  pure Absolute also bypasses per-prompt conditioning memory.
- Scene Chain sampler: embed_guidance_source (relative/absolute) lets the
  per-step nudge draw from the global direction/value function.
- Studio popup + V2 node expose Steer mode + Absolute strength.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-04 10:06:56 +03:00
co-authored by Claude Opus 4.8
parent ea602f9de0
commit c823d5bf3e
4 changed files with 262 additions and 32 deletions
+2
View File
@@ -4,6 +4,8 @@
### Added
Added an **Absolute / Relative steer mode** to Refiner V2 and Studio. *Relative* (the default, unchanged behaviour) is per-prompt: it learns and applies the best conditioning for one specific prompt. *Absolute* is prompt-agnostic — it accumulates a single global "taste" prior across **every** rated generation and pulls conditioning toward it regardless of the prompt. In Absolute mode a rating means "this has (or lacks) details I like **in general**", not prompt adherence: a Perfect means "I love this, give me more of it everywhere", and a low rating means "this is missing the details I like in general". The pull layers two engines — a pooled liked/disliked direction (the learned, automated analogue of the manual Conditioning Adjust phrase shift) and the keyless value function on top — and is applied at the conditioning output (`_v2_finalize_conditioning`). *Both* keeps the per-prompt fit and layers the global prior under it; pure *Absolute* bypasses per-prompt memory entirely. The global store learns from rated runs even with no refinement key wired (it is keyless by design). The Scene Chain sampler's `embed_guidance` gains an `embed_guidance_source` (`relative` / `absolute`) so the per-step nudge can draw from the global taste direction too. Studio exposes `Steer mode` + `Absolute strength` in the refiner panel.
Ported **velocity bias + reactive rescue** to the `FunPack Distilled Flow` sampler (`sample_funpack_distilled_flow`), reusing the same capture/memory/rescue machinery as the Hybrid Euler-2S and LTXAV/RF samplers. The sampler now exposes `velocity_bias_mode`, `velocity_bias_strength`, `velocity_bias_source`, `velocity_refinement_key`, `rescue_mode`, `rescue_threshold`, and `rescue_strength`, and these are wired into Studio's **Distilled Flow** sampler config panel (with the same blank/`default` velocity-key → wired-refinement-key fallback the Hybrid panel uses). Steering is applied magnitude-preservingly and, on packed LTXAV latents, confined to the video stream (audio-safe). Note: few-step distilled schedules may only land on one or two velocity targets, so apply/rescue fire less often than on an 8-step run; they no-op cleanly when no target matches. With everything off the sampler is byte-identical to the previous deterministic ODE.
## [2.7.7] - 2026-06-03
+210 -21
View File
@@ -360,6 +360,12 @@ def update_refinement_sampler_context(refinement_key, sampler_context):
print(f"[FunPack] update_refinement_sampler_context: failed to save context: {e}")
# Fixed, prompt-agnostic key for the Absolute steering store. Unlike every other refinement
# key (which hashes a specific prompt), this one collects liked/disliked directions across
# ALL prompts into a single "global taste" file. Relative mode = per-prompt; Absolute = this.
FUNPACK_ABSOLUTE_KEY = "__funpack_absolute_global__"
def refinement_state_path(refinement_key, mode, prefix="refine", extension="json"):
base_dir = os.path.dirname(os.path.abspath(__file__))
refinements_dir = os.path.join(base_dir, "refinements")
@@ -5950,6 +5956,16 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
"latent": ("LATENT", {
"tooltip": "Optional video latent for creativity masking. Takes priority over any saved latent for this key. Connect your i2v or previous KSampler output here.",
}),
"steer_mode": (["relative", "absolute", "both"], {
"default": "relative",
"label": "Steer Mode",
"tooltip": "Relative: per-prompt steering (finds the best conditioning for THIS prompt — default). Absolute: pull conditioning toward the global learned taste, regardless of prompt. Both: layer them.",
}),
"absolute_strength": ("FLOAT", {
"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05,
"label": "Absolute Strength",
"tooltip": "How hard Absolute mode pulls toward the global taste direction. 0.6 is visible but non-destructive; raise for stronger override of the prompt.",
}),
},
}
@@ -11371,6 +11387,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
advisor_clip=None, feedback_prompt="", prompt_repair=True, temporal_style="natural",
split_by_transitions=False, split_transition_placement="start", reference_injection=False,
value_guidance=True, latent=None, seed_output_connected=False,
steer_mode="relative", absolute_strength=0.6,
_seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None,
batch_variants=1):
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
@@ -11490,25 +11507,17 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# value_guidance only controls APPLICATION (ascent below) — a user who runs with it
# off still builds the VF, so enabling guidance later works immediately.
if has_previous_run and refinement_key and not learning_profile.get("skip_learning"):
try:
import torch as _torch
try:
from .value_function import OnlineValueFunction
except ImportError:
from value_function import OnlineValueFunction
payload = (previous_run or {}).get("conditioning")
if isinstance(payload, dict):
with _torch.inference_mode(False), _torch.enable_grad():
cond_tensor = serializable_to_tensor(payload).clone()
reward = float(learning_profile.get("reward", 0.0))
vf_path = refinement_state_path(refinement_key, "value_fn", prefix="refine_v2", extension="pt")
vf = OnlineValueFunction.load_or_create(vf_path, hidden_dim=cond_tensor.shape[-1])
if vf is not None:
vf.train_on(cond_tensor, reward)
vf.save(vf_path)
print(f"[FunPackRefiner] Value function updated — {vf.n_trained} samples, buffer={len(vf.buffer_c)}")
except Exception as e:
print(f"[FunPackRefiner] Value function training failed: {e}")
n = self._v2_train_value_function(
refinement_state_path(refinement_key, "value_fn", prefix="refine_v2", extension="pt"),
(previous_run or {}).get("conditioning"),
float(learning_profile.get("reward", 0.0)),
)
if n is not None:
print(f"[FunPackRefiner] Value function updated — {n} samples")
# Absolute store: the same rating also feeds the keyless, prompt-agnostic taste prior.
# Runs even with no refinement_key (Absolute is global), so standalone runs still build it.
if has_previous_run:
self._v2_learn_absolute(previous_run, learning_profile)
if has_previous_run and not learning_profile.get("skip_learning"):
self._v2_update_streaks(global_state, learning_profile, update_conditioning_strength=not prompt_only_mode)
repair_feedback, repair_persistence_status = self._v2_active_repair_feedback(
@@ -11771,6 +11780,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
elif prompt_only_mode:
refined = cond
adaptation_status = "Adaptation: Prompt only mode; conditioning vectors passed through unchanged."
elif str(steer_mode).lower() == "absolute":
# Pure Absolute: skip the per-prompt conditioning memory entirely. Steering happens
# only via the keyless global-taste pull in _v2_finalize_conditioning, so the result
# is moved toward general preferences "despite the prompt". (Both keeps per-prompt
# memory and layers the global pull on top.)
refined = cond
adaptation_status = "Adaptation: Absolute mode; per-prompt memory bypassed, global taste pull applied at output."
else:
_current_final = [t for t in (prompt_to_encode or "").split(",") if len(t.strip()) > 1]
_prev_final = previous_run.get("final_phrase_texts", []) if isinstance(previous_run, dict) else []
@@ -12214,7 +12230,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
repair_feedback, current_family_slot, _vf_for_memory, _concept_dir,
_concept_strength, _current_final)
return (
self._v2_ascend_conditioning(output_conditioning, refinement_key, apply=value_guidance),
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength),
status,
training_info,
loss_graph,
@@ -12232,7 +12248,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
repair_feedback, current_family_slot, _vf_for_memory, _concept_dir,
_concept_strength, _current_final)
return (
self._v2_ascend_conditioning(output_conditioning, refinement_key, apply=value_guidance),
self._v2_finalize_conditioning(output_conditioning, refinement_key, value_guidance, steer_mode, absolute_strength),
status + enhancement_status,
training_info,
loss_graph,
@@ -12379,6 +12395,177 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
print(f"[FunPackRefiner] Conditioning ascent failed: {e}")
return conditioning_list
# ====================== ABSOLUTE STEERING (prompt-agnostic) ======================
# Relative steering (everything above) keys every learned direction to one prompt's
# state file. Absolute steering accumulates the SAME pooled, channel-space directions
# into a single keyless file so they describe a global "taste" that applies to any
# prompt. It is the learned, automated analogue of FunPackConditioningAdjust: instead
# of a user typing a phrase + strength, the direction is the running average of what
# was rated liked (vs. the global source mean), applied as a broadcast shift.
def _v2_absolute_state_path(self):
return refinement_state_path(FUNPACK_ABSOLUTE_KEY, "clip", prefix=self.V2_STATE_PREFIX)
def _v2_absolute_vf_path(self):
return refinement_state_path(FUNPACK_ABSOLUTE_KEY, "value_fn", prefix=self.V2_STATE_PREFIX, extension="pt")
def _v2_load_absolute_global(self):
"""Load the keyless Absolute store and return (full_state, global_dict)."""
path = self._v2_absolute_state_path()
state = None
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
state = json.load(f)
except (json.JSONDecodeError, OSError, ValueError):
state = None
if not isinstance(state, dict):
state = {"version": 2, "global": {}}
global_state = state.setdefault("global", {})
global_state.setdefault("axis_conditioning_memory", {})
return state, global_state
def _v2_save_absolute_global(self, state):
path = self._v2_absolute_state_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(state, f)
except OSError as e:
print(f"[FunPackRefiner] Absolute store save failed: {e}")
def _v2_train_value_function(self, vf_path, payload, reward):
"""Add one (conditioning, reward) sample to the value function at vf_path. Shared by
the per-key (relative) and keyless (absolute) reward assets. Returns n_trained or None."""
if not isinstance(payload, dict):
return None
try:
import torch as _torch
try:
from .value_function import OnlineValueFunction
except ImportError:
from value_function import OnlineValueFunction
with _torch.inference_mode(False), _torch.enable_grad():
cond_tensor = serializable_to_tensor(payload).clone()
vf = OnlineValueFunction.load_or_create(vf_path, hidden_dim=cond_tensor.shape[-1])
if vf is not None:
vf.train_on(cond_tensor, float(reward))
vf.save(vf_path)
return vf.n_trained
except Exception as e:
print(f"[FunPackRefiner] Value function training failed: {e}")
return None
def _v2_learn_absolute(self, previous_run, learning_profile):
"""Fold the just-rated generation into the prompt-agnostic Absolute store.
Rating semantics differ from Relative here. Relative asks "does this match the
prompt?" and attributes credit per missing/satisfied axis. Absolute asks "do I like
these details IN GENERAL, regardless of the prompt?" — so we deliberately IGNORE the
prompt-specific axis attribution and steer purely on the overall reward:
* a high rating ("Perfect" = I love this and want more of this in general) pushes
the global liked direction toward this generation;
* a low rating ("missing details I like in general") pushes the disliked direction.
Pooled across every prompt, this converges on a single global-taste prior. Also trains
the keyless value function. Runs independent of refinement_key — Absolute is keyless."""
if not isinstance(previous_run, dict) or learning_profile.get("skip_learning"):
return
payload = previous_run.get("conditioning")
if not isinstance(payload, dict):
return
reward = float(learning_profile.get("reward", 0.0))
try:
state, global_state = self._v2_load_absolute_global()
self._v2_update_session_mean(global_state, previous_run.get("source_conditioning"))
session_mean = global_state.get("session_source_mean")
if reward >= 0.3:
# "more of this, generally"
self._v2_store_direction(global_state.setdefault("liked_dir", {}), payload, session_mean)
elif reward <= 0.0:
# "this lacks the details I like, generally"
self._v2_store_direction(global_state.setdefault("bad_dir", {}), payload, session_mean)
# the mild-positive band (0, 0.3) is too ambiguous to move either pole
global_state["total_iterations"] = int(global_state.get("total_iterations", 0)) + 1
self._v2_save_absolute_global(state)
except Exception as e:
print(f"[FunPackRefiner] Absolute learning failed: {e}")
n = self._v2_train_value_function(self._v2_absolute_vf_path(), payload, reward)
if n is not None:
print(f"[FunPackRefiner] Absolute value function updated — {n} samples")
def _v2_apply_absolute(self, conditioning_list, strength, value_blend=True):
"""Pull every conditioning entry toward the global learned taste direction, regardless
of prompt. Two layered engines: (1) the pooled liked direction (minus a global bad-dir
repel), applied as a calibrated broadcast shift via _v2_apply_direction; (2) the keyless
value-function ascent on top for the nonlinear component. No-op until each engine has
enough data (liked/bad need direction_count>=3; VF needs MIN_SAMPLES)."""
if not conditioning_list or strength <= 0:
return conditioning_list
try:
_, global_state = self._v2_load_absolute_global()
except Exception:
return conditioning_list
liked = global_state.get("liked_dir", {})
bad = global_state.get("bad_dir", {})
liked_ready = int(liked.get("direction_count", 0)) >= 3
bad_ready = int(bad.get("direction_count", 0)) >= 3
gvf = None
if value_blend:
try:
import torch as _torch
try:
from .value_function import OnlineValueFunction
except ImportError:
from value_function import OnlineValueFunction
vf_path = self._v2_absolute_vf_path()
if os.path.exists(vf_path):
with _torch.inference_mode(False):
candidate = OnlineValueFunction.load(vf_path)
gvf = candidate if candidate.is_ready() else None
except Exception:
gvf = None
if not (liked_ready or bad_ready or gvf is not None):
return conditioning_list
out = []
applied = False
for entry in conditioning_list:
if not (isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[0], torch.Tensor)):
out.append(entry)
continue
mixed, extra = entry[0], entry[1]
if liked_ready:
mixed = self._v2_apply_direction(mixed, liked, strength)
if bad_ready:
mixed = self._v2_apply_direction(mixed, bad, strength * 0.5, negate=True)
if gvf is not None:
try:
mixed = gvf.ascend(mixed)
except Exception:
pass
out.append((mixed, extra))
applied = True
if applied:
print(
f"[FunPackRefiner] Absolute steer applied (strength={strength}, "
f"liked={liked_ready}, bad={bad_ready}, value_fn={'on' if gvf is not None else 'off'})"
)
return out
def _v2_finalize_conditioning(self, conditioning_list, refinement_key, value_guidance,
steer_mode, absolute_strength):
"""Single output hook for both steering modes. Relative = per-key VF ascend (current
behaviour). Absolute = global taste pull. Both = layer them."""
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))
return out
class FunPackSaveRefinementLatent:
CATEGORY = "FunPack/Refinement"
@@ -13576,6 +13763,8 @@ class FunPackStudio:
split_transition_placement=split_transition_placement,
reference_injection=reference_injection,
value_guidance=value_guidance,
steer_mode=str(rf.get("steer_mode", "relative") or "relative"),
absolute_strength=float(rf.get("absolute_strength", 0.6) or 0.6),
latent=latent,
seed_output_connected=seed_output_connected,
_seed=seed,
+29 -10
View File
@@ -1694,6 +1694,10 @@ class FunPackLTXAVSceneChainSampler:
"default": 0.02, "min": 0.005, "max": 0.1, "step": 0.005,
"tooltip": "Per-step nudge strength toward the liked conditioning direction. Keep small — the direction is applied at every step so it compounds. 0.01-0.03 is typical.",
}),
"embed_guidance_source": (["relative", "absolute"], {
"default": "relative",
"tooltip": "Which learned direction embed_guidance steers toward. Relative: this prompt's liked direction (needs refinement_key_input). Absolute: the global, prompt-agnostic taste direction the Refiner accumulates across all prompts — works with no key.",
}),
"transition_duration": ("INT", {
"default": 16, "min": 0, "max": 128, "step": 2,
"tooltip": "Extra pixel frames of fade beyond the blend zone on each side of a scene boundary. 0 = disable all transition effects.",
@@ -2163,6 +2167,14 @@ class FunPackLTXAVSceneChainSampler:
except Exception:
return None
def _absolute_key(self):
"""The keyless global-taste key for Absolute embed_guidance (mirrors the Refiner)."""
try:
from .conditioning import FUNPACK_ABSOLUTE_KEY
except ImportError:
from conditioning import FUNPACK_ABSOLUTE_KEY
return FUNPACK_ABSOLUTE_KEY
def _load_value_function(self, refinement_key):
"""Load the online value function if trained and ready."""
try:
@@ -2314,6 +2326,7 @@ class FunPackLTXAVSceneChainSampler:
carry_i2v_guides=False,
mid_scene_guide=False, mid_scene_guide_strength=0.4,
embed_guidance=False, embed_guidance_strength=0.02,
embed_guidance_source="relative",
transition_duration=16, decode_tile_size=0,
refinement_key_input="", unique_id=None, prompt=None):
if not isinstance(positive, list) or not positive:
@@ -2330,7 +2343,7 @@ class FunPackLTXAVSceneChainSampler:
num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed,
carry_i2v_guides, mid_scene_guide, mid_scene_guide_strength,
embed_guidance, embed_guidance_strength, transition_duration,
decode_tile_size, refinement_key_input,
decode_tile_size, refinement_key_input, embed_guidance_source,
)
max_scene_count = max(1, int(max_scenes))
@@ -2346,21 +2359,25 @@ class FunPackLTXAVSceneChainSampler:
boundary_entries = []
cumulative_latent_frames = 0
# Load liked direction once for embed_guidance
# Load liked direction once for embed_guidance. Source selects which learned direction:
# 'relative' = this prompt's key; 'absolute' = the global, prompt-agnostic taste store
# (keyless, so it works even without refinement_key_input).
_liked_dir = None
_value_fn = None
if embed_guidance and refinement_key_input:
_liked_dir = self._load_liked_direction(refinement_key_input)
_eg_source = str(embed_guidance_source or "relative").lower()
_eg_key = self._absolute_key() if _eg_source == "absolute" else refinement_key_input
if embed_guidance and _eg_key:
_liked_dir = self._load_liked_direction(_eg_key)
if _liked_dir is None:
print("[FunPackSceneChain] embed_guidance: no liked direction found (need 3+ liked generations)")
print(f"[FunPackSceneChain] embed_guidance ({_eg_source}): no liked direction found (need 3+ liked generations)")
else:
_value_fn = self._load_value_function(refinement_key_input)
_value_fn = self._load_value_function(_eg_key)
if _value_fn:
ready = _value_fn.is_ready()
mode = f"value function ({_value_fn.n_trained} samples, ascent {'on' if ready else 'pending'})"
else:
mode = "fixed direction"
print(f"[FunPackSceneChain] embed_guidance: active via {mode}, strength={embed_guidance_strength}")
print(f"[FunPackSceneChain] embed_guidance ({_eg_source}): active via {mode}, strength={embed_guidance_strength}")
first_scene_seed = self._scene_seed(scene_conditionings[0])
if first_scene_seed is None:
@@ -2560,7 +2577,7 @@ class FunPackLTXAVSceneChainSampler:
latent_template, num_frames_per_scene, frame_overlap, cfg, max_scenes,
use_same_seed, carry_i2v_guides, mid_scene_guide, mid_scene_guide_strength,
embed_guidance, embed_guidance_strength, transition_duration,
decode_tile_size, refinement_key_input):
decode_tile_size, refinement_key_input, embed_guidance_source="relative"):
"""Sample one chain per Studio-packed variant entry (seed + index), persisting each result
(latent + preview + per-entry cond + manifest) under ComfyUI temp for rating in Studio.
Reuses sample() per entry with only the seed changed, so each entry is a clean generation."""
@@ -2573,7 +2590,8 @@ class FunPackLTXAVSceneChainSampler:
num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed=use_same_seed,
carry_i2v_guides=carry_i2v_guides, mid_scene_guide=mid_scene_guide,
mid_scene_guide_strength=mid_scene_guide_strength, embed_guidance=embed_guidance,
embed_guidance_strength=embed_guidance_strength, transition_duration=transition_duration,
embed_guidance_strength=embed_guidance_strength, embed_guidance_source=embed_guidance_source,
transition_duration=transition_duration,
decode_tile_size=decode_tile_size, refinement_key_input="",
)
stamp = _time.strftime("%Y-%m-%d_%H-%M-%S")
@@ -2597,7 +2615,8 @@ class FunPackLTXAVSceneChainSampler:
num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed=use_same_seed,
carry_i2v_guides=carry_i2v_guides, mid_scene_guide=mid_scene_guide,
mid_scene_guide_strength=mid_scene_guide_strength, embed_guidance=embed_guidance,
embed_guidance_strength=embed_guidance_strength, transition_duration=transition_duration,
embed_guidance_strength=embed_guidance_strength, embed_guidance_source=embed_guidance_source,
transition_duration=transition_duration,
decode_tile_size=decode_tile_size, refinement_key_input=key,
unique_id=None, prompt=None,
)
+21 -1
View File
@@ -69,7 +69,7 @@ function defaultSettings() {
refinement_key: "",
overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false },
scene_builder: { mode: "Pass-through", scene: NONE_SENTINEL, scene_name: "", aliases: "", scene_positive: "", scene_negative: "" },
refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true },
refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6 },
advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" },
loras: [],
loras_config: { mode: "ltx2", per_block: false },
@@ -1204,6 +1204,26 @@ function openPanel(node) {
valueGuidanceToggle.inp.addEventListener("change", () => { settings.refiner.value_guidance = valueGuidanceToggle.inp.checked; });
body.append(el("div", "funpack-studio-hint", "The value function always trains in the background on rated generations (cheap, no extra diffusion). This toggle applies the learned reward direction to conditioning (ascent + search) once 10+ gens are rated — on by default. Turn off for strict prompt fidelity; learning still accumulates either way. The chain sampler's 'embed_guidance' uses the same trained function per-step."));
body.append(row("Value guidance", valueGuidanceToggle.wrap));
if (!settings.refiner.steer_mode) settings.refiner.steer_mode = "relative";
const steerSelect = selectEl(["relative", "absolute", "both"], settings.refiner.steer_mode);
steerSelect.addEventListener("change", () => { settings.refiner.steer_mode = steerSelect.value; });
body.append(el("div", "funpack-studio-hint",
"Relative: per-prompt steering — finds the best conditioning for THIS prompt (the default; learns from prompt-specific ratings). " +
"Absolute: pulls conditioning toward a global learned taste that applies to ANY prompt; in this mode a rating means 'this has/lacks details I like in general' (Perfect = 'I love this, give me more of it everywhere'), independent of the prompt. " +
"Both: layer the global taste prior under the per-prompt fit."));
body.append(row("Steer mode", steerSelect));
if (settings.refiner.absolute_strength === undefined) settings.refiner.absolute_strength = 0.6;
const absStrengthInput = numInput(settings.refiner.absolute_strength, 0.0, 2.0, 0.05);
absStrengthInput.addEventListener("change", () => {
const v = parseFloat(absStrengthInput.value);
settings.refiner.absolute_strength = Number.isFinite(v) ? Math.max(0, Math.min(2, v)) : 0.6;
});
body.append(el("div", "funpack-studio-hint",
"How hard Absolute/Both pull toward the global taste direction. 0.6 is visible but non-destructive; raise to override the prompt more strongly."));
body.append(row("Absolute strength", absStrengthInput));
const vfActiveKey = () => settings.refinement_key || linkedRefinementKey(node);
const vfExportBtn = btn("Export", "secondary");
const vfImportBtn = btn("Import", "secondary");