Skip to content

feat(data_service): per-cell loss trajectory + curve-derived shape (GetMetaData)#261

Merged
guillaume-byte merged 4 commits into
devfrom
feat/per-sample-signal-endpoints
Jul 22, 2026
Merged

feat(data_service): per-cell loss trajectory + curve-derived shape (GetMetaData)#261
guillaume-byte merged 4 commits into
devfrom
feat/per-sample-signal-endpoints

Conversation

@AlexGrayBox

Copy link
Copy Markdown
Member

What

Adds a per-cell loss trajectory to the GetMetaData path and integrates the shape classification with it, in _build_metadata_only_response:

  • For each visible sample, attach its loss curve as a loss_trajectory array DataStat (the on-cell sparkline the studio already renders), via query_per_sample(WL_LOSS_TRAJ_SIGNAL, sample_ids).
  • Classify that same curve with classify_loss_shape and emit it as the authoritative tag:loss_shape — so the drawn trajectory and its shape-haze come from one computation and can never disagree. Rows with no loss history keep whatever tag:loss_shape the dataframe carries.

Guarded, additive (+51/-0, one file). No-op for any experiment without a per-sample loss signal. Signal name overridable via WL_LOSS_TRAJ_SIGNAL (default "loss").

Why

This capability existed only as a local patch in the LIBERO showcase's pinned 1.3.3 SDK — so the demo would lose its per-cell curves on current dev. This lifts it into dev cleanly, built on dev's existing query_per_sample primitive (not the superseded __history__ sentinel from signal-executor-wip).

Verification (end-to-end on dev SDK)

Ran the LIBERO showcase on this branch (dev + this port) with the demo's real logger snapshot:

  • load_snapshot → 13 per-sample graphs restored; query_per_sample("loss") → 21,724 rows / 5,431 samples; classify_loss_shape → correct shapes (decreasing curves → monotonic).
  • Live backend logs [loss_traj] page_ids=231 with_history=231 signal=loss; GetHistogram works; auto-pause intact; no crashes.
  • The merged studio (per-cell trajectory Fix dependencies for py3.10+ #126 + composable haze Remove out-of-scope UI files from Python dependency fix PR #130) consumes exactly loss_trajectory + tag:loss_shape.

Follow-up (not in this PR)

Frontend LOSS_SHAPE_PRESET is missing a curated color for high_variance (the 6th LOSS_SHAPES value) — a ~2-line studio tweak, tracked separately.

🤖 Generated with Claude Code

Port the demo's loss_trajectory embedding into dev's GetMetaData path and
integrate the shape classification: for each visible sample, attach its loss
curve as a 'loss_trajectory' array stat AND classify that same curve into an
authoritative tag:loss_shape, so the on-cell sparkline and its shape-haze are
one computation and cannot disagree. Guarded no-op when no per-sample loss
signal exists; signal name overridable via WL_LOSS_TRAJ_SIGNAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ger snapshot)

Two independent clusters have been red on dev's Code CI for many commits — both
stale tests left behind by removed features, not real bugs:

A) ui_docker_bridge: f45510b ('Docker-free UI') renamed ui_docker_bridge.py ->
   cli.py and moved cert scripts to ui/utils/, but a later merge re-added the
   orphaned ui_docker_bridge.py (imported by nothing, pointing at deleted
   docker/docker/utils/ paths + a build-and-deploy.sh that no longer exists).
   cli.py + test_cli.py (29 green) supersede it -> delete the dead file + test.

B) logger snapshot: 671270b (#257) replaced the chunked-zstd logger snapshot
   with DuckDB (loggers.duckdb via flush_logger_to_disk), removing
   save_logger_snapshot but leaving its tests + one caller behind:
   - repoint test_logger_queue_saved_with_weights to assert loggers.duckdb
   - delete test_logger_snapshot_rotation (tested only the removed writer)
   - experiment_service: note-persist path guarded hasattr(save_logger_snapshot)
     -> always False -> notes silently not persisted; swap to flush_logger_to_disk

Verified: repointed test passes against DuckDB; test_checkpoint_workflow 15/15;
test_cli 29/29; full suite collects 1075 clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +3574 to +3624
# -- Per-sample loss trajectory + its shape (one source for the UI) ---
# For each visible sample attach its loss curve as an 'array' DataStat
# (the on-cell sparkline) AND classify THAT SAME curve into tag:loss_shape,
# so the drawn trajectory and its shape-haze come from one computation and
# can never disagree. The curve-derived shape is authoritative: it replaces
# any dataframe-provided tag:loss_shape for rows that have a trajectory;
# rows with no loss history keep whatever the dataframe carries. Guarded and
# a no-op for experiments with no per-sample "loss" signal.
traj_signal = os.environ.get("WL_LOSS_TRAJ_SIGNAL", "loss")
try:
from weightslab.backend import ledgers
from weightslab.src import classify_loss_shape

logger_q = ledgers.get_logger()
if logger_q is not None:
traj = {}
for sid, step, val, _ in logger_q.query_per_sample(
traj_signal, sample_ids=sample_ids):
traj.setdefault(str(sid), []).append((int(step), float(val)))
if traj:
logger.info("[loss_traj] page_ids=%d with_history=%d signal=%s",
len(sample_ids), len(traj), traj_signal)
for i, sid in enumerate(sample_ids):
pts = traj.get(str(sid))
if not pts:
continue
pts.sort()
vals = [v for _, v in pts]
try:
shape = classify_loss_shape(vals, min_points=3)
except Exception:
shape = None
# Downsample the curve for display (endpoints preserved).
if len(vals) > 32:
idx = np.linspace(0, len(vals) - 1, 32).round().astype(int)
curve = [float(vals[j]) for j in idx]
else:
curve = [float(v) for v in vals]
row_stats[i].append(
_DataStat(name="loss_trajectory", type="array",
shape=[len(curve)], value=curve))
if shape:
# Curve-derived shape wins: drop any df-provided one.
row_stats[i] = [s for s in row_stats[i]
if s.name != "tag:loss_shape"]
row_stats[i].append(
_DataStat(name="tag:loss_shape", type="string",
shape=[1], value_string=str(shape)))
except Exception:
logger.debug("loss_trajectory/shape stat skipped", exc_info=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should classify Loss Shape directly in the logger, such that global signal per sample shape classification occurs only when a new signal is added

@guillaume-byte guillaume-byte left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to move the signal per sample shape cls to the logger

Alexandru Rotaru and others added 2 commits July 22, 2026 14:52
Per review (Guillaume): don't classify loss shapes inside
_build_metadata_only_response — that runs per GetMetaData request. The shape is
already computed on the write/train path by enable_loss_shape_signal /
write_loss_shapes (cadenced + exp_hash-scoped), which maintains the
tag:loss_shape dataframe column. So ship ONLY the loss_trajectory curve here and
let the haze read the pre-computed tag column — no per-request classification,
correct exp_hash scoping, one source for the shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… levels)

Extract the query/group/downsample into guard-claused helpers
(_loss_trajectory_curves + _downsample_curve). The GetMetaData call site is now a
flat 'for sid: if curve: append' (was try>if>if>for>if). Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AlexGrayBox
AlexGrayBox force-pushed the feat/per-sample-signal-endpoints branch from 4032964 to 206e499 Compare July 22, 2026 13:22
@guillaume-byte
guillaume-byte merged commit 05d3fae into dev Jul 22, 2026
14 checks passed
@guillaume-byte
guillaume-byte deleted the feat/per-sample-signal-endpoints branch July 22, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants