Skip to content

feat(rldb): detect JPEG vs H264 image encoding instead of trusting dtype - #543

Open
ElmoPA wants to merge 174 commits into
mainfrom
rldb/encoding-detection
Open

feat(rldb): detect JPEG vs H264 image encoding instead of trusting dtype#543
ElmoPA wants to merge 174 commits into
mainfrom
rldb/encoding-detection

Conversation

@ElmoPA

@ElmoPA ElmoPA commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

ZarrDataset dispatched purely on features[key]["dtype"]. That is a claim, and a
stale claim routes real mp4 payloads into simplejpeg -- which fails deep in the
decoder, far from the actual cause. Episodes converted before the codec switch,
or whose metadata was copied from a sibling, could not be loaded at all.

_classify_image_keys now uses three signals, cheapest first, escalating only on
disagreement:

  1. the declared dtype;
  2. the element count -- per-frame JPEG stores one element per frame, chunked
    video one mp4 per frames_per_chunk, so a full-length array is JPEG and a
    much shorter one is video. Compared with ">=" because writers pad past
    total_frames (a 290-frame episode occupies 300 slots);
  3. the magic bytes of element 0, read only to break a tie, and authoritative
    when read.

Verified on 72 real episodes across 6 datasets: identical classification and
ZERO payload reads, so the common path costs nothing.

Also fixes two gaps this exposed:

  • video keys with no "video" metadata block now recover frames_per_chunk by
    decoding chunk 0. It is not derivable arithmetically -- 1000 frames over 4
    chunks admits any fpc in (250, 333].
  • ZarrDataset.getitem had NO video branch, only _read_span did, so a
    detected video key hit a frame-indexed read of a chunk-indexed array and
    returned the wrong elements. It now mirrors _read_span, matching
    decode_jpeg_single for horizon=None and _pad_sequences at the episode tail.

Mismatch warnings are deduped per (key, declared, detected) so a systematically
mislabelled dataset logs once, not once per episode.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_012V58H37tmcvgDthELMd5Xk

ElmoPA and others added 30 commits May 20, 2026 02:21
Squash of:
- a31d8ab4 tshape sim environment
- ac272e8a Add Tsimulation viz/scripted/stats tools + physics tuning
…ding

Squash of:
- f261ff68 tsim training configs/embodiment
- b38264ab Add pushshapes_sim HPT training: keymap, viz, eval fixes
- 1e174131 Add episode-level packed dataloading
Squash of 10 commits from temp-arch-flexible: 7faf2012, 49ed0d34, 8387986e, 11a266ce, 3fe9a353, 0ea9d013, c83b6e69, 63a2e852, 7b71650a, a063c021
- test_hnet_nets.py (57): routing, chunk, dechunk, isotropic,
  stages (padded + packed), HNet assembly, ratio_loss, chunk_stats,
  STE, RMSNorm, AdaLN.
- test_packed_pipeline.py (9): normalize broadcast on padded vs
  packed; _iter_leaves descent; multi-frame JPEG decode; end-to-end
  packed stats collection.
- test_training_recipe.py (20): apply_optimization_params,
  init_weights height-scaled init, apply_lr_multiplier per-stage
  stamping, parameter_groups (default, with bias/norm WD=0,
  per-stage groups, AdamW-consumable). Plus algo wiring tests for
  the opt-in init_weights_range / lr_multipliers /
  use_parameter_groups / weight_decay kwargs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Imports from EgoVerse7@temp-arch-flexible working tree as of 2026-05-20.
Includes:
- algo: input_modules.py + obs_transforms.py (new modules)
- callbacks: chunker_residual_scheduler, ckpt_chunker(+dropout), random_attn_dropout
- data configs: tsimulation_400ep, tsimulation_allep + tweaks to existing tsim configs
- model configs: hnet_pushshapes_mamba_encdec, hnet_pushshapes_obs_ar + tweaks
- eval/* edits, models/hnet_nets/* edits, schedulers, uv.lock
- removes scripts/install_cuda_kernels.sh and egomimic/eval/eval_hnet_sim.py

Excluded: egomimic/algo/hnet.py.bak.preinput (manual backup) and drift_eval_out_* (eval artifacts).
- algo.py: add ar_inference_step_size (sub-steps per env tick at closed-loop AR); thread cfg_scale through _inference_step_ar/_inference_step_chunk; remove unused shape var
- backbone.py: force_uncond branch for CFG two-pass blending; wire attn/resid dropout into Isotropic trunk
- sampling.py: _CFGBackbone wrapper for cfg_scale > 1 sampling; schedule-matrix CFG plumbing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…val viz

- model/dfot_pushshapes.yaml: scale to 67M (d_model=512, T=12, num_heads=8, d_intermediate=2048); attn dropout=0.1, resid dropout=0.1, cond_dropout_prob=0.1; cfg_scale field; causal=true
- data/tsimulation_full.yaml: 750-episode circle_750 dataset, batch_size=16
- evaluator/eval_dfot_val.yaml + eval_dfot_full.yaml: cfg_scale + ar_chunk_size + ar_step_size knobs
- callbacks/ckpt_attn_dropout.yaml: composed callback (checkpoints + random_attn_dropout with values [0.1, 0.5, 0.8, 0.9, 0.95, 0.97, 0.98])
- eval/eval_dfot_val.py: 96x96 -> 512x512 nearest-neighbor upscale, palette (gt=green, chunk=red, ar=yellow), world-coord pixel scaling, threaded cfg_scale

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sbatch_train_dfot_200ep_full750_pace.sh: minor cleanup
- sbatch_train_dfot_400ep_full750_pace.sh: 400ep H200 launch with scheduler.max_steps=18800 (fixes the 200ep cosine-not-decaying bug)
- sbatch_train_dfot_400ep_attndrop.sh: 400ep + random_attn_dropout (the 5.6x sim_coverage win)
- scripts/eval_cfg_latest.py: post-hoc DFoT eval CLI with --cfg-scale, --ar-chunk-size, --ar-step-size, --ar-inference-chunk-size, --ar-inference-step-size, --skip-val/--skip-sim
- scripts/eval_fsd_latest.py: convenience wrapper for inference_mode=chunk
- scripts/sbatch_fsd_eval.sh + sbatch_sim_sweep.sh: sbatch templates for closed-loop sim sweeps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the abstract bases for the upcoming refactor:

- egomimic/algo/outer_stage.py: OuterStage base. Owns an
  inner_stage field (the trunk). Subclasses implement encode
  (raw batch -> trunk-input tensor; can sample noise / record state
  on ctx) and decode (trunk-output -> per-modality prediction keys
  on batch).

- egomimic/algo/loss.py: Loss base + CompositeLoss (weighted
  sum of terms) + MSELoss (per-modality MSE between pred_key and
  target_key). Loss policy becomes data — a hydra config block —
  rather than inheritance.

No algorithm code uses these yet; HNetOuterStage / DFoTOuterStage and
the algo.hnet.HNet / algo.dfot.DFoT refactors come in follow-up commits
on this branch.
Foundational refactor for the upcoming DFoTOuterStage + DFoTLoss
classes:

- q_sample(x, t) -> dict: forward-noising step. Returns x_t, noise,
  alpha_t, sigma_t, logsnr, and the precond_scale * logsnr time_cond
  the backbone consumes. No backbone call inside.

- compute_loss(v_pred, q_state) -> per-token weighted MSE: takes the
  dict from q_sample plus the backbones v_pred and computes the
  SNR-weighted epsilon-MSE.

- forward(backbone, x, t, cond): kept as a back-compat wrapper that
  calls q_sample, runs the backbone, then compute_loss. Existing
  callers (DFoT.forward_training) are unaffected.

Bitwise verified equivalent to the prior single-method path via
the included /tmp/test_diffusion_split.py smoke (loss + x_pred match
exactly, same random seed).

discrete_diffusion.py is unsplit for now; current configs use
continuous.
…verified)

- egomimic/algo/dfot/outer_stage.py: DFoTOuterStage subclass.
  encode: encode obs to per-token cond, sample noise levels, run
  diffusion.q_sample, store q_state + external_cond on ctx, return
  noisy x_t. decode: write batch[pred_v] for the loss to read.
  forward override threads cu_seqlens/max_seqlen (packed mode) and
  time_cond into the backbone call.

- egomimic/algo/loss.py: DFoTLoss class. Reads batch[pred_v] and
  ctx.q_state, calls diffusion.compute_loss (SNR-weighted eps-MSE),
  reduces to scalar.

Bitwise verified via /tmp/test_dfot_outer_stage.py: padded-mode loss
through DFoTOuterStage + DFoTLoss matches DFoT.forward_training to
0.0e+00 difference at fixed seed. Real CondEncoderModule +
DFoTBackbone + ContinuousDiffusion submodules; no mocks of the math
path.

Algo class (DFoT.forward_training) is NOT yet wired to use these —
that comes in the next commit on this branch. Inference paths
(closed-loop AR sample_step, chunk plan-execute) also deferred.
Algo class:
- __init__ now takes outer_stage: DFoTOuterStage and optional
  loss: Loss (auto-built as DFoTLoss(outer_stage.diffusion) if None).
- Removes legacy cond_encoder, backbone, diffusion_type,
  diffusion_kwargs, cond_output_key args — they now live on the
  outer_stage subblock.
- Adds @Property accessors for cond_encoder, backbone,
  diffusion, outer_stage, loss so existing inference paths
  (_inference_step_ar, _inference_step_chunk, _sample_chunk,
  forward_eval) keep working unchanged via property forwarding.
- forward_training shrinks ~40 LOC -> ~20 LOC: build ctx, call
  outer_stage(batch, ctx), call loss(batch, ctx). No more inline
  diffusion math; no more cu_seqlens threading at this level.
- Adds ar_inference_step_size knob.

dfot_pushshapes.yaml:
- New outer_stage: block wraps cond_encoder + backbone + diffusion.
- Removes top-level diffusion_type / diffusion_kwargs; the
  diffusion module is now its own _target_ inside outer_stage.
- loss: omitted (uses default DFoTLoss(outer_stage.diffusion)).

End-to-end smoke (scripts/test_dfot_refactor_e2e.py) verifies the
config instantiates via hydra and forward_training emits a finite
scalar loss. Bitwise loss-equivalence was already shown in the prior
commit (test_dfot_outer_stage.py).

Old checkpoints WILL NOT load — state_dict keys moved from
nets.{cond_encoder,backbone}.* to nets.outer_stage.{cond_encoder,inner_stage}.*
This is intentional per the agreed clean-break refactor.
Adds scripts/test_dfot_inference.py: instantiates the refactored DFoT
from dfot_pushshapes.yaml, runs inference_step in both ar and chunk
modes, asserts action is (action_dim,) and finite. Verifies the
@Property accessors (self.backbone, self.cond_encoder, self.diffusion)
forward correctly to outer_stage submodules so the closed-loop AR
and chunk-mode inference paths keep working after the refactor.

Passing on compute node 8997316:
  [ar] action @ t=0: [0.30 0.47]
  [ar] action @ t=1: [0.44 1.06]
  [chunk] action @ t=0: [-0.78 -1.89]
…ied)

- egomimic/algo/hnet_outer_stage.py: HNetOuterStage class. Inherits
  from OuterStage with inner_stage = HNetCore (stage tree). Owns
  cond_encoder, input_modules (summed per-token contributions),
  action_out head. Three forward paths inherited from the old
  HNetPolicy pattern: forward(batch, ctx) dispatcher (padded/packed),
  generate (offline AR), init_step_state + step (online single-tick).

- egomimic/algo/loss.py: HNetLoss class. Reads batch[pred_action] +
  batch[actions], adds per-chunker ratio_loss_from_aux from ctx.aux.

- scripts/test_hnet_outer_stage.py: equivalence smoke. Instantiates
  the existing hnet_pushshapes.yaml subcomponents, wraps the SAME
  instances in HNetPolicy and HNetOuterStage, ties their action_out
  heads, runs identical padded forward. Verified bitwise (max diff
  0.00e+00 at fixed seed) on H200 alloc 8989249. Also smoke-tests
  the step inference path (shape + finite).

The old HNetPolicy class is still in egomimic/algo/hnet.py and not yet
removed. Algo-class refactor and yaml updates come in follow-up
commits on this branch.

Old checkpoints will NOT load — state_dict keys move from policy.*
to outer_stage.* (or wherever the algo class places it). Per clean-
break policy.
Algo class:
- HNet.__init__ now takes outer_stage: HNetOuterStage + loss: Optional[Loss]
  instead of cond_encoder + hnet + action_dim + action_horizon +
  d_model + action_head_type + input_modules. action_horizon read from
  outer_stage.
- Loss defaults to HNetLoss() if not provided.
- self.nets is now ModuleDict({outer_stage, loss}); old keys
  (self.nets[policy], self.nets[cond_encoder], ...) are exposed via
  @Property forwarding to outer_stage submodules so legacy callsites
  in forward_eval / _teacher_forced_packed / _ar_rollout_packed / step
  inference keep working.
- forward_training builds (batch, ctx), calls outer_stage(batch, ctx)
  + loss(batch, ctx), unpacks per-term breakdown (ctx.action_loss,
  ctx.ratio_loss) into the predictions dict for logging.

HNetLoss:
- Computes action MSE + ratio_loss_from_aux(ctx.aux) and stashes the
  per-term split on ctx for the algo to log separately.

HNetOuterStage:
- Adds back-compat bridge methods forward_padded(actions, obs) and
  forward_packed(actions, obs, cu, msl) returning (pred, aux). The
  forward_eval / _teacher_forced_packed paths use these; .generate /
  .step / .init_step_state already had matching signatures.

hnet_pushshapes.yaml:
- New outer_stage: block wraps cond_encoder + hnet stage tree +
  input_modules + action_head_type. Top-level keeps training-recipe
  knobs (init_weights_range, lr_multipliers, ...) and embodiment
  wiring.
- loss: block omitted (defaults to HNetLoss()).

scripts/test_hnet_refactor_e2e.py: packed-mode forward_training smoke.
Verified passing on H200 alloc 8989249 — produces action_loss 1.51 +
ratio_loss 0.032 + chunker stats for a 2-episode packed batch
(T=12+20). Padded mode hits a pre-existing torch SDPA error
(Explicit attn_mask should not be set when is_causal=True) in
train mode — this is in the trunk code, not introduced by the
refactor (the production training uses packed mode and never hits
the padded-train path).

Old HNetPolicy class is still in algo/hnet.py for now (no longer
used by HNet algo); will be removed in a cleanup commit once all
stage-based + flat yamls are migrated.
…hema

Same outer_stage block pattern as the base hnet_pushshapes.yaml, applied
to the variant configs. Each yaml moves cond_encoder + hnet stage tree
+ (optional) input_modules + action_head_type under outer_stage; keeps
training-recipe knobs + embodiment wiring at the top level.

Configs migrated:
- hnet_pushshapes_big.yaml             (d_model 256, 21M params)
- hnet_pushshapes_crossattn.yaml       (cond_mode: cross_attn)
- hnet_pushshapes_mamba_encdec.yaml    (M8 encoder/decoder)
- hnet_pushshapes_obs_ar.yaml          (ObsToken input module)
- hnet_pushshapes_obs_ar_large.yaml    (ObsToken + d_model 256 + T8)
- hnet_pushshapes_recipe.yaml          (H-Net paper recipe)

scripts/test_hnet_yamls_load.py: batch instantiate smoke. Verified
on H200 alloc 8989249 — all 7 stage-based yamls (base + 6 variants)
instantiate from hydra config and produce sensible param counts
(5.5M baseline up to 42.8M obs_ar_large).
- egomimic/algo/flat_fused_outer_stage.py: FlatFusedOuterStage class.
  Structurally a rename of FlatFusedPolicy with OuterStage inheritance
  plus an OuterStage forward(batch, ctx) dispatcher delegating to the
  existing forward_padded / forward_packed. Legacy generate / step /
  init_step_state preserved verbatim. encode / decode raise
  NotImplementedError since the interleaved 2T-token flow does not
  cleanly split along encode -> trunk -> decode.

- egomimic/algo/hnet.py: HNetFused is now a thin pass-through subclass
  of HNet, kept as a separate _target_ for the existing flat yamls.
  All flat-fused behavior moved into FlatFusedOuterStage; HNet.__init__
  already tolerates outer_stage.inner_stage=None.

- 3 flat yamls migrated to outer_stage schema:
  hnet_pushshapes_fused.yaml, hnet_pushshapes_fused_lowlr.yaml,
  hnet_pushshapes_fused_pusher.yaml.

- scripts/test_hnet_yamls_load.py extended to cover all 10 H-Net
  configs. Verified on H200 alloc 8989249: 10/10 instantiate
  successfully (7 HNetOuterStage + 3 FlatFusedOuterStage). Param
  counts sensible.

Old FlatFusedPolicy class still in algo/hnet.py for now; cleanup of
unused legacy classes (HNetPolicy, FlatFusedPolicy) is a follow-up.
ElmoPA and others added 19 commits July 15, 2026 15:32
EMACallback: shadow of all float state (decay 0.9999) updated per train
batch, saved as ema_state_dict in every ckpt, restored on resume. Eval
opts in via ckpt_loading --use-ema (overlays EMA tensors; int buffers
stay live). VisualCore norm_layer=group swaps backbone BatchNorm2d ->
GroupNorm(C/16,C) (EMA-averaged weights + live BN stats mismatch — the
reason DP uses obs_encoder_group_norm). Default batch = byte-identical.
Launcher gains EXTRA env passthrough. Arm: bf_var4_ema (var4 recipe +
EMA + GN, callbacks=checkpoints_ema).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…ment via ObsEncoders cu stamp; nopre+EMA arms x2 crop scopes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…ment+coverage EMA candidate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…ssing escape-combo cell for the split family)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…de mirror of dp_noaug)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
… history); bf_nopre_win arm (L0 w16, L1 w8, apex full)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…ral vs 4+4; toward sym_w1 all-encode profile)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…ng bug; now verified E6+T2 / E7+T1 x2 levels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
…radNormLogger callback (per-top-module tree walk, read-only) in default callbacks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjuZ3zwWaCeQQigxiNuzGw
- RatioLoss now logs boundary_rate_{i}/avg_chunk_len_{i} + aggregate
  boundary_rate with chunk_stats_from_aux semantics (hard mask, forced
  starts count); frac_indecisive and avg_chunk_len_tok removed
- GradNormLogger deleted; per-layer grad norms come from the existing
  pl_model log_per_layer_grad_norms flag, now default-on in bf_prdec_abl
…h views) + PCA wheel-zoom/drag-pan/dblclick-reset
…be, multi-epoch html rebuild

- bf_chunkviz_nopre_bracket.sbatch: snap+export nopre_gmm 1499/1999/2499 (true-epoch verified) for organization-onset bracket
- bf_chunkviz_reverdict.sbatch: re-probe arms condemned pre-ep2000 (var4/sig_var4/ste/regoff/cvae) at their late ckpts
- bf_rebuild_orghtml.sbatch: rebuild nopre_gmm_organization.html across all 5 epochs
…en->frame step function smoothed to a heat gradient)
…ad w/ temperature (T=2/3 arms; calibrated-clamp router REMOVED: grad=0 beyond bounds) + SDP fix2 family (pusher_hold, w=0.001, adaLN-zero denoiser, r0 ladder) + prenet sr2 trio + diffusion heads + thin ckpts + chunkviz full-precision + abslogit saturation telemetry. prev-action proprio REMOVED (action-copying risk)
…n fast weights, per-episode reset, tanh-gated ~identity at init; DualTrunkLevel ttt knob; arms fix2ada+ttt (H-Net) and txar_sdpada(+ttt) flat pair
… TTT arms NaN'd); SDPHead detach_offregime + FlowHead denoiser_arch knobs; new arms dpclone_flow/ddpm_regoff499/sdp_srfix/sr2c15/sr2opt; 250-cadence launcher
… selection

Video image storage (video_codec.py, _common.decode_video_span, zarr_writer,
zarr_dataset_multi): store frames as one mp4 per group of frames_per_chunk
instead of per-frame JPEG. Measured on real fold episodes: 44.8 -> 20.2 KB/frame
and 8.33 -> 1.68 ms/frame decode, and h264 also beats JPEG at EQUAL bytes
(+2.4..2.9 dB PSNR at 4-8 KB), so it is strictly better coding rather than a
quality-for-size trade. crf15/gop30/yuv420p; decode speed is flat across CRF, so
compressing harder would buy only disk.

Layout: chunk-indexed, NOT one blob per episode -- a span read would otherwise
pull the whole episode (~68 MB) and we read ~13 spans/episode. Frame f lives in
chunk f//fpc at offset f%fpc; chunks start on a keyframe (keyint_min=g,
sc_threshold=0) so each decodes independently. Readers dispatch on
_features[key]["dtype"] ("jpeg" vs "h264"), so existing episodes are untouched
and both formats coexist. decode_video_span returns the same (T,3,H,W) float
array as decode_jpeg_window, so pack_collate/TargetBuilder need no change;
equivalence-gated against the JPEG path on the same episode (38.6-39.1 dB,
including a chunk-boundary span and the partial tail chunk) and stride parity
matches decode_jpeg_window_strided.

annotation_processing.py: port of the role-in-key-name scheme -- role lives in
the zarr key NAME (annotations_task/annotations_subtask), entries are plain
{text,start_idx,end_idx} spans. _load_annotations is now per-key (the previous
single-slot cache aliased across keys) and an absent key degrades to [] rather
than raising.

zarr_dataset_packed: annotation_key + span_indices, so a run can read a
structural segmentation key (fold_segments) and select individual spans. Note
fold_segments deliberately sits OUTSIDE the annotations* glob -- those spans
define training windows, not language, and must not reach the batch as a text
role.

fold_span_transforms: RH_WRIST_MODE=none -> 126-dim keypoints-only (the wrist
pose is redundant with the keypoints). ypr(138)/pos(132) unchanged; default
still pos.
ZarrDataset dispatched purely on features[key]["dtype"]. That is a claim, and a
stale claim routes real mp4 payloads into simplejpeg -- which fails deep in the
decoder, far from the actual cause. Episodes converted before the codec switch,
or whose metadata was copied from a sibling, could not be loaded at all.

_classify_image_keys now uses three signals, cheapest first, escalating only on
disagreement:

  1. the declared dtype;
  2. the element count -- per-frame JPEG stores one element per frame, chunked
     video one mp4 per frames_per_chunk, so a full-length array is JPEG and a
     much shorter one is video. Compared with ">=" because writers pad past
     total_frames (a 290-frame episode occupies 300 slots);
  3. the magic bytes of element 0, read only to break a tie, and authoritative
     when read.

Verified on 72 real episodes across 6 datasets: identical classification and
ZERO payload reads, so the common path costs nothing.

Also fixes two gaps this exposed:

* video keys with no "video" metadata block now recover frames_per_chunk by
  decoding chunk 0. It is not derivable arithmetically -- 1000 frames over 4
  chunks admits any fpc in (250, 333].
* ZarrDataset.__getitem__ had NO video branch, only _read_span did, so a
  detected video key hit a frame-indexed read of a chunk-indexed array and
  returned the wrong elements. It now mirrors _read_span, matching
  decode_jpeg_single for horizon=None and _pad_sequences at the episode tail.

Mismatch warnings are deduped per (key, declared, detected) so a systematically
mislabelled dataset logs once, not once per episode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012V58H37tmcvgDthELMd5Xk

ElmoPA commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude Code Review

Review

Summary

Replaces trust-the-dtype dispatch in ZarrDataset with a three-signal detector (declared dtype → element count → magic bytes), and closes two follow-on gaps: frames_per_chunk recovery when the video block is missing, and a missing video branch in __getitem__. Sensible and well-motivated fix for a real load failure mode.

Key concerns

  1. Class-level mutable state for mismatch dedup. _ENCODING_MISMATCHES: set = set() is a class attribute shared across all ZarrDataset instances and across DDP workers. Under Lightning DDP with multiple dataloader workers, each worker process gets its own copy (fine for dedup within a process), but the set will persist and grow for the process lifetime. Not a real leak (bounded by |keys| × |declared| × |detected|), but worth a comment. More importantly: no lock. If dataset construction ever moves to threads, the if sig not in seen: seen.add(sig) is racy. Low risk given current usage, but flag it.

  2. by_count disagreement with declared, but sniff returns None → verdict silently flips. In the branch:

    elif not sniffed and by_count and by_count != verdict:
        verdict = by_count

    This overrides declared dtype based purely on element count when bytes couldn't be sniffed (e.g. arr[0] returned something _as_bytes couldn't unwrap). Count is a heuristic — a truncated or malformed array could easily satisfy n < total_frames. Consider logging when this fallback fires; right now it silently reclassifies with no diagnostic.

  3. self.total_frames assumed populated. _classify_image_keys is called from init_episode and reads self.total_frames. Confirm this is set on self before this call (the diff doesn't show it). If it's only in metadata, use self.metadata.get("total_frames") explicitly to avoid an AttributeError on some episodes.

  4. arr[0] on a video array reads and decompresses an entire mp4 chunk just to sniff 8 bytes. The docstring claims "ZERO payload reads on the common path" — true when count and declared agree, but any disagreement (including legitimately mislabeled episodes, which is the whole point of this PR) triggers a full chunk fetch. Consider reading a bounded byte slice if the zarr codec allows, or at minimum note this cost in the docstring. Not blocking.

  5. __getitem__ video branch — end_idx bound not clamped. _read_span presumably clamps against total_frames before calling decode_video_span; the new branch computes end_idx = self._chunk_end_idx(idx, horizon, key_type) and hands it straight to decode_video_span. If _chunk_end_idx returns something past the episode, does decode_video_span handle it? Please verify parity with _read_span — the comment says "mirrors _read_span" but I'd like to see them side by side in a test.

  6. _pad_sequences behavior parity. Inline padding via np.repeat(span[-1:], ...) — confirm _pad_sequences does exactly this (repeat last frame, not zero-pad, not edge-reflect). If _pad_sequences ever changes, these two paths silently diverge. Prefer calling _pad_sequences directly.

Suggestions

  • Call self._pad_sequences(span, horizon) (or whatever the existing helper is named) instead of duplicating the pad logic in __getitem__.
  • Add a unit test with three fixtures: (a) episode declaring jpeg with jpeg bytes, (b) declaring jpeg with mp4 bytes (the bug this PR fixes), (c) declaring h264 with no video metadata block. Assert classification and that __getitem__ returns correctly-shaped frames for horizon=None and horizon>1.
  • Add a test for the 1-frame-episode ambiguous case — the code correctly punts to sniffing, but that path should be pinned.
  • Log at INFO when frames_per_chunk is recovered via frames_per_chunk_from_data (the inferred=True flag is set but nothing surfaces it). Silent recovery is exactly the pattern this PR is trying to move away from.
  • The _as_bytes unwrap loop terminates on len(blob) for ndarrays but doesn't guard against a 0-d ndarray whose .item() returns another ndarray (unlikely but zarr VLenBytes has surprised us before). Add a max-depth guard.
  • Docstring on _classify_image_keys says "keeps its declared classification" for unreadable payloads — verify: in the code, if arr is None or n is None, we fall through to if verdict == "h264" using the declared value, which is correct. Good.

Verdict: Request Changes

The core detection logic is sound and the bug is real, but I want (1) test coverage for the mp4-declared-as-jpeg case and the __getitem__ video branch, (2) _pad_sequences reuse rather than inline duplication, and (3) a log line when count-based reclassification fires without sniff confirmation. After those, happy to approve.


Reviewed by Claude · Review workflow

@ElmoPA
ElmoPA changed the base branch from rldb/video-and-annotations to graphite-base/543 August 7, 2026 10:11
@ElmoPA
ElmoPA force-pushed the graphite-base/543 branch from 9cb385b to 95cfd61 Compare August 7, 2026 10:11
@ElmoPA
ElmoPA changed the base branch from graphite-base/543 to main August 7, 2026 10:11
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.

1 participant