Repository navigation
fix(controller): give each SmoothingController its own EMA state - #297
Conversation
The smoothing state lived under one module-level store key, so two SmoothingController instances in the same trial overwrote each other and each smoothed against the other's last write. The result is neither layer's intended output, with no crash or warning. Namespace the key per instance. _INFER_KEY stays shared because it is only appended to; this was the one controller state written destructively. Fixes robocurve#296
jeqcho
left a comment
There was a problem hiding this comment.
Thanks for a really well-executed fix, and for the unusually honest PR write-up — the note about your first test passing against the broken code (constant-stream corruption being invisible) is exactly the kind of detail that saves the next person a wasted afternoon.
What I checked:
- The bug is real.
_SMOOTH_KEYatsrc/inspect_robots/controller.py:69is a single module-level key, read and destructively written innext_action(lines 93–95 pre-fix), so two stackedSmoothingControllers share one EMA slot. I reproduced the arithmetic in the issue (#296) against the code and it holds: from step 2 on, each layer smooths against the other's last write. Given the class docstring explicitly advertises middleware composition, fixing rather than forbidding nesting is the right call. - The fix is correct and minimal. Per-instance key minted at construction (
f"{_SMOOTH_KEY}:{next(_smooth_serial)}") keeps state trial-local via the store rather than moving it ontoself— important, since the store is created fresh per trial (rollout.py:267), so a reused controller instance still gets a clean EMA each trial. Storingprevon the instance would have leaked state across trials; this shape avoids that. I also confirmed the store is never persisted toEvalLog, so the process-local serial comment is accurate, and the counter-over-id(self)choice is sensible for readable dumped stores. - The test bites.
test_stacked_smoothing_controllers_keep_separate_stateintests/test_controller.pyuses a varying action stream (chunk length 1, soDefaultControllerre-infers every step) and compares against an independently computed nested EMA — the failure output against unfixed code in the PR body confirms it would have caught the regression. - Hygiene. All CI checks green including the 100% coverage gate, mypy strict over tests, and the branch is mergeable. The contrast note about
_INFER_KEY/_BUFFER_KEYbeing append-only (and therefore safe to share) is a genuinely useful comment for future controller authors.
One non-blocking observation: a copy.deepcopy/pickle of a SmoothingController would duplicate its store key and reintroduce sharing between the copies. Nothing in the codebase does that today, so I don't think it needs handling — just flagging it in case controller cloning ever becomes a pattern.
Also appreciated the deliberate CHANGELOG anchor choice so the conflict lands on your own #290 rather than #251. LGTM.
Resolve CHANGELOG conflict: keep both the SmoothingController entry (robocurve#296) and the duplicate scene id entry (robocurve#289) under Unreleased > Fixed. Co-Authored-By: Claude Fable 5 <[email protected]>
jeqcho
left a comment
There was a problem hiding this comment.
Re-approving after branch update -- CI green on the updated head.
Fixes #296.
What was wrong
SmoothingControllerkept its EMA state in the per-trial store under a singlemodule-level key (
controller.py:69), read and written destructively(
controller.py:93-95). Two instances active in the same trial therefore sharedone slot: each layer smoothed against whatever the other last wrote, so both
emitted something that is neither layer's intended output — silently, with no
crash or warning.
The class docstring presents this exact composition as the point of the design
("Demonstrates the middleware composition the single-method interface enables"),
and nothing warns against stacking two.
Stacking
alpha=0.9overalpha=0.5on the stream[1, 5, 2, 8, 3]:One key for two controllers, diverging from the second step on.
The fix
Namespace the key per instance via a module-local serial. The contrast that makes
this the right shape:
_INFER_KEYis safe to share because it is only everappended to (
controller.py:65,controller.py:159), soDefaultControllerand
EnsemblingControllercoexisting merely add entries._SMOOTH_KEYwas theonly destructively written controller state, which is exactly why sharing it broke.
I used a counter rather than
id(self)so the key is deterministic and readable ina dumped store.
Tests
test_stacked_smoothing_controllers_keep_separate_statestacks two layers andcompares against the independently computed nested EMA. Against unfixed code:
Worth noting explicitly: my first attempt at this test passed against the broken
code, because I drove it with
ScriptedPolicy, whose oracle emits near-constantsteps — with a constant stream the corruption is invisible. The test needs a
varying action stream, which is why it uses a small local stub policy. I mention it
because it is the kind of regression test that would otherwise look fine and assert
nothing.
Verification
CHANGELOG conflict, deliberately
The
### Fixedinsertion points are all currently claimed by open PRs, so thisentry shares an anchor with my #290 — those two conflict in CHANGELOG.md and
nothing else. I first placed it where it collided with #251, which is approved
and waiting, and moved it specifically so the collision lands on my own newer PR
instead. Verified clean against #250, #251, #256, #286, #288, #293, #295.
Merge #290 or this in any order and I'll rebase the other immediately.
What I deliberately left out
SmoothingControllerreject an innerSmoothingControllerwould also close the bug and is a smaller behaviouralsurface; I went with isolating state because the docstring advertises
composition. If you'd rather it refuse, say so and I'll swap it.
_INFER_KEYor_BUFFER_KEY. They are shared safely for thereason above, and touching them would be scope creep.
by reading for state-isolation bugs, not from a failing run. It is latent. I
think it earns a fix because it is silent and produces wrong actuation, but it is
not urgent, and I would understand closing it as won't-fix.