Skip to content

A full experiment

The capstone. Everything below happens the way a real study does: a question is stated, a matrix of runs answers it, the answer is read with discipline, one cell is reproduced exactly, and the winning policy leaves the experiment as an artifact — exported, served, and scored again in inference. Every command and number on this page is executed and verified when the documentation is built.

flowchart LR
    Q["question"] --> M["sweep matrix<br/>families × seeds"]
    M --> T["traces<br/>one per cell"]
    T --> A["aggregate<br/>cross-family table"]
    T --> E["eval<br/>per-run deep dive"]
    M --> B["export<br/>policy bundle"]
    B --> S["serve<br/>inference trace"]
    S --> E

The question

Does mutation scale matter when the world shifts underneath the policy? The L4 comparison put steady (noise 0.3) against bold (0.8) at three seeds and honestly could not tell them apart. This experiment widens both axes: a third family — cautious, noise 0.05, mutations twenty times smaller — and five seeds instead of three.

The environment is L2's shifting world, byte for byte:

# file: windy.py
"""CartPole whose gravity shifts on a fixed period, with a penalty for dropping the pole."""
import gymnasium as gym


class Windy(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

    def _apply(self) -> None:
        phase = (self._count // self._period) % 3
        self.unwrapped.gravity = self._windy if phase == 1 else self._calm

    def reset(self, *, seed=None, options=None):
        self._apply()
        return super().reset(seed=seed, options=options)

    def step(self, action):
        self._apply()
        self._count += 1
        observation, reward, terminated, truncated, info = super().step(action)
        if terminated:
            reward -= 5.0
        return observation, reward, terminated, truncated, info


def make(**kwargs):
    return Windy(**kwargs)

The matrix

# file: sweep.yaml
base:
  env:       {id: "windy:make", kwargs: {period: 200}}
  algorithm: {name: discrete_hill_climbing, params: {seed: "${seed}"}}
  rollout:   {window_steps: 100, total_steps: 600, auto_reset: true}
  regime:
    provider: scheduled
    params: {schedule: {regimes: [
      {regime_id: calm, duration_steps: 200},
      {regime_id: windy, duration_steps: 200},
      {regime_id: calm_again, alias_of: calm, duration_steps: 200}]}}
  run: {seed: "${seed}"}
families:
  steady:   {}
  bold:     {algorithm: {params: {seed: "${seed}", noise: 0.8}}}
  cautious: {algorithm: {params: {seed: "${seed}", noise: 0.05}}}
seeds: [1, 2, 3, 4, 5]
output_dir: runs
skyfall-crl run --matrix sweep.yaml --dry-run
15 run(s):
  steady-seed1  algorithm=discrete_hill_climbing  seed=1  task=--  steps=600  trace=runs/steady-seed1/traces.jsonl
  steady-seed2  algorithm=discrete_hill_climbing  seed=2  task=--  steps=600  trace=runs/steady-seed2/traces.jsonl
  ...

Fifteen cells, each an ordinary run with its own trace. Run and aggregate:

skyfall-crl run --matrix sweep.yaml --plugin windy.py
skyfall-crl aggregate --traces runs --baseline steady
Cross-family comparison

15 trace(s) grouped by algorithm_id: bold (5 seeds), cautious (5 seeds), steady (5 seeds)
Advantage measured against: steady

  Metric                                   bold           cautious               steady
  -------------------------------------------------------------------------------------
  Per-configuration reward  0.786667 ± 0.134578   0.705 ± 0.242069  0.776667 ± 0.139866
  Adaptation speed                        1 ± 0      2.6 ± 3.57771                1 ± 0
  Forgetting                   -0.09 ± 0.137614  -0.065 ± 0.188414      -0.1 ± 0.153093
  Recovery time                   2.3 ± 2.90689      4.9 ± 3.57771          4 ± 4.47214
  Stability (variance)       1.00494 ± 0.614763  1.34158 ± 1.07531   1.04858 ± 0.638103
  Settled reward            0.808333 ± 0.133723   0.725 ± 0.261207  0.791667 ± 0.164042
  Zero-shot reward              0.85 ± 0.136931     0.7 ± 0.273861       0.9 ± 0.136931
  Return (discounted)         72.5597 ± 21.6945  68.0075 ± 25.6544     71.688 ± 21.9299
  Return (undiscounted)           472 ± 80.7465      423 ± 145.241        466 ± 83.9196

The answer, read with discipline

Three findings, in decreasing order of confidence:

  1. The continual-learning metric separates where the return does not. steady and bold adapt in one step, every seed — 1 ± 0. cautious reports 2.6 ± 3.6: some of its seeds also snap back, and some take seconds of simulated time to re-find their footing after the gravity shift, because mutations twenty times smaller sometimes cannot climb out of what the shift broke. The plain returns (472 vs 423 vs 466, spreads ~±100) overlap far too much to say any of that. This is the protocol's reason to exist: behaviour around change is visible where aggregate return is noise.
  2. cautious trends worse on every column — reward, recovery, stability, zero-shot — but trends are all they are; each mean sits inside its neighbours' spreads.
  3. steady versus bold remains a tie at five seeds, as it was at three. An honest table says so.

The cautious family's ±0.24 on reward is itself worth a look, and one command drills into a single cell:

skyfall-crl eval --traces runs/cautious-seed5
1 trace(s), 3 configuration(s)

  Metric                     segment
  ----------------------------------
  Per-configuration reward  0.466667
  Adaptation speed                 1
  Forgetting                       0
  Recovery time                    8
  Stability (variance)       2.39405
  Performance gap                 --
  Zero-shot reward               0.5
  Regret                          --
  Effective rank                  --
  Settled reward            0.458333
  Return (discounted)         49.381
  Return (undiscounted)          280
  Invalid-action rate              0

Seed 5 earned 0.47 per step against its family's 0.705 mean: this seed's policy never coped at all, in any interval. The family's wide spread is not diffuse noise but two sub-populations — seeds that adapted and seeds that never did — which a mean ± spread can hint at and only a per-cell look can show. That is what the per-run tables are for.

Reproduce a cell exactly

A result that cannot be re-run is an anecdote. Every cell here is deterministic given its document — the seed reaches the environment and the policy — so reproducing one is running it again:

# file: best.yaml
env:       {id: "windy:make", kwargs: {period: 200}}
algorithm:
  name: discrete_hill_climbing
  params: {seed: 1, noise: 0.8}
  policy:
    id: skyfall_crl.train.backends.hill_climbing:linear_menu_policy
    kwargs: {n_actions: 2, seed: 1}
rollout: {window_steps: 100, total_steps: 600, auto_reset: true}
regime:
  provider: scheduled
  params: {schedule: {regimes: [
    {regime_id: calm, duration_steps: 200},
    {regime_id: windy, duration_steps: 200},
    {regime_id: calm_again, alias_of: calm, duration_steps: 200}]}}
run: {seed: 1, trace_path: best.jsonl}

This is the bold, seed-1 cell written out as a standalone document — with one addition: the policy is named rather than left to the algorithm's internal default, because the export below must record how to rebuild it.

skyfall-crl run --config best.yaml --plugin windy.py --export bundle
from pathlib import Path

from skyfall_crl.train import ExperimentConfig, run_experiment

config = ExperimentConfig.from_yaml_file("best.yaml")
config.run.trace_path = "again.jsonl"
run_experiment(config)

first = Path("best.jsonl").read_text().splitlines()
second = Path("again.jsonl").read_text().splitlines()
print(f"rows: {len(first)} and {len(second)}, identical: {first == second}")
rows: 600 and 600, identical: True

Six hundred rows, byte-identical. On this toy nothing was left unseeded; with the ML backends two draws are not seeded by default and the scaling section below sets them explicitly.

The policy leaves the experiment

--export above wrote bundle/: a manifest recording the environment, the observation and action spaces, the policy's constructor and weights format, and the run's provenance — beside the checkpoint itself. Serving it back needs nothing from this page but the environment file:

skyfall-crl serve --bundle bundle --steps 200 --auto-reset --seed 7 --traces served.jsonl --plugin windy.py
200 steps, total reward 190, stationary -> served.jsonl
skyfall-crl eval --traces served.jsonl
1 trace(s), 1 configuration(s)

  Metric                     segment
  ----------------------------------
  Per-configuration reward      0.95
  ...

The served policy earns 0.95 per step over 200 inference steps — one early fall, then flawless — with nothing updating, the same trace format, the same scorer. Three details are worth noticing. --seed 7 makes the served run repeatable; inference against an unseeded environment is legitimately different every time, and a scored inference run should not be. --auto-reset is needed here for the same reason it was in training: CartPole genuinely ends episodes, and serving must say so explicitly because against a persistent world an automatic reset would be destructive. Finally, the served run reports one configuration, and the reason is worth getting right: the bundle recorded the training run's schedule, and serve replays it by default so a served trace segments exactly as a training trace does — but 200 inference steps sit entirely inside the schedule's first 200-step calm interval, so one segment is what there is. Serve longer and the shifts return; pass --stationary to opt out of the recorded schedule deliberately.

Scaling this to the ML backends

Everything above is the full workflow at toy scale. Scaling it to the shipped language-model backends changes the contents of the documents and none of their shape:

base:
  env:       {id: "yourpkg.envs:make"}
  rollout:   {window_steps: 512, total_steps: 100000}
  run:       {seed: "${seed}"}
families:
  ppo:
    algorithm:
      name: ppo
      params: {seed: "${seed}", replay_seed: "${seed}"}
      policy:
        id: skyfall_crl.train.backends.policy:language_policy
        kwargs: {model: ./sft-checkpoint}
  ppo_ewc:
    algorithm:
      name: ppo_ewc
      params: {seed: "${seed}", replay_seed: "${seed}", fisher_seed: "${seed}"}
      policy:
        id: skyfall_crl.train.backends.policy:language_policy
        kwargs: {model: ./sft-checkpoint}
seeds: [1, 2, 3, 4, 5]
output_dir: runs

What changes: the backends (ppo, ppo_ewc, ppo_lcm) need the [train] extra, a GPU worth using, and a policy built from a real checkpoint; replay_seed and fisher_seed must be stated because the ported algorithms deliberately do not seed those draws themselves; windows are hundreds of steps and totals are days, not seconds. What does not change: the document's shape, --dry-run before committing the compute, the aggregate table and its discipline, the per-cell deep dive, the reproduction check, and the export. That is the point of the ladder — the workflow you just ran is the workflow.

What this page did not claim

The finding is real but the world is a toy: nothing here was trained to convergence, and a hill climber on CartPole earns no publication. What the capstone demonstrates is the experiment machinery — question to matrix to disciplined answer to reproducible, servable artifact — so that when the environment is one that matters, the machinery is already yours.