Files
olivv-cs-ComfyUI-FunPack/trajectory_probe.py
digital-garbage 7d2a33f92b perf: vectorize separation_statistic/permutation_test's O(n^2) inner loop
Measured, not assumed: 9 blocks x 2000 trials at n=30 took 8.16s
before this (pure-Python nested loops, GIL-held throughout -- moving
it off the event loop via asyncio.to_thread does NOT mean it stops
costing real wall-clock time, it just stops blocking OTHER requests
while it costs that time). The O(n^2) between/within split is now a
single tensor broadcast per call, and the biggest remaining cost
(permutation_test recomputing the full _unit()+matmul on every one of
2000 trials, when only the shuffled labels change) is hoisted out of
the loop. Same 9x2000 sweep: 0.76s, 10.7x faster.

All 48 existing trajectory_probe tests pass unchanged -- same exact
p-values, same edge cases -- confirming this is a pure speed fix, not
a behavior change. Benefits both trajectory_probe's own "Read" button
and h3_repr_steering's block_sweep, which share this code.
2026-09-03 20:53:29 +03:00

748 lines
32 KiB
Python

"""Per-step trajectory probe: does a run's fate show up BEFORE the halfway point?
Every rating-driven mechanism in the sampler (embed guidance, score slider, DynaShift,
output guidance) shares one gate — `samplers._make_steer_ramp` — which opens only over the
last half of the schedule. Motion and layout are decided in the half that gate never
reaches, so a "Missing action" rating currently has no window in which it could act. The
proposed fix is to bank the model's own x0_hat at several schedule positions and learn a
value per position, so steering can reach the early half.
That fix is only worth building if the early half carries a usable signal at all. This
module answers exactly that question and nothing else:
Do runs the user rated well and runs the user rated badly separate, in x0_hat space,
at EARLY schedule positions — by more than label-shuffling alone would produce?
It records, it never steers, and it is off by default. Turn it on in **Settings ▸ Learning
▸ Trajectory probe**, which also reads the answer back; the switch is the environment
variable ``FUNPACK_TRAJECTORY_PROBE``, and the editor sets it in this process, so it takes
effect on the next generation with nothing to restart. `tools/trajectory_separation.py`
prints the same reading for a terminal.
What it records per step is a 512-float pooled descriptor of the predicted output, the same
`compress_packed_latent` compression the output value function is already trained on, so a
positive result feeds straight into the existing `LatentValueFunction` machinery.
BUILT FOR H3, which sets three of the design choices here:
- **Few steps.** H3 runs at 4 (turbo), 7 (bong_tangent + euler_a) or 12. Buckets are
clamped to the step count, so a 4-step run measures 4 buckets of one step each rather
than reporting empty ones. On H3's default 12-step shift-12 schedule, buckets 0 and 1 of
4 are precisely the window `_make_steer_ramp` leaves unreachable.
- **CFG is always 1.0**, so comfy passes one row through the model per step and a bucket
mean is a mean over steps. At CFG>1 comfy stacks the conditional and unconditional rows
into ONE call's batch dimension, and `compress_packed_latent` averages over batch — so
the descriptor would be a blend of a prediction and its negation. Rather than guess which
row is which, the observer refuses any prediction with a batch above 1 and counts it, so
such a run reports an incomplete measurement instead of a contaminated one. The same
refusal covers batched generation, where one descriptor cannot describe several videos.
- **The schedule is generated from a shift**, not hand-authored, so bucket 2 of a shift-3
run and bucket 2 of a shift-12 run cover different sigma ranges. Each row records its
step count and both shifts, and the analysis refuses to pool across different schedules
without saying so.
WHY x0_hat AND NOT x_t: at mid sigma, x_t is dominated by that run's own seed noise, so
banking it and pulling a later run toward it partially copies the old seed — the
static-character attractor `value_function` was rewritten to avoid. x0_hat is the model's
guess at the finish: blurry early, but seed-independent in the part that matters.
WHERE THE FILES LIVE: `refinements/trajectory_probe/`, deliberately NOT the `<key>.*`
sidecar convention. Refinement keys are disposable and get cleared between rentals; this
is accumulated measurement data whose whole value is surviving that. Nothing steers off
it, so it cannot cause the orphaned-sidecar drift the sidecar convention exists to
prevent.
"""
import os
import random
import re
import time
import torch
#: Schedule positions to bucket into. 4 splits the run into quarters: the first two are the
#: window no rating-driven mechanism can currently reach.
DEFAULT_BUCKETS = 4
#: Descriptor width. Matches LatentValueFunction.DEFAULT_HIDDEN_DIM so a positive result can
#: train the existing value function without re-recording anything.
DESCRIPTOR_DIM = 512
#: Rows per log file. Large enough for many rentals' worth of ratings, bounded so an
#: always-on probe cannot grow without limit.
MAX_ROWS = 512
_ENABLED_FILE = "enabled"
def _switch_path():
return os.path.join(_probe_dir(), _ENABLED_FILE)
def probe_enabled():
"""True when the probe should record. Off by default; Settings ▸ Learning turns it on.
The environment variable is the live switch (the sampler runs in this process, so the
editor's toggle reaches the next generation immediately), and it wins when set. What it
is set to is also written to disk, so a restarted ComfyUI — a new rental, say — comes
back recording instead of quietly having stopped.
"""
raw = os.environ.get("FUNPACK_TRAJECTORY_PROBE", "").strip().lower()
if raw:
return raw in ("1", "true", "yes", "on")
try:
with open(_switch_path(), "r", encoding="utf-8") as fh:
return fh.read().strip() == "1"
except (OSError, ValueError):
return False
def set_probe_enabled(on):
"""Set the switch for this process AND for the next one. Returns the new state."""
on = bool(on)
os.environ["FUNPACK_TRAJECTORY_PROBE"] = "1" if on else "0"
try:
os.makedirs(_probe_dir(), exist_ok=True)
tmp = _switch_path() + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write("1" if on else "0")
os.replace(tmp, _switch_path())
except OSError as e:
_failed("trajectory probe", "switch save", e,
"recording is on for this session only and will be off after a restart")
return on
def bucket_count():
try:
n = int(os.environ.get("FUNPACK_TRAJECTORY_BUCKETS", "").strip() or DEFAULT_BUCKETS)
except ValueError:
return DEFAULT_BUCKETS
return n if 2 <= n <= 16 else DEFAULT_BUCKETS
def _probe_dir():
base_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_dir, "refinements", "trajectory_probe")
def _safe_key(refinement_key):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(refinement_key or "default").strip())
return (safe or "default")[:80]
def log_path(refinement_key):
return os.path.join(_probe_dir(), f"{_safe_key(refinement_key)}.trajectory.pt")
def pending_path(refinement_key):
return os.path.join(_probe_dir(), f"{_safe_key(refinement_key)}.pending.pt")
def _atomic_save(payload, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
torch.save(payload, tmp)
os.replace(tmp, path) # a concurrent read never sees a partial write
# --- recording ------------------------------------------------------------------
def schedule_positions(sigmas):
"""The schedule's sigma values as a plain descending list, or None.
Bucketing reads POSITION on this list, not the absolute sigma value, for the reason
`_make_steer_ramp` was rewritten: on H3 a large shift keeps sigma high right up to the
final step, so absolute sigma says nothing about how far through the run we are.
"""
if sigmas is None:
return None
try:
vals = [float(v) for v in sigmas.flatten().tolist()]
except Exception:
return None
return vals if len(vals) >= 2 else None
class TrajectoryRecorder:
"""Accumulates pooled x0_hat descriptors per (scene, schedule bucket) for one run.
One recorder spans the whole run; the sampler installs a fresh observer wrapper per
scene and tells the recorder which scene it is, so a multi-scene chain does not average
scene 1's opening across scene 3's ending.
"""
def __init__(self, sigmas, n_buckets=None, dim=DESCRIPTOR_DIM):
self.requested_buckets = int(n_buckets or bucket_count())
self.dim = int(dim)
self.scene = 0
self.pass_index = 0
# (scene, pass, bucket) -> {"sum": [dim], "steps": int, "sigma_hi", "sigma_lo"}
self.cells = {}
self._seen = set() # (scene, pass, sigma) already recorded — see observe()
self.pass_steps = {} # pass index -> step count of the schedule it ran
self._foreign = 0 # steps refused because they belong to no bound schedule
self.begin_pass(sigmas, index=0)
def begin_pass(self, sigmas, index=None):
"""Bind the schedule the next steps will be sampled on.
A scene can run the sampler more than once — a second pass runs its own schedule IN
FULL, from its own starting sigma — and the observer wrapper stays installed across
both. Sigmas from a second schedule have no defined position on the first, so each
pass is bound and bucketed separately rather than being nearest-matched into the
previous pass's buckets, which would silently average two different denoisings into
one measurement.
"""
self.values = schedule_positions(sigmas)
self.steps = max(0, len(self.values) - 1) if self.values else 0
# H3 runs at 4-12 steps. More buckets than steps would report empty ones as if they
# had been measured, so the request is a ceiling, not a promise.
self.n_buckets = max(1, min(self.requested_buckets, self.steps or 1))
self.pass_index = self.pass_index + 1 if index is None else int(index)
self.pass_steps[self.pass_index] = self.steps
def begin_scene(self, scene_index):
self.scene = int(scene_index)
#: A sigma has to BE one of the bound schedule's values, not merely the closest one to
#: it. Without that, a step from an unbound schedule lands in whichever bucket it is
#: nearest and is counted as if it had been measured there. The tolerance covers the
#: float32 round-trip through the model call, nothing wider.
SIGMA_TOLERANCE = 1e-4
def bucket_for(self, sigma):
"""Which quarter (or n-th) of the BOUND schedule this sigma sits in, or None.
None means "this step is not on the schedule I was told about" — a step from a pass
nobody bound, which must be refused rather than nearest-matched. The final entry of
a sigma list is the terminal 0 and is not a step, so positions are taken over the n
steps, not the n+1 boundaries.
"""
vals = self.values
if not vals:
return None
n = len(vals) - 1
if n < 1:
return None
try:
sigma = float(sigma)
except (TypeError, ValueError):
return None
k = min(range(n), key=lambda i: abs(vals[i] - sigma))
if abs(vals[k] - sigma) > self.SIGMA_TOLERANCE:
return None
return min(self.n_buckets - 1, (k * self.n_buckets) // n)
def observe(self, sigma, descriptor):
"""Fold one step's descriptor into its bucket. Cheap by construction: the caller
has already pooled the latent down to `dim` floats.
Only the FIRST call at a given sigma is kept, so a sampler that evaluates a sigma
more than once contributes it once. (This is not what guards against CFG — comfy
stacks the unconditional row into the same call's batch instead of calling again;
see note_unmeasurable and the module docstring.)
"""
if descriptor is None:
return False
bucket = self.bucket_for(sigma)
if bucket is None:
# Either no schedule is bound, or this step belongs to one that was never bound.
# Counted so the run can say the measurement is incomplete instead of quietly
# logging a partial one.
self._foreign += 1
return False
mark = (self.scene, self.pass_index, round(float(sigma), 6))
if mark in self._seen:
return False
self._seen.add(mark)
d = descriptor.detach().float().reshape(-1).cpu()
if d.numel() != self.dim:
return False
cell = self.cells.get((self.scene, self.pass_index, bucket))
if cell is None:
self.cells[(self.scene, self.pass_index, bucket)] = {
"sum": d, "steps": 1, "sigma_hi": float(sigma), "sigma_lo": float(sigma),
}
return True
cell["sum"] = cell["sum"] + d
cell["steps"] += 1
cell["sigma_hi"] = max(cell["sigma_hi"], float(sigma))
cell["sigma_lo"] = min(cell["sigma_lo"], float(sigma))
return True
def note_unmeasurable(self):
"""Count a step that was reached but could not be measured honestly. Kept in the
same tally as steps from an unbound schedule: both mean the saved run was measured
from fewer steps than it actually sampled, which the analysis has to be able to say.
"""
self._foreign += 1
return False
def cell_rows(self):
"""Per-cell means, ordered by (scene, pass, bucket). fp16 — these are descriptors
for a distance test, not values anything is reconstructed from."""
rows = []
for (scene, pass_index, bucket) in sorted(self.cells.keys()):
cell = self.cells[(scene, pass_index, bucket)]
mean = (cell["sum"] / max(1, cell["steps"])).to(torch.float16)
rows.append({
"scene": scene,
"pass": pass_index,
"bucket": bucket,
"desc": mean,
"steps": cell["steps"],
"sigma_hi": cell["sigma_hi"],
"sigma_lo": cell["sigma_lo"],
})
return rows
def is_empty(self):
return not self.cells
def save_pending(refinement_key, recorder, prompt_hash=None, seed=None, meta=None):
"""Persist this run's per-bucket descriptors as the candidate the NEXT rating judges.
One pending per key, overwritten by EVERY run — the same pairing rule DynaShift's
pending latent follows, so a rating always scores the run that produced it.
A run that measured nothing still overwrites, with no rows. Skipping the write instead
would leave the previous run's pending in place for the next rating to consume, which
logs one run's descriptors under another run's rating: silent, and precisely the kind of
mislabelled row that would make the whole measurement lie.
"""
if not refinement_key or recorder is None:
return False
try:
_atomic_save({
"version": 1,
"rows": recorder.cell_rows(),
"n_buckets": recorder.n_buckets,
# Pass 0's step count: schedule_id compares runs, and a run is comparable to
# another by the schedule its measured pass ran on.
"steps": recorder.pass_steps.get(0, recorder.steps),
"pass_steps": dict(recorder.pass_steps),
"unbound_steps": recorder._foreign,
"prompt_hash": prompt_hash,
"seed": None if seed is None else int(seed),
"meta": dict(meta or {}),
"stamp": time.strftime("%Y-%m-%d_%H-%M-%S"),
}, pending_path(refinement_key))
return True
except Exception as e:
_failed("trajectory probe", "pending save", e, "this run will not enter the measurement")
return False
def _failed(tag, what, error, effect):
try:
try:
from .funpack_log import failed
except ImportError:
from funpack_log import failed
failed(tag, what, error, effect)
except Exception:
print(f"[FunPack {tag}] {what} failed ({error}) — {effect}")
def commit(refinement_key, reward, rating_key=None, axes=None, scene_rewards=None):
"""Pair the pending descriptors with the rating that scores their run and append the
row to the log. Always removes the pending file, so a stale candidate can never be
paired with a later, unrelated rating. A pending with no rows (a run that measured
nothing) is consumed and logs nothing. Returns the log length, or None."""
if not refinement_key:
return None
path = pending_path(refinement_key)
if not os.path.exists(path):
return None
count = None
try:
pending = torch.load(path, map_location="cpu", weights_only=False)
if isinstance(pending, dict) and pending.get("rows"):
pending["reward"] = float(reward)
pending["rating"] = None if rating_key is None else str(rating_key)
pending["axes"] = dict(axes or {})
# Per-scene rewards when the user rated each scene separately. Without them a
# multi-scene run collapses to ONE reward covering every scene's descriptors, so
# a scene rated Perfect inside a run whose worst scene was Awful is filed as a
# bad sample — the two labels the measurement is trying to tell apart, mixed.
if scene_rewards:
pending["scene_rewards"] = {int(k): float(v) for k, v in scene_rewards.items()}
rows = load_log(refinement_key)
rows.append(pending)
rows = rows[-MAX_ROWS:]
_atomic_save({"version": 1, "rows": rows}, log_path(refinement_key))
count = len(rows)
except Exception as e:
_failed("trajectory probe", "log append", e, "this rating is missing from the measurement")
finally:
try:
os.remove(path)
except OSError:
pass
return count
def load_log(refinement_key):
"""Recorded runs (oldest first), or [] when this key has none yet."""
path = log_path(refinement_key)
if not os.path.exists(path):
return []
try:
data = torch.load(path, map_location="cpu", weights_only=False)
rows = data.get("rows") if isinstance(data, dict) else None
return [r for r in (rows or []) if isinstance(r, dict) and r.get("rows")]
except Exception as e:
_failed("trajectory probe", "log load", e, "the measurement reads as empty")
return []
def load_all_logs():
"""Every recorded run across every key, as (key, rows) pairs — the probe is meant to
accumulate across disposable keys, so the analysis pools them by default."""
out = []
directory = _probe_dir()
if not os.path.isdir(directory):
return out
for name in sorted(os.listdir(directory)):
if not name.endswith(".trajectory.pt"):
continue
key = name[: -len(".trajectory.pt")]
out.append((key, load_log(key)))
return out
#: How coarsely a conditioning is rounded before hashing. Raw bytes answer "are these
#: bit-identical", which is not the question: measured on a real session, ONE prompt
#: produced 20 distinct ids across 21 generations, because GPU jitter moves values across
#: rounding boundaries. Two decimals is far coarser than that jitter and far finer than the
#: gap between two different prompts.
HASH_QUANTUM = 100.0
def prompt_hash(conditioning):
"""A stable id for "the same prompt", so the analysis can ask whether a separation is
really about ratings rather than about which prompt was running."""
if not isinstance(conditioning, torch.Tensor):
return None
try:
from hashlib import md5
c = conditioning.detach().float()
while c.dim() > 1:
c = c.mean(dim=0)
# +0.0 so a -0.0 bin, which round() can produce, hashes as the 0.0 it equals.
return md5((torch.round(c * HASH_QUANTUM) + 0.0).cpu().numpy().tobytes()).hexdigest()[:16]
except Exception:
return None
# --- analysis -------------------------------------------------------------------
#
# The question is whether ratings separate descriptors at a bucket. With a handful of runs,
# any statistic will look impressive on noise, so the null distribution is built from the
# data itself: shuffle the rating labels and recompute. If the real labels do not beat their
# own shuffles, the bucket carries nothing and the early-window idea is dead there.
def _unit(vectors):
"""Unit vectors, CENTRED on what the set has in common.
Runs of one prompt share most of their content -- measured on a real session, 86% of
each descriptor was the part every run had, and the run-specific remainder was ~14%.
Comparing the raw vectors therefore measures the shared part, and every distance comes
out near zero and near equal: 21 rated runs gave a mean cosine of 0.97 and no
separation at ANY bucket, including the late ones where steering demonstrably works.
Subtracting the mean leaves only what differs between runs, which is the only place a
rating difference could ever have been.
"""
m = torch.stack([v.detach().float().reshape(-1) for v in vectors])
return torch.nn.functional.normalize(m - m.mean(dim=0, keepdim=True), dim=-1)
def _pairwise_eq(values, n):
"""n hashable values -> [n,n] bool tensor, True where values[i] == values[j]. Canonical
integer-codes the values first so this works for str/bool/None/whatever, then does the
actual n^2 comparison as one tensor broadcast instead of a Python double loop -- the
broadcast releases the GIL for its duration, the loop never did. See permutation_test's
docstring for why this matters: 9 blocks x 2000 trials each doing an O(n^2) pure-Python
loop was measured to hold the GIL for multiple seconds, contending with anything else
running at the time (a generation's own host-side orchestration included) even though it
was moved off the event loop via asyncio.to_thread -- "not blocking" and "not slow" are
different guarantees, and only vectorising the actual hot loop delivers the second one."""
codes, idx = {}, []
for v in values:
idx.append(codes.setdefault(v, len(codes)))
t = torch.tensor(idx)
return t.unsqueeze(0) == t.unsqueeze(1)
def separation_statistic(descriptors, labels, groups=None):
"""Mean between-label cosine distance minus mean within-label cosine distance.
Positive means runs rated differently look more different than runs rated the same —
the thing that has to be true for a per-bucket value function to have anything to learn.
`groups` (e.g. prompt hashes) restricts every pair to within one group, so "these are
different prompts" cannot masquerade as "these are different ratings". Returns None when
no admissible pair exists.
"""
n = len(labels)
if n < 3 or len(descriptors) != n:
return None
units = _unit(descriptors)
dist = 1.0 - (units @ units.T)
upper = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1)
if groups is not None:
upper = upper & _pairwise_eq(groups, n)
same_label = _pairwise_eq(labels, n)
between = dist[upper & ~same_label]
within = dist[upper & same_label]
if between.numel() == 0 or within.numel() == 0:
return None
return float(between.mean() - within.mean())
def permutation_test(descriptors, labels, groups=None, trials=2000, seed=0):
"""Observed separation, its p-value under label shuffling, and the within-label mean
distance (the run-to-run noise floor at this bucket).
Labels are shuffled WITHIN each group when groups are given, so the stratification used
for the statistic is preserved in the null — an unstratified shuffle against a stratified
statistic would answer a different question and flatter the result.
`dist`/`upper` (the descriptor similarity matrix and the group-restricted i<j mask) do not
depend on the labels, only on the descriptors and groups -- computed ONCE here rather than
once per trial via separation_statistic(). At 2000 trials that redundant recompute (a full
_unit() renormalise + matmul every trial) was measured to dominate the vectorised version's
own runtime; only the label-dependent mask needs to change per trial."""
n = len(labels)
if n < 3 or len(descriptors) != n:
return None
units = _unit(descriptors)
dist = 1.0 - (units @ units.T)
upper = torch.triu(torch.ones(n, n, dtype=torch.bool), diagonal=1)
if groups is not None:
upper = upper & _pairwise_eq(groups, n)
def _stat(lab):
same_label = _pairwise_eq(lab, n)
between = dist[upper & ~same_label]
within = dist[upper & same_label]
if between.numel() == 0 or within.numel() == 0:
return None
return float(between.mean() - within.mean())
observed = _stat(labels)
if observed is None:
return None
rng = random.Random(seed)
if groups is None:
pools = {None: list(range(n))}
else:
pools = {}
for i, g in enumerate(groups):
pools.setdefault(g, []).append(i)
hits = 0
for _ in range(trials):
shuffled = list(labels)
for idxs in pools.values():
picked = [labels[i] for i in idxs]
rng.shuffle(picked)
for i, lab in zip(idxs, picked):
shuffled[i] = lab
stat = _stat(shuffled)
if stat is not None and stat >= observed:
hits += 1
same = dist[upper & _pairwise_eq(labels, n)]
return {
"separation": observed,
"p_value": (1 + hits) / (1 + trials),
"noise_floor": float(same.mean()) if same.numel() else None,
"n": len(labels),
}
def collect_bucket(rows, bucket, good_above=0.0, bad_below=0.0, scene=None, pass_index=0):
"""(descriptors, labels, prompt groups) for one bucket across recorded runs.
Runs whose reward sits between the two thresholds are dropped rather than assigned to a
side: "Forget it" carries reward 0.0 and is explicitly not a judgement, and a
near-neutral rating is not evidence for either label.
Only the first sampling pass is read by default. A second pass runs its own schedule
from its own starting sigma, so its bucket 1 is not the same window as pass 1's — they
are separate measurements, not more samples of one.
"""
def _label(value):
try:
value = float(value)
except (TypeError, ValueError):
return None
if value > good_above:
return 1
if value < bad_below:
return 0
return None
descriptors, labels, groups = [], [], []
for row in rows:
picked = [c for c in row.get("rows", [])
if c.get("bucket") == bucket
and c.get("pass", 0) == pass_index
and (scene is None or c.get("scene") == scene)]
if not picked:
continue
per_scene = row.get("scene_rewards") or None
if per_scene:
# Each rated scene is its own labelled sample: one aggregate reward spread over
# every scene would file a Perfect scene as a bad one whenever another scene in
# the same run was rated badly.
for cell in picked:
label = _label(per_scene.get(int(cell.get("scene", 0))))
if label is None:
continue
descriptors.append(cell["desc"].float())
labels.append(label)
groups.append(row.get("prompt_hash"))
continue
label = _label(row.get("reward"))
if label is None:
continue
descriptors.append(torch.stack([c["desc"].float() for c in picked]).mean(dim=0))
labels.append(label)
groups.append(row.get("prompt_hash"))
return descriptors, labels, groups
def schedule_id(row):
""""Which schedule was this run sampled on" — step count plus H3's two flow shifts.
Bucket 2 of a 4-step shift-12 run and bucket 2 of a 12-step shift-3 run cover different
sigma ranges, so pooling them compares different windows and calls the result a rating
effect. The analysis reports the mix rather than silently averaging it away.
"""
meta = row.get("meta") if isinstance(row, dict) else None
meta = meta if isinstance(meta, dict) else {}
return (row.get("steps") if isinstance(row, dict) else None,
meta.get("shift_video"), meta.get("shift_audio"))
def schedule_mix(rows):
"""schedule_id -> run count, so a report can say what it is pooling."""
mix = {}
for row in rows:
mix[schedule_id(row)] = mix.get(schedule_id(row), 0) + 1
return mix
def analyse(rows, n_buckets=None, trials=2000, good_above=0.0, bad_below=0.0, pass_index=0):
"""Per-bucket verdicts across recorded runs, pooled and (when prompts repeat) stratified
by prompt. Buckets are ordered earliest-first: bucket 0 is the window nothing currently
steers in, and it is the one the whole proposal depends on."""
if n_buckets is None:
n_buckets = max((c.get("bucket", 0) for r in rows for c in r.get("rows", [])
if c.get("pass", 0) == pass_index), default=-1) + 1
results = []
for bucket in range(int(n_buckets)):
descriptors, labels, groups = collect_bucket(
rows, bucket, good_above, bad_below, pass_index=pass_index)
entry = {"bucket": bucket, "n": len(labels),
"n_good": sum(labels), "n_bad": len(labels) - sum(labels)}
entry["pooled"] = permutation_test(descriptors, labels, None, trials=trials)
stratified = None
if groups and all(g is not None for g in groups) and len(set(groups)) > 1:
stratified = permutation_test(descriptors, labels, groups, trials=trials)
entry["within_prompt"] = stratified
results.append(entry)
return results
def row_identity(row):
"""What makes two recorded runs the same run.
Import is expected to be run more than once -- the same export carried onto a
new box, or two boxes' exports merged. Appending blindly would double the
sample, and the permutation test reads n directly: duplicated runs would
report a separation as far more significant than the evidence supports.
"""
if not isinstance(row, dict):
return None
return (row.get("stamp"), row.get("seed"), row.get("prompt_hash"),
row.get("reward"), len(row.get("rows") or []))
def merge_rows(refinement_key, incoming):
"""Add runs to a log, skipping ones already there. -> (total, added)."""
existing = load_log(refinement_key)
seen = {row_identity(r) for r in existing}
added = 0
for row in incoming:
# Same guard load_log applies: a row with no cells describes no run, and
# a reward that is not a number cannot be labelled.
if not isinstance(row, dict) or not row.get("rows"):
continue
try:
float(row.get("reward"))
except (TypeError, ValueError):
continue
identity = row_identity(row)
if identity in seen:
continue
seen.add(identity)
existing.append(row)
added += 1
if added:
_atomic_save({"version": 1, "rows": existing[-MAX_ROWS:]}, log_path(refinement_key))
return len(existing[-MAX_ROWS:]), added
def clear_all():
"""Delete every recorded run, every pending candidate, and the per-bucket heads.
The heads are rebuilt from this log on each rating, so removing them alone achieves
nothing -- the next rating restores them from the runs still on disk. The switch is
deliberately left alone: "throw away what was measured" and "stop measuring" are
different requests, and a clear that silently stopped recording would look like the
probe had broken.
-> {"runs": n, "files": n} describing what went.
"""
removed = {"runs": 0, "files": 0}
directory = _probe_dir()
if os.path.isdir(directory):
for name in sorted(os.listdir(directory)):
if name == _ENABLED_FILE:
continue
path = os.path.join(directory, name)
if name.endswith(".trajectory.pt"):
removed["runs"] += len(load_log(name[: -len(".trajectory.pt")]))
try:
os.remove(path)
removed["files"] += 1
except OSError:
pass
# The heads live on the refinement-key sidecar path, so they are found by glob rather
# than by asking which keys exist -- a key deleted since would otherwise leave its
# heads behind to be loaded by a key that reuses the name.
try:
import glob
try:
from .conditioning import refinement_state_path
except ImportError:
from conditioning import refinement_state_path
sidecars = os.path.dirname(refinement_state_path("x", "value_fn_buckets",
prefix="refine_v2", extension="pt"))
for path in glob.glob(os.path.join(sidecars, "*value_fn_buckets*")):
try:
os.remove(path)
removed["files"] += 1
except OSError:
pass
except Exception as e:
_failed("trajectory probe", "clear heads", e,
"the learned windows are still on disk and will keep steering")
return removed