Instrumenting to Tier 2¶
The windy world changes, but its reward is still nearly binary — upright pays 1, a fall pays once — so adaptation and recovery had almost nothing to resolve. This rung instruments the same environment to Tier 2: it will describe every step, score itself through a composed reward specification, label its own trace, and report its provenance. The measurements sharpen immediately, and the duplication L2 warned about disappears.
The reward, as a file¶
One component, reward-as-data style. The same file will score the run and derive its ceiling — one source of truth for what the experiment optimises.
The instrumented environment¶
# file: windy2.py
"""The windy environment, instrumented to Tier 2: it labels and describes every step."""
import gymnasium as gym
from skyfall_crl.rewards import (
Cadence,
CompositionEngine,
RewardComponent,
RewardSignal,
RewardSpec,
StepContext,
register,
step_from_result,
)
REGIMES = ("calm", "windy", "calm_again")
class Upright(RewardComponent):
"""How vertically the pole is held: 1 upright, 0 at the failure angle."""
name = "upright"
cadence = Cadence.STEP
reads = ("extras",)
clip = (0.0, 1.0)
default_weight = 1.0
def compute(self, ctx: StepContext) -> RewardSignal:
return self._signal(float(ctx.extras.get("upright", 0.0)))
register("upright", Upright, overwrite=True)
SPEC = RewardSpec.from_yaml_file("balance.yaml")
class WindyDescribed(gym.Wrapper):
def __init__(self, period: int = 200, calm: float = 9.8, windy: float = 18.0):
super().__init__(gym.make("CartPole-v1"))
self._period, self._calm, self._windy = int(period), float(calm), float(windy)
self._count = 0
self._engine = CompositionEngine(SPEC)
def _phase(self) -> int:
return (self._count // self._period) % len(REGIMES)
def _apply(self) -> None:
self.unwrapped.gravity = self._windy if self._phase() == 1 else self._calm
def reset(self, *, seed=None, options=None):
self._apply()
self._engine.reset()
return super().reset(seed=seed, options=options)
def step(self, action):
self._apply()
regime = REGIMES[self._phase()]
boundary = self._count % self._period == 0
observation, _, terminated, truncated, info = self.env.step(action)
upright = max(0.0, 1.0 - abs(float(observation[2])) / 0.2095)
ctx = StepContext(
step=self._count,
regime_id=regime,
regime_origin="calm" if regime == "calm_again" else None,
extras={"upright": upright},
)
scored = self._engine.compute_step(ctx)
info["regime"] = {"id": regime, "boundary": boundary}
info["trace"] = step_from_result(ctx, scored)
self._count += 1
return observation, scored.total, terminated, truncated, info
def describe(self):
return {"environment": "windy-described", "period": self._period,
"reward_spec": SPEC.name}
def make(**kwargs):
return WindyDescribed(**kwargs)
Walk it against the Tier-2 contract — three additions, one per capability:
- The step record. Each step builds a
StepContext— here just the pole's uprightness (1 vertical, 0 at the failure angle) inextras, the seam meant for exactly this — has the engine score it, and puts the resultingEpisodeStepininfo["trace"]. The environment's reward is the composition's total, so what the trace explains and what the policy earned cannot disagree. - The regime report.
info["regime"]carries the label and a boundary flag, and theStepContextcarries the label and its origin onto the trace. The environment now owns its own labels — which is why the configuration below has noregime:section at all. The L2 duplication is gone. - Provenance.
describe()says what this run was configured with; it travels into the run's record and, later, into an exported policy's manifest.
Run it¶
# file: experiment.yaml
env: {id: "windy2:make", kwargs: {period: 200}}
algorithm: {name: discrete_hill_climbing}
rollout: {window_steps: 100, total_steps: 600, auto_reset: true}
run: {name: described, seed: 0, trace_path: traces.jsonl}
skyfall-crl run --config experiment.yaml --plugin windy2.py
skyfall-crl eval --traces traces.jsonl --spec balance.yaml --plugin windy2.py
eval now takes the spec too — the same file — and derives the ceiling from it. The --plugin
matters there as well: deriving a ceiling resolves the components the spec names.
1 trace(s), 3 configuration(s), reward spec 'balance'
ceiling (analytic): 1 per step, 1 on the episode's last
Metric analytic
-----------------------------------
Per-configuration reward 0.698133
Adaptation speed 57
Forgetting 0.0885529
Recovery time 3
Stability (variance) 0.0786697
Performance gap 0.283771
Zero-shot reward 0.692865
Regret --
Effective rank --
Settled reward 0.716229
Return (discounted) 80.4604
Return (undiscounted) 418.88
...
What the instrumentation bought¶
Compare this against L2's table, same world, same algorithm:
- Adaptation speed 1 → 57. Not a worse policy — a reward that can finally see. Uprightness
is continuous, so the running mean climbs as the policy genuinely re-learns the doubled
gravity, and crossing 90% of its eventual level takes 57 real steps. The
1was the measurement saturating; the57is the measurement working. - Forgetting flipped sign: −0.175 → +0.0886. Under the graded reward, the revisit is slightly worse than the first encounter — genuine, mild forgetting the binary reward could not detect.
- Performance gap 0.284, at last. The spec's one component clips to
[0, 1]at weight 1, so the derived ceiling is 1 per step — and the settled state of 0.716 leaves a measured 0.284 on the table. No hand-supplied bound involved. - Every trace row now carries the breakdown.
reward_components: {upright: ...}on each step — one component here, but the same rows attribute every future spec term by term.
Check it, then trust it¶
The contract has a checker, and an instrumented environment should pass it before anything is built on top:
from skyfall_crl.conformance import check_environment, render
import windy2
print(render([check_environment(windy2.make, tier=2)]))
environment (tier 2): make
--------------------------
ok declares an observation space and an action space
ok reset() returns an observation inside its space
ok step() returns a well-formed transition
ok releases what it holds when closed
ok the same seed starts the same way
ok describe() reports what the run was configured with
ok reports the active configuration in info
ok emits an EpisodeStep for every step
ok the configuration is not visible in the observation
The last line is the one the whole benchmark rests on: the label reaches the trace and never the
observation. From the shell, the same check is skyfall-crl conformance --env windy2:make --tier 2
--plugin windy2.py. With a conforming Tier-2 environment in hand, one run stops being the
interesting unit — comparing algorithms is next.