Observability cookbook (P1.05.11; ADR-017)
This cookbook covers the workflows P1.05.11 enables — live W&B dashboards
for the founder + JSON-first co-analysis for the agent. The design lives
in ADR-017;
this document is the operator-side recipe collection.
TL;DR
# 1. Install observability extras (imageio[ffmpeg] for rollout MP4 encoding).
uv sync --extra observability
# 2. Authenticate W&B (one-time, founder-side).
wandb login
# 3. Enable the W&B sink for a Stage-1b cell.
uv run chamber-spike train \
--config configs/training/ego_aht_happo/stage1_pickplace.yaml \
--override wandb.enabled=true
# 4. Browse the dashboard at https://wandb.ai/<entity>/concerto-chamber.
# 5. Programmatic co-analysis (agent-side; works offline, no W&B account).
uv run chamber-analyze list-runs --archive-root spikes/results/ --json | jq .
Cookbook recipes
Worked examples with real run IDs from
spikes/results/stage1-failure-investigation/2026-05-20/ — the five
post-launch Stage-1b cells (seeds 0–4) committed before P1.05.11
landed. Pre-P1.05.11 archives carry the envelope-level events
(training_start, safety_telemetry, rollout_update,
safety_telemetry_final, training_end) but not per-step PPO
scalars (policy_loss, dist_entropy, approx_kl, …) or per-step
rollout sidecar JSONLs — those are emitted only by cells launched
after this slice merged with observability.wandb.enabled=true. The
recipes note when an example is forward-looking vs grounded in the
committed archive.
Cookbook recipe 1 — First-instrumented-cell walkthrough
End-to-end: edit the config, launch a Stage-1b cell with the W&B sink on, view results in the browser, find the artefacts on disk.
Step 1 — Edit the config (or use a per-run override).
Persistent (edit
configs/training/ego_aht_happo/stage1_pickplace.yaml):
wandb:
enabled: true
project: concerto-chamber # ADR-017 §Decisions D2 — single project
rollout_recorder:
enabled: true
interval_frames: 25000 # record one eval episode every 25k frames
fps: 20
Or per-run (no config edit; useful for one-off experiments):
uv run chamber-spike train \
--config configs/training/ego_aht_happo/stage1_pickplace.yaml \
--override wandb.enabled=true \
--override rollout_recorder.enabled=true
Step 2 — Launch the cell.
make stage1-as # or: uv run chamber-spike train ...
During training the canonical record is the per-cell
logs/<run_id>.jsonl. W&B receives the same events live; you can
open the dashboard before the cell completes and watch the curves
update.
Step 3 — View in W&B (founder-side).
Browse https://wandb.ai/<entity>/concerto-chamber. The cell appears
as a run named after its 16-hex run_id (derived deterministically
from (seed, git_sha, pyproject_hash, run_kind); see
concerto.training.logging.compute_run_metadata). Default chart
group is train/ (PPO health scalars); safety/ carries
lambda_mean/var/min/max; eval/ carries success_rate and
mean_episode_* per eval-cell completion. The run's Overview panel
shows the wandb.config fields: run_id, seed, git_sha,
pyproject_hash, run_kind, prereg_sha (full SHA; the short-form
also appears as a tag — see §Decisions D10).
Step 4 — Find artefacts on disk (agent-side).
<cfg.log_dir>/ # default ./logs/
└── <run_id>.jsonl # canonical per-cell log
<spike_archive>/ # default spikes/results/<spike-id>/
├── spike_as.json # SpikeRun envelope (ADR-016)
├── leaderboard.json # ADR-008 leaderboard entry
└── rollouts/ # paired MP4 + sidecar JSONL artefacts
└── as-hetero/ # one sub-dir per condition_id
├── step_025000.mp4 # founder-side: drag into W&B Reports / PR
├── step_025000.jsonl # agent-side: parse with chamber-analyze
├── step_050000.mp4
├── step_050000.jsonl
└── …
The W&B run name (16-hex run_id) matches the JSONL filename
byte-for-byte. The agent's chamber-analyze subcommands work
against the JSONLs alone — no W&B account needed.
Cookbook recipe 2 — Comparing two runs (worked: 2026-05-20 archive)
Side-by-side comparison across N runs. Worked example uses the two
lowest-seed cells from the committed 2026-05-20 failure-investigation
archive (43a0d043cb9f54ac = seed 0; e3ca669746e8f4bb = seed 1).
The actual numbers reflect the pre-P1.05.8 Surface 1 rubber-stamp
bug (every episode trivially passed under the old success rule); the
recipe is structural — same commands work against post-merge cells
that produce honest metrics.
Step 1 — List candidates.
uv run chamber-analyze list-runs \
--archive-root spikes/results/stage1-failure-investigation/2026-05-20 \
--json | jq '.[] | {run_id, seed, terminal_success_rate, lambda_steady_state}'
Expected:
{"run_id": "43a0d043cb9f54ac", "seed": 0, "terminal_success_rate": 1.0, "lambda_steady_state": -7.0}
{"run_id": "e3ca669746e8f4bb", "seed": 1, "terminal_success_rate": 1.0, "lambda_steady_state": -7.0}
{"run_id": "ef23e3b576f38c91", "seed": 2, "terminal_success_rate": 1.0, "lambda_steady_state": -7.0}
{"run_id": "a7b549a31fbd8222", "seed": 3, "terminal_success_rate": 1.0, "lambda_steady_state": -7.0}
{"run_id": "da40f205c78355ac", "seed": 4, "terminal_success_rate": 1.0, "lambda_steady_state": -7.0}
Step 2 — Compare two cells head-to-head.
uv run chamber-analyze compare 43a0d043cb9f54ac e3ca669746e8f4bb \
--archive-root spikes/results/stage1-failure-investigation/2026-05-20 \
--metrics mean_reward_final,terminal_success_rate,lambda_steady_state \
--json
Expected:
[
{"run_id": "43a0d043cb9f54ac",
"mean_reward_final": null,
"terminal_success_rate": 1.0,
"lambda_steady_state": -7.0},
{"run_id": "e3ca669746e8f4bb",
"mean_reward_final": null,
"terminal_success_rate": 1.0,
"lambda_steady_state": -7.0}
]
mean_reward_final is null here because the 2026-05-20 archive's
rollout_update events serialised the per-step reward as the string
repr "tensor([3.18e-05])" rather than a numeric field — pre-P1.05.11
type-bug, not slice-induced. Cells launched post-merge emit
mean_episode_reward numerically via the new event="eval" lines
and surface here as a float.
Step 3 — Drill into a single scalar.
uv run chamber-analyze metrics 43a0d043cb9f54ac \
--archive-root spikes/results/stage1-failure-investigation/2026-05-20 \
--metric lambda_mean --namespace safety --json | jq '.[0:5]'
Expected first 5 of ~100 rollout-window stats:
[
{"step": 1024, "wall_time": null, "value": -0.2679},
{"step": 2048, "wall_time": null, "value": -0.7802},
{"step": 3072, "wall_time": null, "value": -1.4115},
{"step": 4096, "wall_time": null, "value": -2.0428},
{"step": 5120, "wall_time": null, "value": -2.6741}
]
wall_time is null for pre-P1.05.11 archives; post-merge cells
carry an ISO-8601 stamp on every emitted line.
Cookbook recipe 3 — Per-step rollout analysis
Find the step in a recorded eval episode where a key info flag
flips (e.g. is_grasped goes False → True), and read the
obs_summary fields at that step. This recipe is forward-looking:
the 2026-05-20 committed archive pre-dates the recorder, so the
sidecar JSONLs at <archive>/rollouts/<condition>/step_*.jsonl do
not exist there. Any cell launched post-P1.05.11 with
rollout_recorder.enabled=true produces them.
Step 1 — Find a recorded rollout.
# List the rollout sidecar JSONLs for one run's archive.
find <archive_root>/rollouts -name "step_*.jsonl" | head
Expected layout:
<archive_root>/rollouts/as-hetero/step_025000.jsonl
<archive_root>/rollouts/as-hetero/step_050000.jsonl
<archive_root>/rollouts/as-hetero/step_075000.jsonl
…
Step 2 — Dump per-step records for one rollout via chamber-analyze.
uv run chamber-analyze rollout-frames <run_id> \
--archive-root <archive_root> \
--episode 50000 | jq '.[0]'
Expected first record (illustrative; one line per env step):
{
"event": "rollout_step",
"metric_namespace": "rollout",
"step_global": 50000,
"step_episode": 0,
"condition": "as-hetero",
"seed": 0,
"wall_time": "2026-05-22T15:54:48.752+00:00",
"obs_summary": {
"cube_pose": [0.073, 0.000, 0.020, 1.000, 0.000, 0.000, 0.000],
"goal_pos": [0.000, 0.100, 0.250],
"tcp_pose": [0.250, 0.000, 0.500, 1.000, 0.000, 0.000, 0.000],
"ego_qpos": [0.000, -0.785, 0.000, -2.356, 0.000, 1.571, 0.785, 0.040],
"ego_qvel": [0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000],
"partner_qpos": [0.350, 0.200, 0.000, 0.000, 0.500, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000],
"partner_qvel": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"is_grasped": false,
"gripper_width": 0.080
},
"action": [0.10, 0.00, -0.05, 0.00, 0.00, 0.00, 0.00, 0.00],
"reward": 0.0,
"terminated": false,
"truncated": false,
"info": {"is_grasped": false, "elapsed_steps": 0},
"frame_index": 0
}
Step 3 — Find the step where is_grasped flips.
uv run chamber-analyze rollout-frames <run_id> \
--archive-root <archive_root> \
--episode 50000 \
--field is_grasped | jq '[.[] | select(.is_grasped == true)] | .[0]'
Expected (first step where the gripper closed on the cube):
{"step_global": 50018, "is_grasped": true}
Step 4 — Read the surrounding obs_summary at that step.
uv run chamber-analyze rollout-frames <run_id> \
--archive-root <archive_root> \
--episode 50000 | jq '.[] | select(.step_global == 50018) | .obs_summary'
Expected:
{
"cube_pose": [0.075, 0.001, 0.038, 1.000, 0.000, 0.000, 0.000],
"goal_pos": [0.000, 0.100, 0.250],
"tcp_pose": [0.075, 0.001, 0.038, 1.000, 0.000, 0.000, 0.000],
"ego_qpos": [0.103, -0.612, -0.005, -2.182, 0.012, 1.520, 0.802, 0.000],
"ego_qvel": [0.012, 0.041, 0.003, 0.022, 0.001, -0.005, 0.001, -2.310],
"partner_qpos": [0.350, 0.200, 0.000, 0.000, 0.500, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000],
"partner_qvel": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"is_grasped": true,
"gripper_width": 0.000
}
Note the TCP and cube poses are co-located (the gripper closed on
the cube); gripper_width dropped to 0; the rapid ego_qvel[7]
negative spike is the gripper-close velocity. The agent now has a
mechanistic picture of the grasp moment without watching the MP4.
The obs_summary carries a curated subset of fields per ADR-017
§Decisions D6 — never the full 65-D flattened ego state. Extending
the field set requires editing
chamber.observability.rollout_recorder._{OBS_EXTRA,AGENT_SUB,INFO}_KEYS
and rebuilding any archived rollouts.
Cookbook recipe 4 — Embedding a comparison plot in a PR description
Render a multi-run scalar comparison to PNG and attach it to a GitHub PR description.
Step 1 — Generate the PNG via chamber-analyze plot.
uv run chamber-analyze plot 43a0d043cb9f54ac e3ca669746e8f4bb \
--archive-root spikes/results/stage1-failure-investigation/2026-05-20 \
--metric lambda_mean --namespace safety \
--out /tmp/lambda_seed0_vs_seed1.png \
--title "lambda_mean: seed 0 vs seed 1 (Stage-1b post-launch, 2026-05-20)"
plot uses matplotlib's Agg backend (headless-safe; works in CI and
on remote hosts without an X display). Output PNG is suitable for
direct embed.
Step 2 — Attach to GitHub via drag-and-drop OR via gh.
Browser path. Drag the PNG into a GitHub PR description editor; the
editor uploads to user-images.githubusercontent.com and inserts

at the cursor.
CLI path. Edit the PR body via gh pr edit --body-file:
# Compose the body (which references the eventual attachment URL).
cat <<'EOF' > /tmp/pr_body.md
## Lambda divergence — seed 0 vs seed 1

The two seeds' λ trajectories diverge at ~25k frames; seed 0 saturates the
clamp by 50k while seed 1's overshoot is bounded ~6% lower.
EOF
# Upload the image via GitHub's web UI (manual step), then paste the
# user-images URL into the body above before:
gh pr edit <pr-number> --body-file /tmp/pr_body.md
Step 3 — Inline the chart in a Markdown README or ADR.
For permanently-embedded charts (vs ephemeral PR-description images),
commit the PNG under docs/assets/ and reference via a relative
path:

mkdocs renders this against the docs/ build root; GitHub's source-view renders it against the file's own location. Both work.
How-to guides
How to enable W&B for a spike run
The W&B sink is opt-in via cfg.wandb.enabled. Two ways to flip it on:
Per-run override (no config edit):
uv run chamber-spike train \
--config configs/training/ego_aht_happo/stage1_pickplace.yaml \
--override wandb.enabled=true
Persistent (edit the YAML):
# configs/training/ego_aht_happo/stage1_pickplace.yaml
wandb:
enabled: true
project: concerto-chamber # ADR-017 §Decisions D2 — single project
Degrade-to-no-op paths (each emits one UserWarning, never raises) —
the canonical full list is documented in §Environment variables below.
How to back-fill historical archives
# Dry-run against committed archives — show what would replay, no W&B calls.
uv run python scripts/wandb_backfill.py \
--archive-root spikes/results/ \
--project concerto-chamber --mode offline --dry-run
# Replay the three committed Stage-1 archives to W&B.
uv run python scripts/wandb_backfill.py \
--archive-root spikes/results/ \
--project concerto-chamber --mode online
# Also replay the four gitignored .local/ post-widening snapshots.
uv run python scripts/wandb_backfill.py \
--archive-root .local/ \
--include-local \
--project concerto-chamber --mode online
Idempotent: re-running against the same archive skips existing W&B
runs (wandb.init(id=<run_id>, resume="never")).
What backfill does deliver. Replays per-cell JSONL events
(training_start, safety_telemetry, rollout_update,
safety_telemetry_final, training_end) + envelope-level terminal
metrics (per-episode success, mean_reward,
constraint_violation_peak, fallback_fired) into W&B.
What backfill does NOT deliver. Per-step PPO health curves
(policy_loss, value_loss, dist_entropy, approx_kl,
clip_fraction, grad_norm) for historical archives — those
scalars were never written to disk before P1.05.11. They are only
available for runs going forward.
Reference
Config schema reference
The two Pydantic blocks P1.05.11 adds, with every field, its default,
and a one-line semantic. Both blocks live in
concerto.training.config and round-trip through Hydra YAML.
WandbConfig (src/concerto/training/config.py:46-69):
wandb:
# Master switch. When False (default), bind_run_logger is built
# without a W&B sink and only the JSONL fallback fires. CI runs
# and unit tests should leave this off.
enabled: false
# W&B project name. Single project across all stages per
# ADR-017 §Decisions D2; tag-based filtering separates runs
# in the UI by stage:/sub_stage:/condition:/prereg:/backfill:.
# Pre-ADR-017 default was "concerto-m4b" (migrated 2026-05-22).
project: concerto-chamber
RolloutRecorderConfig (src/concerto/training/config.py:295-339):
rollout_recorder:
# Master switch. When False (default), the eval-cell loop skips
# frame capture, the env factory does not request
# render_mode="rgb_array", and finalise() short-circuits.
enabled: false
# Record one eval episode every K training frames. Default
# 25_000 frames ≈ 4 recordings over a 100k Stage-1b cell.
interval_frames: 25000
# When True (default), emit the paired per-step sidecar JSONL
# alongside the MP4. When False, MP4-only. The agent-side
# co-analysis workflow assumes per_step_jsonl=true.
per_step_jsonl: true
# How many eval episodes to record at each interval trigger.
episodes_per_record: 1
# Video frame rate for the MP4 encode. Default 20 matches
# ManiSkill's Stage-1b control_freq.
fps: 20
Both blocks are _FrozenModel subclasses (frozen + extra="forbid")
so YAML typos fail loud with pydantic.ValidationError at
load_config() time.
Environment variables
P1.05.11 reads three W&B environment variables. None are mandatory; each missing or misconfigured value resolves to a documented degrade-to-no-op path per ADR-017 §Decisions D4.
| Env var | Default | Effect when set | Effect when missing |
|---|---|---|---|
WANDB_API_KEY |
(none) | Authenticates the W&B online client. Required for WANDB_MODE=online. |
If cfg.wandb.enabled=true AND WANDB_MODE != "offline": make_wandb_run_sink emits a UserWarning and returns None; the run continues with JSONL only. |
WANDB_MODE |
online |
online uploads to W&B servers; offline writes to local ./wandb/ directory only; disabled skips both. offline is what the equivalence smoke + the integration test use. |
Defaults to online. |
WANDB_ENTITY |
(login default) | Overrides the W&B entity (user or team) the run lands under. | Uses the entity tied to the active wandb login session. |
The complete degrade-to-no-op decision tree
(concerto.training.logging.make_wandb_run_sink):
| Condition | Result |
|---|---|
cfg.wandb.enabled=false |
Silent — JSONL-only path, no warning. |
cfg.wandb.enabled=true AND WANDB_API_KEY missing AND WANDB_MODE != "offline" |
UserWarning("WANDB_API_KEY missing …"); JSONL-only. |
cfg.wandb.enabled=true AND WANDB_MODE=offline |
Constructs the sink; writes to ./wandb/ locally; no upload. |
import wandb fails (ImportError) |
UserWarning("import wandbfailed …"); JSONL-only. |
wandb.init(...) raises |
UserWarning("wandb.init(...) raised …"); JSONL-only. |
| All checks pass | Live W&B run; tags + config payload from make_wandb_run_sink's arguments. |
The canonical record is always <cfg.log_dir>/<run_id>.jsonl.
W&B is downstream of that. If your run completed and the JSONL
exists, the run is valid (ADR-017 §Decisions D1).
JSON output schemas
Every chamber-analyze subcommand supports --json. This is the
agent-co-analysis contract (ADR-017 §Decisions D5); schemas are
public-API-stable across patch versions. Adding fields is allowed;
removing or renaming a field requires a major-version bump on the
CLI. Treat the field names listed here as load-bearing.
chamber-analyze list-runs --json
Array of per-cell _RunSummary records (one per <run_id>.jsonl
under --archive-root). Fields without source data in the archive
resolve to null rather than being omitted so the JSON shape is
stable across archive vintages.
[
{
"run_id": "<16-hex>",
"jsonl_path": "<absolute-path>",
"stage": "1" | "2" | "3" | null,
"sub_stage": "1a" | "1b" | "2" | "3" | null,
"condition": "<condition_id-or-pair>" | null,
"seed": <int> | null,
"task": "stage1_pickplace" | "mpe_cooperative_push" | "stage0_smoke" | null,
"run_kind": "ego_aht_happo" | null,
"git_sha": "<40-hex>" | null,
"n_steps": <int> | null,
"terminal_success_rate": <float in [0, 1]> | null,
"mean_reward_final": <float> | null,
"lambda_steady_state": <float> | null
},
...
]
Sort order: by seed ascending, then run_id ascending. Stable
across invocations against the same archive.
chamber-analyze summary <run_id> --json
One _RunSummary record (same shape as a single element of
list-runs). Exit code 2 if the run_id is not found.
chamber-analyze metrics <run_id> --metric <name> --json
Array of time-series points for one metric on one run. Filtered by
--namespace (one of train, eval, safety, hardware, rollout).
[
{
"step": <int>,
"wall_time": "<iso8601-with-tz>" | null,
"value": <float>
},
...
]
step is always present and is the global training-frame counter
at emission. wall_time is null for pre-P1.05.11 archives.
chamber-analyze compare <run_id_a> <run_id_b> [...] --metrics M1,M2 --json
Array of one record per requested run_id (in input order). Each
record carries run_id plus one field per metric. Unknown
run_ids are kept in the array with an "error": "not_found"
field so the response shape matches the request length.
[
{
"run_id": "<16-hex>",
"<metric_1>": <number> | null,
"<metric_2>": <number> | null,
...
},
...
]
Default metric set when --metrics is omitted:
mean_reward_final, terminal_success_rate,
lambda_steady_state.
chamber-analyze plot ... --out <path.png>
Side effect only (writes the PNG to --out). Exits 0 on success,
2 if matplotlib isn't installed (uv sync --extra viz resolves).
No JSON output.
chamber-analyze rollout-frames <run_id> --json
Two output shapes depending on whether --field <key> is passed:
Without --field: the full per-step JSONL line:
[
{
"event": "rollout_step",
"metric_namespace": "rollout",
"step_global": <int>,
"step_episode": <int>,
"condition": "<condition>",
"seed": <int>,
"wall_time": "<iso8601>",
"obs_summary": {"cube_pose": [...], "goal_pos": [...], ...},
"action": [<float>, ...],
"reward": <float>,
"terminated": <bool>,
"truncated": <bool>,
"info": {...},
"frame_index": <int>
},
...
]
With --field <key>: each row projects to two fields —
step_global and the requested key (looked up in
obs_summary first, then info):
[
{"step_global": <int>, "<key>": <value> | null},
...
]
The lookup order (obs_summary then info) means is_grasped
resolves from obs_summary.is_grasped when the env's per-step info
exposes it there; if the recorder's obs_summary selector misses
the key, the info mirror catches it.
Filesystem layout
<cfg.log_dir>/
└── <run_id>.jsonl # per-cell canonical log
<spike_archive>/ # default spikes/results/<spike-id>/
├── <run_id>.jsonl # one per (seed, condition) cell
├── spike_*.json # SpikeRun envelope (ADR-016)
├── leaderboard.json # ADR-008 leaderboard entry
└── rollouts/ # ADR-017 §Decisions D6 sidecars
└── <condition>/ # one sub-dir per condition_id
├── step_000000.mp4
├── step_000000.jsonl
├── step_025000.mp4
├── step_025000.jsonl
└── ...
<run_id> is the 16-hex SHA from
concerto.training.logging.compute_run_metadata(seed, run_kind, repo_root).
Two reruns with identical (seed, git_sha, pyproject_hash, run_kind)
produce the same run_id — the W&B run name matches the JSONL
filename byte-for-byte.
Troubleshooting
env.render() returns None; rollout MP4s not generated
Symptom. chamber.observability.RolloutRecorder.finalise() returns
(None, jsonl_path) instead of (mp4_path, jsonl_path). ManiSkill
logs the warning "Requested to use render device 'sapien_cuda', but
CUDA device was not found. Falling back to 'cpu' device. Rendering
might be disabled." The per-step sidecar JSONL still emits cleanly;
the agent-side co-analysis path is unaffected.
Root cause. NVIDIA driver/library version mismatch. nvidia-smi
reports "Failed to initialize NVML: Driver/library version
mismatch (NVML library version: 535.x)." The userspace
libnvidia-ml.so upgraded but the kernel's nvidia.ko module did
not — a classic post-kernel-upgrade-without-reboot state.
torch.cuda.is_available() returns False. The SAPIEN renderer
falls back from sapien_cuda to CPU and returns None from
render(mode="rgb_array").
Diagnostic steps.
nvidia-smi # "Failed to initialize NVML" → driver mismatch
uv run python -c "import torch; print(torch.cuda.is_available())" # False
Recovery.
- Reboot the host. This re-syncs the kernel module against the
updated userspace library.
nvidia-smishould report a healthy driver/CUDA version table after restart. - Re-verify rendering.
uv run python -c "
import numpy as np
from chamber.envs.stage1_pickplace import make_stage1_pickplace_env
env = make_stage1_pickplace_env(
condition_id='stage1_pickplace_panda_plus_fetch_ego_aht_happo_per_agent',
episode_length=20,
root_seed=0,
render_mode='rgb_array',
)
env.reset(seed=0)
frame = env.render()
print(type(frame).__name__, getattr(frame, 'shape', None))
"
# Expect: ndarray (H, W, 3)
- Flip the env-render xfail-strict test. The integration test at
tests/integration/test_rollout_recorder_env_render.pyis marked@pytest.mark.xfail(strict=True)for exactly this host state. Run it post-reboot:
uv run pytest tests/integration/test_rollout_recorder_env_render.py --no-cov -v
# XPASSED → strict=True fails the run → forces marker removal.
When the test xpasses, remove the @pytest.mark.xfail marker in
the same commit that re-runs it (lifecycle hygiene precedents:
ADR-004 §Revision history 2026-05-17 / P1.02 Bounds.action_norm;
ADR-002 §Revision history 2026-05-21 / P1.05.8 trainer obs
reader). The follow-up commit subject:
test(observability): remove env-render xfail post-host-reboot.
Related tracking issue.
#188 — the
sapien_gpu_available() predicate is looser than what
test_draft_zoo's skipif actually needs (a tighter
sapien_cuda_renderer_available() check would prevent the
downstream KeyError: 'state' on this host state). Not slice-induced;
filed during P1.05.11's push-gate failure review.
Cross-references
- ADR-017 — Observability and experiment tracking — design & rationale.
- ADR-002 — RL framework §Decisions — the structured-logging contract this slice extends.
- ADR-016 — SpikeRun envelope schema — the wire-format archive shape (NOT changed by P1.05.11).
src/concerto/training/logging.py—RunContext,bind_run_logger,log_scalars,log_eval,make_wandb_run_sink.src/concerto/training/config.py—WandbConfig,RolloutRecorderConfig.src/chamber/observability/rollout_recorder.py—RolloutRecorder.src/chamber/cli/analyze.py—chamber-analyze.scripts/wandb_backfill.py— backfill script.