Changelog¶
All notable changes to the tutorial code and its documentation are recorded here. The two are versioned together — a code change ships with the matching docs update in the same version.
The version lives in the repo-root VERSION
file and is shown in the docs header.
Versioning policy¶
We use a SemVer-style MAJOR.MINOR.PATCH:
| Bump | When | Who |
|---|---|---|
PATCH 0.0.0 → 0.0.1 |
Docs edits, small fixes, refactors with no API change. | Routine, applied with the change. |
MINOR 0.0.0 → 0.1.0 |
New runnable feature (e.g. Crippled-Ant wrapper, Hydra sweep, action masking) or an API change. | Routine, applied with the change. |
MAJOR 0.x → 1.0.0 |
"Everything works and is tested." | Granted by Leander only. |
While the project is pre-1.0.0, expect things to move.
[Unreleased]¶
Nothing outstanding.
[0.35.0] — 2026-09-15¶
Added — random is an agent the harness knows¶
competition.run accepted noop, rule-based or a checkpoint path while
scripts/score_4d.py also accepted random, so --agent random on the harness
fell through to PPO.load("random") and died on No such file: random.zip.
agents.RandomLegalController now ships beside the brick — uniform over the
mask read off the observation, never an invalid action, seeded from each
episode's first observation so its row is reproducible and its deterministic
and stochastic post-mortem passes are the same pass. The four names are now
the same in both tools, and an unknown name says so instead of hunting for a
zip. On the 100 public seeds: 76 failed, 667 congestion, exit_miss 0.701,
2947 clearances.
[0.34.0] — 2026-09-15¶
Fixed — the scored environment really is ours now¶
competition.ingest puts a submitted zip's root first on sys.path before it
imports competition.rollout, and rollout bound its scored environment with
a plain from envs import Flight4DEnv. For any zip that shipped an envs/
package — i.e. exactly the submissions the lockstep design exists for — that
resolved to the team's copy, so every KPI on the private board would have
been read from an environment the team wrote. The generic contract suite
does not compare against our physics, so a rewritten _count_congestion
sailed through the gate. Found while checking the deck's "only the action
index crosses the boundary" claim against the code.
competition/_paths.py now exposes reference_envs(), which loads the
repository's own envs by file path under a private module name;
rollout.make_env and submission.N_CLEARANCES read from it, so nothing on
sys.path can shadow the scored environment. The existing test built its
submission in-process and never hit the import path;
test_a_shipped_envs_package_cannot_change_what_is_scored now runs ingest
in a fresh process on a zip whose environment reports zero congestion, and
asserts it scores what the stock environment says.
Added — competition.ingest --out submission.csv¶
competition.run submit rolls a checkpoint out on the stock environment, so a
team that redesigned its observation had no route to the public board short
of writing its own loop. ingest --out writes the action trace of a zip flown
in lockstep — the team's environment observed, ours scored — on the public
seeds, padded to the full horizon as Kaggle requires. Refused with
--private, since a private trace cannot be uploaded anywhere.
Changed — the participant docs now carry the whole pipeline¶
The Kaggle page lists every flag of competition.run
and competition.ingest in one table, the custom-environment route, and the
team/submission limits; the rules gain a
step-by-step "build the zip" and state the policy explicitly: Kaggle is how
you submit, the zip is how we check — if the top of the board is close, the
teams involved are asked for code and zips.
[0.33.0] — 2026-09-14¶
Added — two new Session #3 decks, and the action vocabulary one clip at a time¶
slides/advanced-intro.tex (ninety minutes) and slides/advanced-challenge.tex
(twenty to thirty) replace the advanced + litmus pair for the session
without removing either — both older decks still build unchanged, and the
Makefile now knows all five.
The intro deck opens with why this problem: Eurocontrol's long-term
outlook (make_figures_intro.py, numbers transcribed from the cited
publication), the controller as the bottleneck, and the two SESAR 3 JU
projects the package grew out of — TADA and ASTRA — before stating plainly what
airtraffic removes from the real problem (routes, headings, pilot latency,
fuel) and the one secondary objective it keeps in place of fuel. The simulator
and MDP module then walks the constants, the conflict rule against the mid-air
rule, the observation, the mask, termination, the reward and the ranking; the
baseline policy gets its own frame — idle, or which aircraft; then which
clearance, the two-stage choice the environment sees as one flat index; the
litmus module introduces Priority4DController pedantically (assign, steer,
come home; why it refuses the macro; its one knob); and the last module is
about what the current solution still gets wrong — with the first-generation
measurements (the injection probe, the macro training run) labelled as such
rather than passed off as Flight4DEnv results. The old appendices survive,
grouped, under \appendix.
scripts/render_4d.py gained --n-flights and eight single-clearance
demos (fl_inc, fl_dec, fl_inc2_t, fl_dec2_t, spd_dn_t, spd_up_t,
resume, plus noop), all on seed 2 at n_flights=2 — the same converging
pair as the handbook's three-resolution demos, with the bystanders removed —
each issued to aircraft 0 at step 14. Their outcomes are on
the environment page
as GIFs and a table, in the deck as clips, and pinned by
tests/test_render_4d.py so the numbers cannot drift silently.
Fixed — the render's KPI-bar labels¶
The safety and timeliness bars' labels were rotated y-labels on sliver-wide axes, so they landed over the main plot on one side and over the neighbouring bar on the other. They are now x-labels under each bar, with the tick labels pushed outward (safety reads on its left, timeliness on its right). Every committed GIF and staged poster/filmstrip was regenerated.
Also noticed while building: a Beamer frame whose body starts with a brace
group ({\scriptsize ... \begin{tabular}) has that group parsed as the frame
subtitle, and the table vanishes without a warning. The older
advanced.tex has one such frame (Pick one track); it is left as it was, per
the decision not to touch the old decks, but the new decks open those groups
with \begingroup instead and slides/README.md now says why.
[0.32.1] — 2026-09-04¶
Added — GitHub Pages plumbing, and the competition's join link¶
.github/workflows/docs.yml builds the handbook with mkdocs build --strict
and deploys it through actions/upload-pages-artifact /
actions/deploy-pages — GitHub's "Deploy from GitHub Actions" Pages mode,
the same one rl-bootcamp-setup already uses. No gh-pages branch is ever
written to; going public later is a two-step flip (repo visibility, then
Pages source in Settings), not a new deploy mechanism. Dormant until both
are set — until then it just double-checks the handbook builds on every push.
The 2026 competition's join link (https://www.kaggle.com/t/a0a74b5818114812a2b076b172dfe197) is now on the Kaggle leaderboard page, which is also where the link belongs — everywhere else that sends someone toward Kaggle already links to that page rather than repeating the URL.
Changed — sharper on why the trophies aren't scored on Kaggle¶
The public/private split was explained as "different seeds" in three places
(kaggle.md, competition.md,
design-sprint.md) without saying why the private
run has to happen offline at all. It's not only the seeds: every zip can ship
its own environment code, and a shared Kaggle notebook has no way to run
twenty different envs/ packages without them colliding — the reason
score-zips already gives each zip its own
process. All three pages now
say so.
[0.32.0] — 2026-09-03¶
The Kaggle leaderboard is set up and verified end to end, the standalone syllabus files are gone, and every runnable thing in the repository is now listed in one place.
Fixed — every real submission would have been rejected by Kaggle¶
competition.run submit wrote one row per step actually flown, but the
solution file is exported at the full 50-step horizon and kaggle/metric.py
rejects a submission missing any (seed, step) row. Every real agent ends at
least one episode early — the brick ends nearly all of them in a mid-air inside
a dozen steps — so the refusal would have hit every submission, including
the reference ones.
Measured before the fix, against the 100 published seeds:
| agent | rows written | rows required | verdict |
|---|---|---|---|
| PPO 4M | 4 789 | 5 000 | refused, 211 missing |
| rule-based | 4 238 | 5 000 | refused, 762 missing |
| noop | 1 162 | 5 000 | refused, 3 838 missing |
write_submission now idles every trace out to max_steps. The padding is
score-neutral — replay_actions already idled through the tail, and
read_submission already filled missing cells with the idle action — so no
number changes; the file simply covers what it claims to.
The gap existed because every test built traces that happened to run the full
horizon. test_score_accepts_traces_from_episodes_that_ended_early closes it,
using the harness's own legal_traces, which stops when the episode does.
Added — docs/advanced/kaggle.md, and the dry run behind it¶
The competition was set up and scored locally, all the way through the real
metric file. Verified 2026-09-03, score v2.0.0, against the committed
ppo_4M_curriculum.zip:
| agent | failed | congestion | exit_miss |
clearances | touched |
|---|---|---|---|---|---|
| PPO 4M | 10 | 176 | 0.1288 | 2 089 | 477 |
| rule-based | 20 | 96 | 0.5700 | 727 | 231 |
| noop | 100 | 752 | 0.0000 | 0 | 0 |
Three things the dry run confirms, each of which would have broken the board:
the metric and the harness agree exactly on all four packed components for
all three agents; the metric never pulls torch into sys.modules; and handing
the metric one Usage split scores only that split's seeds.
The page carries both halves — how a participant builds and uploads a submission, and the organiser runbook for creating the competition.
Changed — the metric is a Kaggle notebook, and it now meets that contract¶
The custom metric is pasted into a Metric Notebook, not uploaded as a file,
and Kaggle enforces four things the repository cannot see: a function named
score, its first three parameters fixed, type annotations on every
argument, and a finite non-null float return. All four held already;
test_score_satisfies_kaggles_metric_notebook_contract now asserts them, so a
refactor cannot break one and have it surface on the leaderboard instead.
score()'s docstring is what a host sees when selecting the metric, capped
at 8 000 characters. It was one line. It now leads with the ranking and the
direction, documents the submission format, and carries twelve doctests — two
worked scores on a two-aircraft head-on sector, and two rejection messages. The
examples print their errors rather than raising, so they pass identically whether
the file is imported as a module or pasted into a cell, where the exception's
qualified name would differ. Verified in both contexts.
Three behaviours were measured rather than assumed:
Pass Complete Submissiondoes not matter. On or off, the score is identical to the last digit — the metric reads only the seeds present in the solution it was handed.- Row order does not matter. Kaggle aligns on the id column; shuffling the submission changes nothing, because the metric sorts internally.
- The 30-minute scoring limit is not close. 0.04 s for a 60-seed split.
Verified — on the real competition, not just locally¶
A sandbox submission of the shipped checkpoint returned
12012163199400.000 public and 28014722826928.000 private — exactly the
values computed locally, on both splits. One upload confirms the four things
that could each have failed silently: the metric notebook runs, the solution
file parses, the Usage split is applied, and the horizon padding satisfies
Kaggle's row check.
It also settles a question this repository could not answer: the board renders the packed key at full precision, so submissions differing only in the lowest-weighted component still show as different numbers. The open caveat about display rounding is removed rather than softened.
Changed — three numbers, not two leaderboards¶
competition.md described a public Kaggle board and our private one. With
Kaggle's own Usage split there are three scores, and conflating two of them
would have been the confusing kind of wrong. The page now separates them and
says plainly that Kaggle's private split is not secret: every seed it scores
ships in seeds_public.txt.
It cannot be otherwise, and the reason is structural — a submission is an action trace keyed by seed, so producing rows for a seed requires knowing it. An action-trace competition cannot have secret seeds at all, which is exactly why the trophies are decided by the participant's zip on our machine. Kaggle's split hides the final rank from view; ours hides the seeds from training.
The one setting that matters is Leaderboard Sort Order, which defaults to higher is better and has to be turned off. It is flagged at the top of the organiser section and has a screenshot slot of its own.
The template's suggested del solution[row_id_column_name] must not be
copied: for this metric the id is the data — s0000042_t07 names the seed and
the step, and the replay cannot be reconstructed without both.
Added — --holdout-frac on the solution exporter¶
Kaggle wants a public/private split of its own, and the exporter could only mark
every row the same way. --holdout-frac 0.4 marks the back 40% of the exported
seeds Usage=Private, deterministically, so the file regenerates identically
without a split being recorded anywhere.
This is not the seed list that decides the trophies. --private exports
that one, and it never goes near Kaggle; the exporter's docstring now says so at
the top, because the two words meet in one file.
Added — docs/reference/tooling.md¶
Every runnable thing in the repository, grouped by session and marked with who
is expected to run it. Written because eleven scripts appeared nowhere in the
handbook — the whole Kaggle path, the post-mortem, make_seeds, and the entire
slide pipeline among them.
Writing it turned up that most of solutions/ no longer runs: several files
read SCENARIO.n and SCENARIO.m, which ScenarioConfig has not had since the
Flight4DEnv rewrite. The page says so rather than listing them as tooling.
Removed — tutorial/TUTORIAL_SYLLABUS_{ADVANCED,INTERMEDIATE}.md¶
Both files, and the two handbook pages that embedded them. The Advanced one
opened with a banner admitting it described a lecture structure that no longer
existed, and its body still referenced um_flight_env.py, a MultiDiscrete
action space, MaskablePPO, a five-tuple observation and an Easy/Medium/Hard
evaluation suite — none of which are true of Flight4DEnv.
What was worth keeping was rewritten against the current environment as the design sprint: the four tracks, the MDP pitfalls table, the sprint format and the deliverables. Track E (masking) and Track F (permutation equivariance) are not tracks any more — both ship as defaults — and the session guide already covered them.
Track D gained a correction the syllabus never had: changed dynamics are a training-only intervention, because the scored run uses our frozen simulator.
The mkdocs-include-markdown-plugin dependency went with them; those two pages
were its only users.
Changed — the Advanced deck's Kaggle walkthrough¶
Two leaderboards told participants to score the public board with
scripts/score_4d.py on seeds 900000–900099. The public board has not worked
that way since the harness landed: it replays a submission.csv built on
competition/seeds_public.txt.
Three appendix frames added — the three-command submission flow, the reference
rows on the board, and what a rejection looks like — with a new \shot macro
whose missing-file box names the screenshot to take. Body pages unchanged at 20;
the deck is 74 pages.
Added — the private board actually exists now¶
competition.md described unzipping a submission, loading the team's
environment class and running the contract suite. None of it had code. The
harness could score a bare SB3 checkpoint passed as --agent path.zip and
nothing else: no zip handling, no submission.yaml, no env_entry_point, and
rollout.make_env was hardcoded to our environment with the comment "Always
ours, never the student's" — which directly contradicted the published rules.
competition/ingest.py closes it, plus competition.run score-zips as the
batch runner. The design decision that makes it safe to run student code
against a ranking:
A team's environment supplies observations, never KPIs. Their env and ours run in lockstep on the same seed, and only the action index crosses. Their reward, their termination shaping and their info dict are never read. So a wrong environment cannot buy a better score — it feeds their policy worse information and scores worse, honestly earned. There is nothing to defend against and the code does not pretend to.
Verified three ways on the same seeds, all returning 4 / 38 / 0.1541 / 455 /
98: the pre-existing --agent checkpoint.zip path, a zip using the stock
environment, and a zip shipping its own environment whose reward is multiplied
by 1000 and offset by −7. The last one is the point — a mangled reward passes
the contract suite and changes nothing.
Also fixed along the way:
- Our env decides when the episode ends. A student env that terminates early no longer shortens the scored episode; the sector is idled to the horizon, so giving up stays a penalty rather than a way to stop accumulating congestion.
- One process per zip. Every submission ships a directory called
envs; importing two of them into one interpreter would silently give the second team the first team's code, and produce a plausible wrong number. - The contract suite can be aimed without editing
conftest.py.RLB_ENV_ENTRY_POINT=envs.my_env:MyFlightEnv pytest tests/test_env_contract.pyis what a team runs and what the harness runs. Getting the path precedence right needed both env vars set before anything importsenvs— once a package is insys.modules, path order stops mattering. - Rejections are actionable.
-xstops at the first failure and the harness reports the assertion that fired, not a count. "24 failed" tells a team nothing at a prizegiving.
Changed — the submission format matches the harness that reads it¶
inference.py is gone from the spec: the manifest says everything needed, so
there was no loader for a team to write and nothing read the file.
vecnormalize.pkl now produces a visible warning instead of being silently
ignored — Flight4DEnv emits observations already scaled, and the harness does
not wrap the scored env.
The trap worth naming: a submitted envs/ replaces ours on the import path,
so a file in it doing from envs.flight_4d import Flight4DEnv looks inside the
team's own package and fails. Copy the whole directory and use a relative
import. This is the most likely way for a submission to be rejected and it is
entirely avoidable, so it is called out in the rules with the command to check
it.
Added — docs/advanced/organisers.md, the runbook¶
Every competition command in the order an organiser runs it, across the three phases: seeds and Kaggle files before the session, the two things worth having ready during it, and the private board, post-mortem and video after it closes.
Then a complete flag reference — competition.run's global flags and all five
subcommands, make_seeds, kaggle.export, ingest, the four environment
variables, and which files are secret. Every flag in it was checked against
--help rather than transcribed from memory, which turned up make_seeds
--count and replay --strict as previously undocumented.
The split with the Kaggle page is deliberate: this one is what to type, that one is what to click.
Verified by running the documented Phase 3 end to end against a throwaway salt —
make_seeds --which private, then score-zips --private over two real zips on
100 genuinely unseen seeds. Both scored identically, which is the
reward-independence property holding on a second seed set.
Fixed — a missing private seed list blamed the teams¶
score-zips resolved the seed list inside each subprocess, so a host who had
not run make_seeds got the same FileNotFoundError repeated once per
submission, listed under a heading about the contract gate. It now resolves the
list once, before spawning anything. The rejection heading no longer claims
every rejection is a contract failure either — a bad zip or a missing manifest
is not.
Added — docs/planning/simplification_log.md¶
Six places where the code is heavier than the job needs, or carries something that no longer runs. Internal, excluded from the site.
Added — competition.run decode¶
The packed score explains itself to nobody: 1.20 × 10¹³ carries no obvious
relationship to "10 failed episodes." kaggle.metric.unpack inverts pack
exactly — it is a mixed-radix encoding, so decoding is divmod, not a fit —
and python -m competition.run decode <score> puts that on the command line:
exit_miss_bucket is lossy one way — pack already rounded it to the nearest
EXIT_MISS_BUCKET before packing, so unpack can return the bucket and an
approximate mean but not the exact value that went in. A component sitting
exactly on its BOUND_* constant may be clamped rather than measured; the
docstring says so rather than let a saturated value read as a real count.
Kaggle's leaderboard has no second column to put a friendlier number in — this
is the closest a Metric Notebook competition gets, and it costs nothing on the
scoring path: pack and score are untouched, unpack is a new pure function
next to pack. Its doctest is collected by its own test, mirroring how
score's is, and test_pack_unpack_round_trip checks the inverse holds across
200 random draws over the full bound space, not just the one worked example.
[0.31.5] — 2026-09-03¶
The handbook is reorganised by difficulty rather than by topic. Three session tabs, each self-contained and colour-coded, with Home and Setup in front and Reference behind.
Changed — one tab per session, each a self-contained container¶
Thirteen pages moved. The docs were organised by kind — Environments / Running / Sessions — and each kind held exactly one page per session, so re-parenting was a transpose: whole files moved, no page was split.
| Was | Now |
|---|---|
sessions/fundamentals.md, environments/maze.md |
fundamentals/ |
sessions/intermediate.md, environments/crippled-ant.md, running/crippled-ant.md, sessions/syllabus-intermediate.md |
intermediate/ |
sessions/advanced.md, environments/airtraffic.md, running/airtraffic-{training,evaluation}.md, running/baseline.md, sessions/competition.md, sessions/syllabus-advanced.md |
advanced/ |
reference/ is unchanged and changelog.md joins it. The Advanced tab carries
seven pages, so it is grouped in the nav rather than listed flat.
Every internal link was rewritten by resolving each target against its old
directory, mapping through the move table, and recomputing the relative path
from the new one. All moves preserved directory depth, so ../assets/… links
were unaffected.
changelog.md needed a distinction: its backticked prose paths are historical
record and are left alone, but its markdown links must resolve or the
published page is broken. Ten links were repointed; the prose was not.
Added — colour-coded tabs¶
docs/overrides/main.html sets the tab colour from page.url — green for
Fundamentals, amber for Intermediate, red for Advanced, and the site's blue for
Home, Setup and Reference. Pure Jinja, no JavaScript, and it survives the
light/dark toggle because it redefines the same CSS variables
docs/stylesheets/extra.css already drives everything from.
Contrast was measured, not assumed. Every value clears 4.5 against its own
surface in both schemes. The brand amber #e09f3e scores 2.22 on a white
page and is unusable as link text; the light-mode amber here is #9a6700
(4.87).
The colour is applied in two files — the tab in main.html, the site-map pill
in extra.css — and they must stay in sync. Both say so.
Changed — the home page is now a site map¶
The four old cards were organised by kind and two of them pointed into
environments/ and running/, which no longer exist as tabs. In their place: a
"do this first" pair, then three colour-coded session cards whose colour is
the difficulty and matches the tab you land in, then a new "Understanding the
codebase" section mapping repository paths to tabs.
Fixed — two bugs that only rendering caught¶
{ .rlb-f } attr_list on a raw <span> does not apply. It rendered as
literal visible text on the page, and the difficulty pills had no background
at all — white text on transparent. Separately, Material renders grid cards as
div > ul > li, so the original .rlb-map .rlb-f selectors matched nothing.
The fix puts the colour class on the pill itself, so the difficulty colour cannot be broken by reordering the cards; only the decorative left border stays positional, and the CSS says so.
[0.31.4] — 2026-09-03¶
Three decks instead of two, and the Advanced deck stops being a four-hour lecture. Slides become a tour with a pointer to the handbook; the room gets its time back.
Added — slides/litmus.tex¶
Module 6 of the Intermediate deck becomes a standalone auxiliary deck that runs
alongside Session #3. It was a Session #3 argument wearing a Session #2 timeslot:
every number on it is measured on Flight4DEnv, the environment Session #3
designs, and Session #2 had no time to do it justice.
Session #2 therefore ends 30 minutes earlier — the timetable now runs 0:00–2:30 and closes on "Wrap-up, and what Session #3 asks of you". Its body drops 38 → 31 pages. Learning objective #4 changes from "apply the litmus test" to "say what you would need to measure before claiming a learned policy beat anything", which is the part Session #2 can actually deliver. The AirTraffic command block in the appendix cheat sheet shrinks to the three commands that still make sense before you have met the environment.
Changed — advanced.tex re-scoped from four hours to thirty minutes¶
Body 46 → 20 pages (14 frames, 5 sections). It is now a tour of the baseline solution: the contract, the sector, the four constants, the demonstration clips, the observation and action spaces, termination vs. truncation, the reward, and reading the training curves. Every frame points at the handbook rather than restating it.
Five frames are new. The observation and action spaces are TikZ diagrams
— the flat 103-vector split into five aircraft blocks, two globals and the 36
inline mask bits, with the thirteen per-aircraft features grouped by what they
are for; and the action space as an aircraft × clearance grid showing
1 + 7*aircraft + clearance with idle as index 0. The reward frames are built
on the figures added in 0.31.3.
Nothing was lost. 29 frames moved into seven labelled appendix sections
(Extra --- eleven mistakes, Extra --- the competition, and so on), so a
presenter can jump to any of them during questions or the sprint. The appendix is
now 43 frames, which is large — but an appendix is not presented linearly, and
labelled sections make it navigable.
Three frames were deleted rather than moved, per the standing rule that duplicates and stale mechanics go: the old opener and closing punchline (both replaced), and the 4-hour timetable, which describes a schedule that no longer exists and would be misinformation if kept.
Fixed — five new frames were overfull, and two diagrams were clipped¶
Authored at 33–57 pt overfull. Trimmed by removing boxes, not shrinking type — merging paired callouts into one, demoting a block to a grey footnote, cutting the TensorBoard list from four items to three. Both decks' bodies are now clean at the 18 pt threshold.
The observation diagram initially ran its feature list off the right edge, losing
severity, t_conflict and active; the fix wraps it into four role-grouped
lines in a box that sizes itself to its content. The action diagram had the idle
cell colliding with the row labels and its caption clipped at the slide edge.
Note on measuring overfull boxes¶
The Overfull \vbox list is only meaningful when a deck is built inside
slides/. Without figures/ and media/ present, \fig and \clipbox fall
back to placeholder boxes of a different height and the list changes — which
briefly made a pre-existing box look like a regression. intermediate carries
three long-standing boxes over threshold (46.1, 29.4, 28.3 pt) and advanced one
(49.5 pt, in an appendix frame); the README now records this instead of claiming
the decks are clean.
Follow-up created¶
tutorial/TUTORIAL_SYLLABUS_ADVANCED.md still describes the old lecture-heavy
Modules 1–7 on 4-hour timings. It now carries a prominent note saying the deck
was re-scoped, but it needs rewriting to match — a content decision, not a
mechanical edit.
[0.31.3] — 2026-09-03¶
Plots and renders, so the reference pages stop asking anyone to picture a trade-off from a table of numbers. Four figures, four demonstration clips, and a palette validator, because "is this colourblind-safe?" is a question with a computable answer.
Added — the two-aircraft demonstration clips¶
scripts/render_4d.py gains a scripted mode: --agent split|macro|speed replays
a fixed (step, aircraft, clearance) list, plus --n-range to pin the traffic
draw and --ext gif. All three run on seed 2 at n_range=(2,2), where flights 0
and 1 sit co-level at FL374 and close to d_cpa = 1.0 m at step 18.
The three resolutions trade against each other, which is worth more than three clean wins:
| demo | outcome | congestion | clearances | exit_miss |
|---|---|---|---|---|
| do-nothing | dies at step 18 | 3 | 0 | 0.000 |
| split the levels | survives | 0 | 4 | 0.000 |
| one macro | survives | 2 | 1 | 0.000 |
| offset the speeds | survives | 5 | 2 | 0.035 |
The scripted controller is deliberately open-loop: these clips exist to show what one clearance does to the geometry, and a reactive controller would make it impossible to say which decision produced which part of the outcome. The fixed step numbers are an approximation and the docs say so — one aircraft may be commanded per step and a temporary manoeuvre's return leg runs on a timer the caller does not control, so timing a resolution is part of the problem.
docs/environments/airtraffic.md gains the clips and, before them, a table
explaining how to read a render — tracks, projections, congestion rings, the
hotspot square, the exit-deviation tick, the two KPI bars and the clearance
markers.
Added — four reference figures¶
slides/scripts/make_figures_4d.py writes PDF to slides/figures/ and PNG to
docs/assets/figures/ in one call, so a figure cannot be current in one place
and stale in the other: the reward decomposition, the design ratios, the
severity cliff, and the RECOVERY_SEP_FACTOR trade-off curve.
The sweep is drawn as a trade-off curve, not a dual-axis chart. Both quantities are costs, so they go on two axes of one plane and the frontier is readable directly — down-left is better on both, and 7.0 is visibly the last point inside the random-legal reference lines on both columns.
Changed — two figures that did not survive contact with the data¶
The severity surface became a line with an annotated cliff. A heatmap
renders the exponential fade but hides what matters: predict_conflicts omits a
pair at or beyond ALT_MIN_SEP, so severity is deleted, not decayed. A
discontinuity needs a form that shows it.
The mid-air-charge figure was dropped. It plotted the charge against
calibrate_4d.py's "worst cost of flying on" and appeared to show that a late
crash is cheap — a category error, since worst_remaining is a maximum over all
states and cannot be drawn as a horizontal line against a per-step curve. The
shipped check is two scalars, and two scalars are a sentence, not a chart. The
reasoning sits in the script so nobody re-adds it.
Added — slides/scripts/validate_palette.py¶
A port of the six accessibility checks (lightness band, chroma floor, CVD separation, tritan, normal-vision floor, contrast) using the same OKLab metric and Machado transforms as the JS original, because this machine has no JS runtime and the check must be computed rather than eyeballed.
It immediately earned itself: the repo's existing amber #e09f3e fails
contrast against a light surface — 2.22 against a floor of 3.0. The new
three-series figures use #1f6feb / #c2801f / #a3195b, which passes all six.
Darkening the amber instead was tried and rejected: it collapses CVD separation
against the existing green, the classic red-green convergence.
Not fixed here: #e09f3e is still used by make_figures.py and
make_figures_mistakes.py, which carry the same contrast failure. Repainting
the existing figures is a wider change and is filed, not done.
Changed — the macro section of the controller reference¶
The argument was sound and the setup was missing. It now states what the macro
mechanically is (_T = auto-reverting on a hold_steps timer), why it is
tempting (one clearance against three, because RESUME is charged and the
auto-revert is not), why two levels is the minimum that resolves anything
(DALT 12 against ALT_MIN_SEP 24), the failure rate as a rate (50% against
random's 34%, rather than 20-in-40 against 34-in-100), what "just-in-time lead"
means, and why 8 aircraft were never commanded at all — the one-clearance-
per-step budget.
flight_4d.py calls the macro "the minimum effective vertical intervention"
while the controller page calls it worse than random. Both are true and they
describe different halves of one instruction; the environment now says so and
points at the controller, so the two files stop appearing to disagree.
Added — a drift guard¶
make_figures_4d.py mirrors seven environment constants rather than importing
the airtraffic package, so it runs from the repo root against committed CSVs.
test_figure_constants_match_env asserts the copies still agree, and skips if
slides/ is absent from a filtered export.
[0.31.2] — 2026-09-03¶
The reward reference stops describing an environment that no longer exists.
docs/reference/reward-design.md was written against the retired
first-generation env at n=5, m=5 — ten aircraft, a signature Flight4DEnv
does not have — and carried a survival bonus that was removed in 0.19.0. It is
now measured on Flight4DEnv at its own defaults, n+m=5 with n ~ U(2,4).
Changed — reward-design.md rewritten and re-measured¶
Every number is new, from 40 seeds at gamma=0.99. The headline result
inverts: on the retired environment do-nothing scored best (+59.68),
because the survival bonus paid it 100 points it could not lose. On
Flight4DEnv do-nothing is the worst policy on the board by nearly a factor of
three (127.51 against the controller's 47.25), while remaining perfectly on time
and perfectly efficient — which is a far better teaching row, because it shows
two of the three objectives can be maxed by doing nothing at all.
Also now documented, and previously absent: the severity function's vertical credit falling out of the physics rather than being chosen (one level removes 39%, the second the remaining 61%); the design ratios as the actual design statement; and the mid-air charge's margin over the worst cost of flying on (450 against 170.55), which is what stops a cost-only reward from paying the agent to crash.
Changed — potential-based shaping is now filed as an extension, not a description¶
It was described as what the reward is. It is Track C of the design sprint —
something a student may add. The section now says so, keeps the measured
shaping-scale sweep and the step-function trap (both DALT=12 and
ALT_MIN_SEP=24 are unchanged, so that trap is still live), and states the
trade the environment accepted by not using it.
Added — the "detour versus endpoint" fix, which was never written down¶
The first-generation environment charged the timeliness deviation right now, every step, so the reward integrated the whole detour: 9.963 penalised against 0.039 scored over 100 seeds. The reward said never leave your plan while the ranking said leave it freely, but come back.
Flight4DEnv does not fix this by moving the charge to the horizon, which is
what the old page implied. It changes the quantity — _exit_deviation() returns
where the aircraft will cross if it holds its current speed. A detour that is
later recovered projects back to target and costs nothing; only an unrecoverable
deviation stays on the bill. The two regimes (projection before the boundary,
recorded fact after) are documented with the phantom-delay bug that conflating
them caused.
Added — calibrate_4d.py --agent and --csv¶
measure() took no agent and hard-coded a random-legal rollout. It now accepts
"noop", "random", "rule-based" or a checkpoint path — the same dispatch
score_4d.py uses, so both read the same strings — and can write the per-agent
decomposition to CSV. The terms are still read off env's own
_severity/_deviation rather than re-derived, so a decomposition cannot drift
from the reward it claims to decompose. Output at slides/data/air_reward.csv,
which Phase 2's reward plots will consume.
Not changed, on inspection¶
Three flagged survival-bonus mentions turned out to be correctly framed already:
slides/advanced.tex:630 presents it as removed mistake #1, :656 explicitly
states its absence in Flight4DEnv, and TUTORIAL_SYLLABUS_ADVANCED.md:344 is
a historical note about a past training run. All three describe it as history or
as absent, not as current behaviour, so none were touched.
[0.31.1] — 2026-09-03¶
A documentation-review pass, ahead of a larger docs and slides refactor. Two of
the three published Session #2 policies were not actually in the repository, and
Flight4DEnv contradicted itself about whether its reward is potential-based.
No behaviour changes.
Fixed — two Session #2 checkpoints were silently excluded from git¶
.gitignore's un-ignore exception for the Crippled Ant checkpoints was
checkpoints/*.zip, one directory level too shallow to match the actual layout,
checkpoints/<run>/final_model.zip. ppo_specialist_3M and
ppo_randomised_range_3M were therefore never committed, while their
.hydra/config.yaml and vecnormalize.pkl were — so a fresh clone got two
populated-looking checkpoint directories with no policy inside them.
ppo_healthy_3M escaped only because it had been force-added.
The exception now uses **, and both missing policies are committed. The
airtraffic checkpoint was unaffected: it sits directly in checkpoints/, where
the single-level pattern matched.
Fixed — the reward was documented as potential-based shaping, and is not¶
Flight4DEnv.step() computes three direct per-step costs plus a terminal crash
charge. There is no gamma*Phi(s') - Phi(s) term anywhere in the environment,
and the comment at the reward itself says so explicitly, calling the direct-cost
form "a deliberate choice".
Four other places in the same module still described the retired shaping design
— the module docstring's design point 3, two comments on the reward weights
referring to Phi_safe / Phi_4D and a gamma^T discount that no longer
applies, and a # potentials section header over what are plain cost functions.
docs/environments/airtraffic.md and scripts/calibrate_4d.py had inherited the
same claim.
This mattered beyond tidiness: potential-based shaping provably cannot move
the optimal policy, and the docs were extending that guarantee to weights which
genuinely can. Anyone retuning w_safe or w_clearance on the strength of it
was working from a false premise. All six now describe the direct-cost design
and state the trade it accepts. docs/reference/environment-api.md already
described this correctly and was used as the reference.
Fixed — hold_steps docstring disagreed with the signature¶
The module docstring said hold_steps "defaults to 8 (16 s)"; the constructor
has defaulted to 5 (10 s). Only the prose was wrong.
Fixed — version strings that had drifted¶
mkdocs.yml's DOCS_VERSION fallback still read 0.17.0 despite claiming to
mirror the repo-root VERSION, and the home page advertised 0.6.0. The
fallback now mirrors, and the home page points at this changelog so it cannot
drift again.
The two decks' \institute lines still read VERSION 0.29.0. Those are
provenance stamps — the release the quoted numbers were measured at — not
mirrors of the current version, so they are deliberately left alone until the
board is re-measured.
Added — internal planning documents¶
docs/planning/tutorial_refactor_plan.md (the compiled review, in six
dependency-ordered phases) and docs/planning/volunteer_checklist.md (five
standalone verification tasks). Both live under docs/planning/, which
exclude_docs keeps out of the student handbook.
[0.31.0] — 2026-08-28¶
Domain randomisation gets a second, working attempt; the specialist gets a
fair budget; the rule-based controller's recovery order stops being an
accident of flight index; and Flight4DEnv gets the render path it did not
have — congestion visibility carried forward from the retired environment,
plus the flight plan and two live KPI bars that renderer never had.
Fixed — Priority4DController's RESUME order was arbitrary¶
_recover() picked among simultaneously-eligible aircraft by ascending flight
index (np.flatnonzero's own order), which carries no information about the
problem. It now resumes in assignment order — the same earliest-conflict-
first ordering _assign already computed and stored — so the aircraft the
controller decided mattered first also comes home first. The board and
competition numbers are unchanged (14 failed / 90 congestion / 667
clearances on score_4d.py's seeds; 20 / 96 / 727 on the competition's) —
simultaneous eligibility is rare enough on these seeds not to move the
aggregate, but the design was wrong regardless of whether it showed up.
Pinned by a new synthetic test, since the natural seeds don't reliably exhibit
the tie.
Added — scripts/render_4d.py, and the render path it needed¶
Flight4DEnv still declares no render modes by design — hard masking and a
flat action space were the problem, not visualisation, and the environment
stays free of matplotlib. The new script drives it from the outside: step it,
record what happened, replay through CollisionCourseSimulator's shared
geometry. The congestion styling (dashed-while-separated, solid-once-not,
severity-weighted, a fading trail) is carried over verbatim from the retired
environment's own renderer, which solved the same visibility problem once
already — an almost-invisible alpha=0.1 fixed circle, replaced.
New on top of that inheritance, none of it in the retired renderer:
- The flight plan itself. A solid track behind each aircraft, one colour per segment for the level actually flown, and a dashed projection ahead — one colour for the level held now — running exactly as far as the aircraft has left to fly. Headings are never commanded, so that projection is not a guess; it is where the aircraft goes if nothing else touches it.
- Two KPI bars, safety and timeliness, on a green-red scale (low is good),
reading straight off
_severity()/_deviation()— the same methods the reward uses, not a re-derivation from positions that could quietly drift from what the policy is actually scored on. - A per-aircraft deviation tick, the same green-red scale, above each flight's altitude/speed label.
- Smaller clearance markers, larger heading arrows, a square plot (the data aspect is pinned; it no longer stretches to fill whatever box the KPI bars and colourbar leave behind), and the legend outside the axes.
sessions/03-advanced/airtraffic/renders/seed900000_n3m2_*.mp4 — noop,
random, rule-based and the reference PPO checkpoint, all the same scenario —
feed the posters and filmstrips both decks now embed via make_media.py; the
old clips were the retired environment's, quietly showing the wrong sector
for a deck that had already moved on.
Fixed — Session #2's specialist and domain-randomisation checkpoints¶
The specialist now gets the same 3M-step budget as the generalist it is compared against. The committed 1M-step specialist tied the transferred zero-shot policy on its own injury (1112 vs 1119) at a third of the training; give it the full budget and it dominates: 3978 vs 1119, a 3.6× margin — and healthy collapses further, not less, 93.7 against the 1M version's 335. More training on one failure mode sharpened the dependency on that failure existing; it did not buy generalisation.
Domain randomisation over severity was tried at n_random_legs_max=4
first, and it collapsed. With four legs disabled there is barely anything
left to actuate, so near-stillness is close to optimal — and the policy
generalised that strategy to every severity, scoring worse than
do-nothing even healthy (-150, every band flat and near zero). The
training curve did not show this: ep_rew_mean sat around 300 with ep_len
500-600, reading as mediocre-but-functional. Only scoring against fixed
scenarios — not the mixed training distribution — exposed it. Narrowed to
n_random_legs_max=1 (severity uniform on {0, 1}, keeping 0 in the training
distribution — the actual design fix n_random_legs=1 never had), it trained
into a real result: wins 2-legs-dead outright (973 vs 919 generalist, 722
specialist) and lands within 2% of the specialist's own 1-leg aggregate (1220
vs 1247), for a healthy floor of 787 — below the do-nothing line, and the
honest price of the robustness bought above it.
Both checkpoints are published at sessions/02-intermediate/crippled-ant/
checkpoints/{ppo_specialist_3M,ppo_randomised_range_3M}/, laid out as run
directories so transfer_benchmark.py and render_agent.py take them
directly. slides/data/ant_transfer.csv and fig_specialist/fig_randomised
are regenerated from the real 20-episode benchmark; nothing here is
transcribed by hand into a slide that could drift from it.
[0.30.0] — 2026-08-28¶
The pass that retires the first-generation environment. One environment, one action space, one policy, one board — and every number on a slide traceable to a committed CSV.
Removed — the first-generation environment leaves the session tree¶
LegacyFlightEnv and everything built only for it moved out of
sessions/03-advanced/airtraffic/ and into solutions/advanced/, which is now
explicitly legacy example code rather than a second live curriculum:
envs/um_flight_env.py->solutions/advanced/legacy_env.pypolicies/order_invariant.py->solutions/advanced/legacy_policy.pyagents/rule_based.py->solutions/advanced/legacy_agents.py
It all still imports and runs — code that cannot run is not example code — but
the participant-facing package now exports exactly Flight,
CollisionCourseSimulator and Flight4DEnv, and nothing else.
Deleted outright, because they only ever drove the retired environment:
scripts/{train,evaluate,evaluate_baseline,render}.py, the root
um_flight_env.py duplicate (which imported a package that is not in this repo
and had been dead for some time), {train,eval}_umflightenv_sb3.py, all three
notebooks/*.ipynb, conf/default.yaml, plotting.py, utils/viz.py,
tests/{test_baseline,test_macro_action,test_policy}.py, and
slides/scripts/{extract_advanced,make_figures_advanced}.py with the six CSVs
and figures they produced.
Authoring Flight4DEnv notebooks is deliberately deferred, not forgotten.
Changed — the trainer gains the settings the exercise is about¶
Session #3's exercise is choosing settings, not writing them, so
scripts/train_4d.py now offers the two that were worth porting:
--curriculum/--curriculum-frac/--curriculum-n-start— narrows the per-episode draw ofnand widens it to the scored density.n + mis fixed either way, so the observation and action spaces never move. Needs the newFlight4DEnv.set_n_range(lo, hi).--auto-entropy/--auto-entropy-target— tunesent_coefso the policy's entropy tracks a fraction ofln(n_actions). A fixed coefficient has failed in both directions here; the failures are about the entropy, not the knob.
Action-set choice, observation frame stacking and LR-schedule choice were considered and not ported: the first two contradict the environment's own design (auto-reverting clearances replace RESUME; the state is Markov on one frame), and the third only ever offered a known-worse option.
--checkpoint-every (default 500k) closes a real gap: train_4d.py saved only
at the very end, so an interrupted 80-minute run lost everything.
New scripts/callbacks_4d.py houses both callbacks.
Changed — the competition harness scores the environment the session teaches¶
competition/ was still building the retired environment; the deck said so out
loud. It now runs Flight4DEnv end to end. The action no longer factorises
across flights, so the submission format changes with it:
- One row per
(seed, step)carrying the flatDiscreteindex, replacing the per-(seed, step, flight)grid built forMultiDiscrete. SCORE_VERSION1.0.0 -> 2.0.0, per that file's own convention.- The scored 4D quantity is
exit_missthroughout, nottimeliness. - The action-set/action-mode dispatch is deleted rather than ported — there is one action space now, so there is nothing to dispatch on.
kaggle/metric.py re-derives the new physics standalone in numpy — the metric
sandbox has no gymnasium by design — including the hold_steps auto-revert, the
two-level macro and the 4D gate. test_kaggle_metric.py's replay-parity
property is the oracle that keeps that honest, and it passes.
tests/test_env_contract.py was 26 tests of retired-environment internals;
it is now a genuinely generic contract, which is what its own docstring always
claimed it was and what a student's subclass has to pass.
Added — the reference agent, and the checkpoints that were only ever promised¶
Retrained on Flight4DEnv: 4M steps, curriculum + auto-entropy, ~80 min.
On 100 held-out seeds, against the rule-based bar:
| agent | failed | cong./100 steps | conflict-free | exit_miss | clearances |
|---|---|---|---|---|---|
| do-nothing | 96 | 44.04 | 4% | 0.000 | 0 |
| random-legal | 34 | 15.05 | 9% | 0.754 | 2924 |
| rule-based | 14 | 2.01 | 63% | 0.554 | 667 |
PPO 4M [det] |
1 | 3.46 | 49% | 0.087 | 2278 |
PPO 4M [sto] |
2 | 4.58 | 48% | 0.143 | 3338 |
The learner wins the two ranked-first objectives and loses efficiency. That is the first time in this bootcamp a learned policy has cleared the heuristic.
One hundred seeds does not pin a failure rate. The same checkpoint fails roughly 3.5% of a further 600 held-out seeds. The board row reproduces; the rate needs more seeds than a board has, and the deck now says so on the slide.
*.zip stays gitignored, with three narrow exceptions, because participant
material promised checkpoints that no fresh clone could produce:
crippled-ant/checkpoints/ppo_healthy_3M/, ppo_randomised_3M/ (both laid out
as run directories, so transfer_benchmark.py --run ... --model final takes
them directly) and airtraffic/checkpoints/ppo_4M_curriculum.zip.
Changed — decks¶
Session #2 rebalances away from healthy-to-cripple transfer and toward domain
randomisation: fig_leg_asymmetry and fig_specialist move to Extra slides,
and a measured three-way comparison arrives in the body — zero-shot 879-1747
across the four one-leg injuries (2.0x), randomised 858-1161 (1.35x), specialist
660 mean. Randomising flattened the spread by giving up its best case, and
n_random_legs=1 never shows the policy a healthy Ant, which is a design bug
worth showing rather than a price worth paying. The VecNormalize frame moves
to Extra slides. Body 38 -> 37 pages.
Session #3 gains a frame documenting the settings menu, since "choose the right settings" is the exercise. Body 44 -> 45 pages, both decks under the 50-page cap.
Fixed¶
fig_randomisedrendered two series under a title promising three: the randomised run was never registered inextract_data.py'sANT_RUNS, andfig_randomised's ownlen(present) < 2guard cannot catch a missing third.fig_4d_boardpinned the trained row by a hardcoded label, so it vanished silently whenever a new agent was scored. It now finds the row by scoring mode.- The deck offered pre-trained checkpoints at a path that did not exist, with a healthy return that matched no measurement.
docs/{environments/airtraffic,sessions/advanced}.mdcarried "not migrated yet" banners. They are migrated.
[0.29.0] — 2026-08-27¶
env.n_random_legs=1 never ran — Exercise 5 was a dead slide¶
CrippledAnt.__init__ gated the mutual-exclusion check on
disabled_joints is not None or disabled_legs is not None, and conf/config.yaml
defaults both to []. An empty list is not None, so every invocation of
the documented Exercise 5 command:
python scripts/train.py env.n_random_legs=1
ValueError: n_random_legs is mutually exclusive with disabled_joints/disabled_legs
make_ant() twenty lines below used truthiness (if disabled_joints or
disabled_legs or n_random_legs is not None), so the two disagreed about what
"no injury specified" means. Fixed to bool(disabled_joints) or
bool(disabled_legs); tests/test_crippled_ant.py::test_random_and_static_conflict
still passes because it passes a non-empty disabled_legs=[0]. 31 tests green.
This is why Session #2's Module 4 had no ending: Exercise 5 shipped as an instruction with no result, no data and no figure, because the instruction could not be run.
Session #3's deck moves onto Flight4DEnv¶
The two decks described different environments. Session #2's Module 6
previewed Flight4DEnv (Discrete(1 + 7n), mask carried in the observation,
score_4d.py, brick fails 96/100). Session #3's deck described LegacyFlightEnv
end to end (MultiDiscrete([5] * n), competition.run, brick fails 83/100).
Same audience, consecutive days.
Flight4DEnv is the baseline as of 0.27.0 and got its rule-based controller in
0.28.0, so the advanced deck follows it:
| Module | was | now |
|---|---|---|
| 1 — case study | "LegacyFlightEnv as it stands" |
"the design you are replacing" |
| 3 — the bar | competition.run compare, UMFlight numbers |
score_4d.py, the 4D board |
| 4 — eleven mistakes | 13 frames of war stories | 8 grouped frames, each ending in the line it put in flight_4d.py |
| 5 — sprint | train.py --noop-bias 3.5 |
train_4d.py --steps 200000 |
| 6 — leaderboard | UMFlight table at VERSION 0.17.0 |
the measured 4D board |
Module 4's arc is the substantive change: the eleven mistakes are no longer a
war-story reel, they are the derivation of Flight4DEnv. Critique the old
design (Module 1) → see the eleven mistakes in it (Module 4) → here is what
replaced them → beat it (Modules 5–6).
Known gaps, both flagged in place rather than papered over:
competition/rollout.pystill buildsLegacyFlightEnv, so the submission and replay harness has not moved. The cheat-sheet frame now says so explicitly rather than implyingcompetition.run comparescores what you are graded on.docs/environments/airtraffic.mdanddocs/sessions/advanced.mdstill documentLegacyFlightEnv. Both carry a warning admonition pointing atflight_4d.py,train_4d.pyandscore_4d.py. Migrating the handbook is the next piece of work after the harness.
The rule-based controller is on the board, and it is not the loser¶
scripts/extract_4d_board.py scored only noop, random and a checkpoint.
Added Priority4DController, so data/air_4d_board.csv now carries the row the
whole "do you need RL?" argument turns on:
| agent | failed | ep_len | congestion | /100 steps | conflict-free | exit_miss | clearances |
|---|---|---|---|---|---|---|---|
PPO masked 1.5M [det] |
1 | 49.1 | 194 | 3.95 | 53% | 0.072 | 2421 |
PPO masked 1.5M [sto] |
2 | 49.0 | 193 | 3.94 | 48% | 0.085 | 3410 |
| rule-based | 14 | 44.8 | 90 | 2.01 | 63% | 0.554 | 667 |
| random-legal | 34 | 35.1 | 528 | 15.05 | 9% | 0.754 | 2924 |
| noop | 96 | 12.5 | 551 | 44.04 | 4% | 0.000 | 0 |
100 held-out seeds (900000–900099). The learner wins the rank key on failed
alone. But the controller is twice as safe per step flown (2.01 against
3.95) on comparable episode lengths — 44.8 to 49.1, so this is not the
dying-early confound the deck warns about elsewhere — and it does it with a
quarter of the clearances.
Two slides were stale against this. Session #2's Module 6 said "nobody has
hand-coded a controller for this environment yet, so RL beats a heuristic here
is untested"; it now quotes the comparison. Session #2 also mis-stated the
observation as Box(13 * n_flights + 2); the mask rides inside the
observation, so it is Box(-1, 1, (103,)) at n=5 — 13n features + 2 globals
+ 36 legality bits.
The decks are hand-written Beamer again¶
scripts/rebuild_intermediate.py and scripts/rebuild_advanced.py are deleted,
with intermediate.tex.bak and advanced.tex.bak. Introduced in 0.26.0 to hold
the pre-cut wording while the bodies were trimmed to 50 pages, the generator
doubled the edit surface for every change and silently destroyed hand edits to
the .tex — its own README said so. intermediate.tex and advanced.tex are
now the source. git log -p has the pre-cut text.
Both decks are shorter, and the hyperparameter module is a mention¶
| Deck | body pages | was | |
|---|---|---|---|
intermediate |
38 | 54 | 48 / 58 |
advanced |
44 | 59 | 50 / 87 |
Session #2's Module 5 went from three frames to one. The shipped grid is 6 cells
× 300k steps, and at 300k every cell is still in the statue regime — it ranks
how fast policies stand up, not how well they walk, on one seed per cell against
a seed-to-seed swing of 500. The frame that survives is the one about
.hydra/config.yaml being what stops evaluation disagreeing with training; the
grid-search walkthrough moved to the appendix and the timetable gave the ten
minutes to Module 4.
Appendix frames that duplicated a body frame, or that described LegacyFlightEnv
mechanics the sessions no longer use (--noop-bias, the n=5 m=5 frozen
scenario, the UMFlight renders), were deleted rather than kept. Both decks now
build with zero overfull \vbox over 18 pt — including four frames that
were already clipped before this change.
[0.28.0] — 2026-08-27¶
Session #2 slides — Module 6 rebuilt on Flight4DEnv, and the statue claim corrected¶
The statue number was right; the slide that quoted it was wrong. A perfect
statue banks 1000.6 ± 4.0 (zero torque, 20 episodes, 3 cm travelled) — the
healthy bonus alone. The 1M run's best_model.zip banks 987.2 ± 2.9 over the
full 1000 steps and covers 0.43 m. So "≈ 990" was a fair round number.
What was wrong is where the deck said to look for it. Slide 13 claimed the 1M curve was "flat at exactly the statue line". It is not:
| tag, 1M run | at 1M |
|---|---|
rollout/ep_rew_mean |
16.4 |
rollout/ep_len_mean |
65.3 |
eval/mean_reward, best |
987.8, at 20k steps |
The statue lives in the eval curve, and only for the first 100k steps —
after which that run degrades monotonically to eval 121 by 1M. EvalCallback
froze the 20k checkpoint as best_model.zip, and evaluate.py --model best is
the default, so the shipped policy comes from the first 2% of the run while
the headline curve never goes near 1000. fig_ppo_curves now plots rollout and
eval together and marks that checkpoint. The conclusion is unchanged and better
supported: the 3M run's eval does not clear the statue line until 1.64M
steps.
Measured checkpoints, 20 episodes, deterministic:
| checkpoint | return | ep_len | distance |
|---|---|---|---|
| zero torque (perfect statue) | 1000.6 ± 4.0 | 1000 | 0.03 m |
| uniform random torques | −35.2 ± 73.2 | 132 | 0.42 m |
PPO 1M, best_model |
987.2 ± 2.9 | 1000 | 0.43 m |
PPO 1M, final_model |
54.3 ± 42.1 | 36 | 2.51 m |
PPO 3M, best_model |
1879.1 ± 732.1 | 863 | 77.5 m |
PPO 3M, final_model |
2316.2 ± 589.2 | 930 | 101.1 m |
Module 6 now previews the environment Session #3 actually uses¶
The litmus-test module was built on the pre-redesign LegacyFlightEnv — a
MultiDiscrete action space, a clearance budget, an unconditional alive bonus,
and a 60-line controller that beat PPO outright. All of it is superseded. The
module is rebuilt on Flight4DEnv: three ranked objectives, hard action
masking, the 4D exit gate, and the board re-measured on the finished 1.5M run.
slides/scripts/extract_4d_board.py (new) regenerates
slides/data/air_4d_board.csv; fig_4d_board plots it. 100 held-out seeds
(900000–900099), hold_steps=5, runs/v4d_s123/final_model.zip:
| agent | failed | ep_len | congestion | per 100 steps flown | exit_miss | clearances |
|---|---|---|---|---|---|---|
| PPO masked, 1.5M [det] | 1 | 49.1 | 194 | 3.95 | 0.072 | 2421 |
| PPO masked, 1.5M [sto] | 2 | 49.0 | 193 | 3.94 | 0.085 | 3410 |
| random-legal | 34 | 35.1 | 528 | 15.05 | 0.754 | 2924 |
| do-nothing | 96 | 12.5 | 551 | 44.04 | 0.000 | 0 |
These supersede the 0.27.0 table above, which was taken from a mid-run checkpoint while the same run was still training. The direction is unchanged.
Raw congestion cannot rank these rows. The brick's 551 against the agent's
194 is a factor of 2.8; per step actually flown it is 11×, because the brick
stops accumulating conflict-steps the moment it hits something. Every congestion
figure in the deck is now quoted both ways, and extract_4d_board.py carries
the steps-flown column score_4d.py does not.
What the module no longer claims. No hand-written controller exists for
Flight4DEnv, so "RL beats a heuristic" is untested here and the slide says so.
The agent beats the two baselines that need no training, and that is the claim.
Also in the deck: the Module 1 discussion frame drops its list of expected
answers; Exercise 7 and 8 point at score_4d.py and train_4d.py (25 min for
1.5M, down from 80); the wall-clock and deliverables appendices match; the title
page reads VERSION from the repo instead of a hard-coded string. 48 body
pages, within the 50-page budget.
Fixed¶
solutions/README.mdstill warned that the directory is gitignored and "not pushed". Untrue since 0.27.0 tracked the reference-solution code — and it is the doc someone reads before deciding whether a path there is safe to quote. It now says what actually keeps the directory away from participants (the filtered export, the docs-site exclusion, and the rule that nothing undersessions/may import from it), and that artefacts remain ignored.- Both deck generators now emit a
GENERATED FILE — DO NOT EDIT BY HANDbanner naming the script and the.bak.slides/intermediate.texandslides/advanced.texare build products; a hand edit compiled fine and was destroyed by the next generator run with no error and no diff.
Pending — the deck leads the move¶
Flight4DEnv becomes the participant-facing baseline solution and moves from
solutions/advanced/reference_solution/ into sessions/03-advanced/airtraffic/
(envs/flight_4d.py, scripts/score_4d.py, scripts/train_4d.py,
scripts/calibrate_4d.py, policies/autoregressive.py,
tests/test_flight_4d.py). Exercises 7 and 8 and the cheat sheet already quote
the destination paths, so they are wrong until the move lands and right
afterwards — the only ordering that leaves no window where a participant-facing
deck points into the instructor bundle.
slides/scripts/extract_4d_board.py needs no edit on the day: it tries
envs.flight_4d under the session tree first and falls back to env_4d under
solutions/, and looks for the checkpoint in both runs/ directories. Verified
against the old layout — the regenerated CSV is byte-identical.
reward_probe.py is not in the move (it probes LegacyFlightEnv, not
Flight4DEnv), so it stays in the instructor bundle and has been dropped from
the participant cheat sheet.
[0.27.0] — 2026-08-27¶
Result — the first agent on Flight4DEnv that is good on its own terms¶
100 held-out seeds (900000+, away from the training callback's block):
| # | agent | failed | congestion | conflict-free | exit_miss | clearances |
|---|---|---|---|---|---|---|
| 1 | trained [det] | 2 | 184 | 55% | 0.065 | 2395 |
| 2 | trained [sto] | 6 | 171 | 56% | 0.157 | 2933 |
| 3 | random-legal | 34 | 528 | 9% | 0.754 | 2924 |
| 4 | do-nothing | 96 | 551 | 4% | 0.000 | 0 |
Against the previous best on this environment: congestion 611 → 184,
conflict-free 4% → 55%, exit_miss 0.418 → 0.065.
Normalised for survival — the comparison raw congestion cannot support, since a policy that dies early accumulates fewer conflict-steps by existing for less time:
| agent | steps flown | congestion / 100 steps |
|---|---|---|
| do-nothing | 1251 | 44.04 |
| random | 3509 | 15.05 |
| trained | 4902 | 3.75 |
Four times better than random per step flown, while flying 40% more steps.
The remaining weakness is objective 3: 48.9 clearances per 100 steps against random's 83.3. Cheaper than random, nowhere near frugal, and the only objective that has not moved.
Changed — the mid-air charge is proportional to the steps not flown¶
450 at step 0, 225 at step 25, 9 at step 49, 0 at the horizon. One dimensionless multiplier, expressed inw_safe, replacing the flat w_collision.
The purpose is to regularise the return, not to punish. An episode ending at step 10 banks ten steps of cost and one running to 50 banks fifty, so returns are not comparable across episodes and the critic fits that length-driven spread on top of everything else. Charging the steps not flown completes every episode to the same horizon. Deterring a crash is a side effect — a welcome one, since the rate sits comfortably above the ~3.3 per step a real episode costs.
Note this reverses a decision made deliberately in 0.19.0 for LegacyFlightEnv,
where the charge was made time-independent on the grounds that "sizing it on
the steps remaining would make a mid-air at step 49 nearly free, which invites
reckless flying near the horizon". The reasoning differs because every term in
Flight4DEnv is a cost: charging the forfeited steps is the correct accounting
of what ending early avoids, and at step 49 there is genuinely only one step of
cost to forfeit.
Added — two tests holding the charge¶
test_crash_charge_covers_the_steps_not_flown— per state, since the charge now varies with the step: at everyk, crashing must cost at least what flying on would have. It strips the terminal charge back out before comparing, or the test is circular and raising the charge raises its own bar.test_crash_charge_falls_to_zero_at_the_horizon— linear in steps forfeited, exactly zero atmax_steps.
123 tests pass.
Fixed¶
train_4d.py's run header referenced the removedw_collisionand crashed on launch. It now printscollision_rate, and — as with the weights andhold_stepsbefore it — reads from the environment rather than the CLI.
[0.26.0] — 2026-08-27¶
Changed — breaking: Flight4DEnv's reward, rebuilt from first principles¶
Three terms and nothing else, replacing the potential-based pair:
r = −( w_safe · Σ_conflicts severity predicted, summed over infringements present
+ (w_safe/(n+m)) · Σ_i dev_i timeliness, always present, dev ∈ [0,1]
+ w_clearance · 1[clearance issued] ) per non-NOOP action
The scaling rule is now asserted by a test, not stated in a comment: the whole
fleet at maximum deviation costs exactly one severity-1 infringement, so each
aircraft carries w_safe/(n+m). Measured split over an episode under a random
legal policy: safety 91%, timeliness 5.9%, action 3.1%.
Why predicted severity is the right quantity. predict_conflicts omits any
pair at or beyond ALT_MIN_SEP outright, and weights the rest by
exp(−d_alt/ALT_MIN_SEP). So one flight level removes 39% of the
infringement and the second removes the remaining 61% by deleting the
conflict from the list. That weighting falls out of the simulator's own conflict
definition rather than an exponent chosen by hand — and it cannot drift away from
the scored rule, because it is the scored rule.
Potential-based shaping is gone. Predicted severity is already dense — a conflict twenty steps out is in today's sum — so it does not need shaping to be learnable. The trade is deliberate and recorded: direct costs can move the optimum, where PBRS provably cannot.
Absolute scale reduced 10× (w_safe 30 → 3). Only ratios matter to the policy;
magnitude is what the critic must fit from a zero initialisation, and an episode
return near −80 is kinder than −800.
Changed — breaking: the action mask is part of the observation¶
Flight4DEnv.observation_space is now 13·5 + 2 + 36 = 103, with the legality
mask as the trailing 1 + 7·n_flights entries and mask_offset naming where it
starts. MaskInObsWrapper is a deprecated no-op that asserts rather than
appending a second copy.
SB3's rollout buffer stores observations and nothing else, so a mask supplied any other way is present when the action is chosen and absent when its log-probability is recomputed — PPO then compares two different distributions and optimises a ratio nobody intended. It fails as a wrong answer, not an error.
Removed¶
w_4d,w_invalid, and the invalid-action penalty. An invalid action is not something to learn to avoid; it is something to remove from the action space. The counter remains so a broken policy is loud rather than silent.
Fixed — the same duplication bit twice¶
train_4d.py carried its own copies of the environment defaults and silently
overrode them — once for hold_steps, once for the reward weights, each time
launching a run that was not the run intended. Every env knob in the trainer now
defaults to None and falls through to Flight4DEnv, and the run header prints
what the environment ended up with rather than what the CLI asked for.
Measured — why the previous run plateaued¶
Diagnosed on the 1.5M-step checkpoint, 60 seeds:
- The temporal component of
exit_missis exactly 0.0000 on 100% of aircraft — the symmetric speed pulse works. The entire residual 0.42 is vertical: permanent level changes never undone, on 105 of 109 touched aircraft. - The policy used 2 of 35 actions — 96.9% permanent
FL_DEC/FL_INC, with the reverting macro andRESUMEat zero uses — and issued its last clearance at step 1.4 (median 1), idling for the remaining 48. - Nothing was masked out:
RESUMEwas legal on 105 of 109 off-plan aircraft, and injecting it never hurt (Δcongestion −0.42, −0.18, −0.15, 0.00, 0.00 at steps 3/8/15/25/40, Δexit_missnegative throughout). It was free improvement left on the table. - Root cause was the reward, not exploration. The old
Φ_safewas linear ind_alt, so a one-level move — which leaves the pair squarely in conflict — collected 50% of the credit. The agent found that local optimum at step 1, took it on every aircraft, and stopped. It was optimising the shaping exactly as written.
Slides¶
Module 4 is now ten mistakes. New frame "We paid 50% for a manoeuvre that
resolves nothing" with fig_vertical_credit: the hand-made linear factor
against the simulator's own exp(−d_alt/24), beside the trained policy's
clearance histogram. Body is 50 pages, at the cap.
[0.25.0] — 2026-08-27¶
Changed — the advanced deck is now problem-first¶
The body was weighted towards solutions. It is now weighted towards defining the problem and towards the mistakes made building it, which is what a participant can actually use in a four-hour session.
| Module | was | now |
|---|---|---|
| 1 — The problem | 3 | 6 |
| 2 — What you get to design | 2 | 3 |
| 3 — What we score | 5 | 4 |
| 4 — Nine mistakes we made | — | 12 |
| 5 — Your design sprint | 4 | 3 |
| 6 — The competition | 7 | 6 |
Body stays at 49 pages. The solution-heavy material — potential-based
shaping in full, both defect derivations, the network and masking slides, all six
prior-project field notes, the Kaggle metric — moved to Extra slides, now 35 frames.
Added — Module 1 defines the problem before offering any answer¶
- The sector, and what you are asked to do in it — the conflict predicate, the no-turns restriction, and the three counted quantities.
- The 4D exit gate —
t* = s/v(0),Δt = t̂ − t*, why an untouched aircraft is free by construction, and the "slow everything down" hole this closes. - Four constants decide what is possible —
DALT12 againstALT_MIN_SEP24; 10 levels at 2-level separation giving exactly 5 slots for exactly 5 converging aircraft;DVEL35 needing ~14 steps against a 1000 m radius; 50 steps against a 100-clearance budget. Three of the nine mistakes are one of these relations, missed. - The tension at the centre of the problem —
δs = Δv·H·Δt_step,δt = δs/v₀, why returning to nominal speed does not return the schedule, and why a matched+Δv … −Δvpair is ap²event for an independent sampler.
Added — Module 4, the mistakes, each with its arithmetic¶
Grouped so the classes transfer even when the specifics do not: 1–3 the reward said something you did not mean; 4–5 the reward was fine and could not be reached; 6–7 the action space was not what you thought; 8–9 the measurement was wrong.
New measured figures, all from slides/data/air_mistakes.csv:
fig_reward_scale— the two asymmetries that make a weight lie about its own strength. One conflict is worth 30 and is collected immediately; one clearance cost 0.35, giving 86:1 in favour of acting, and the whole episode's clearance bill was 6.64 against that same 30. Second panel: a terminal charge is multiplied byγ^T ≈ 0.75before the agent feels it.fig_worse_than_random—exit_miss0.590 random, 0.844 trained, 0.531 after recalibration. A trained agent worse than random on an objective is telling you that objective is not priced.fig_mask_legality— speed clearances legal on 13–16% of idle (aircraft, step) pairs under a horizon-based dead-end test, 81–84% under a schedule-based one. A mask that refuses everything is a missing action.fig_eval_size— the same two checkpoints, ranked one way on a 20-episode callback and the other way on 50 frozen seeds.fig_det_sto— an idle-biased policy scored by argmax issues 0 clearances and is byte-identical to the brick. Three runs were written off on that evidence and all three were still learning.
Plus scripts/make_figures_mistakes.py, which reads the CSV so every number on
these slides is traceable like every other number in the deck.
[0.24.0] — 2026-08-27¶
Result — the macro action set did NOT survive training¶
Two 1.5M-step runs, identical but for action_set (macro vs resume),
action_mode=single, hierarchical head, endpoint timeliness, n=5 m=5, seed 123.
Frozen board, 50 public seeds:
| # | agent | failed | congestion | timeliness | clearances |
|---|---|---|---|---|---|
| 1 | rule-based-resume | 2 | 133 | 0.034 | 874 |
| 2 | rule-based | 2 | 134 | 0.104 | 1311 |
| 3 | control @1.31M | 2 | 660 | 0.698 | 405 |
| 4 | control final | 5 | 582 | 0.639 | 415 |
| 5 | macro @1.31M | 6 | 508 | 0.709 | 2252 |
| 6 | macro final | 10 | 453 | 0.693 | 2097 |
| 7 | noop | 42 | 467 | 0.000 | 0 |
The control ranks above the macro run. The macro agent buys lower congestion
with more failed episodes — the pattern failed_episodes-first exists to catch.
It also issues 45 clearances/episode against the control's 8: making the
effective intervention cheap in decisions made the policy prolific, not
selective.
The 20-episode training callback said the opposite (macro 3 failures / 213
congestion vs control 6 / 249) and inverted on 50 frozen seeds. Same lesson as
EvalCallback picking a best model from 5 episodes: never conclude a
head-to-head from the training callback.
This does not overturn the 0.22.0 injection probe. Its claim was reachability and that still holds; convergence was never claimed, and this disconfirms it.
Added — a fresh environment built around the 4D constraint¶
solutions/advanced/reference_solution/env_4d.py—Flight4DEnv, a standalonegym.Env(not a subclass of theLegacyFlightEnvchain; only the kinematics are reused).n + m = 5withn ~ U{2,3,4}per reset.
Every disturbing clearance auto-reverts, so the 4D recovery is inside the
instruction rather than left as a second decision: FL_INC2_T climbs two
levels and comes back, SPD_UP_T is a symmetric pulse (+dv for
hold_steps, -dv for hold_steps, then nominal). One clearance each.
Reward is Φ_safe (squared horizontal × vertical intrusion, zero outside
either threshold) plus Φ_4D (quadratic in temporal and vertical deviation),
both potential-based; the clearance count is a transition cost, charged
once at issue, never shaped.
The observation carries the temporal deviation dt_exit, the vertical
deviation, a per-aircraft clearance counter and the manoeuvre countdown —
which is what makes it Markov on a single frame, since headings are never
commanded and every track is a straight line.
-
policy_ar.py— a two-stage head (aircraft → clearance, stage two reading the selected aircraft's embedding) with a custom SB3Distribution. Masks arrive in the observation viaMaskInObsWrapper, because SB3's rollout buffer stores only observations: a mask supplied any other way is present when the action is chosen and absent when its log-probability is recomputed, and PPO then optimises a ratio between two different distributions. -
test_env_4d.py— 85 tests. The load-bearing ones assert that the autoregressivelog_probandentropymatch a flat Categorical overlog p(i) + log p(c|i); that the shaping telescopes exactly toγ^T Φ_T − Φ_0; and that a speed pulse leavesdt_exitat zero.
Fixed — two bugs the tests and probes caught, both consequential¶
- The kinematic dead-end mask used the episode horizon, not the aircraft's own exit schedule. Most aircraft leave well before step 50, so the remaining time was massively overstated and nearly every speed clearance looked like it would arrive early. Speed clearances were legal on 13–16% of idle (aircraft, step) pairs — the velocity axis, the one the 4D tension is about, was effectively deleted. Now 81–84%.
- A symmetric pulse that was not symmetric.
_advance_holdsticks inside the step that issues, so a counter of2·holdspenthold−1steps fast andholdslow, leaving exactly onedv·DTof along-track debt — 70 m, or 0.298 s at 235 m/s, which nothing ever paid back. The counter is now2·hold + 1. hold_stepsdefault 8 → 5, andtrain_4d.py's own default aligned to it. At 8 the pulse ran 34 s against a ~57 s crossing and the mask correctly refused it most of the time.
Measured, not changed — the prior project uses GRU, not LSTM¶
multi_agent/network/rlm.py: GRU, 192 hidden, BPTT window 8, RLlib stateful
handling. No recurrence has been added here. SB3 ships only LSTM recurrence
(sb3-contrib RecurrentPPO), so matching it is not available off the shelf —
and Flight4DEnv is Markov on one frame, where that full simulator is not.
[0.23.1] — 2026-08-27¶
Documentation — brought in line with 0.22.0 and 0.23.0¶
- Reward design gains What actually moves the gradient — measured: the four-variant ladder, the false-negative table, and why the macro-action is the row that closes it. The three results a designer should internalise before tuning anything — endpoint-charging is necessary but not sufficient (3.0% → 3.0% help rate); scale cannot create gradient where a term is exactly zero; and decomposing a scalar reward per aircraft is algebraically a no-op.
- Environment API gains an Action sets
section covering
"macro", the one-clearance cost, the rail refusal, and theDALT12 vsALT_MIN_SEP24 arithmetic that motivates it. - The eleventh per-aircraft feature is documented as a standing instruction is in progress rather than resume-in-progress, with the note that the slot was reused deliberately so the observation shape — and old checkpoints — survive the new action set.
- AirTraffic environment: the MDP-in-one-screen
table said the observation was
11·k·F + 2with a survival bonus and no terminal state. It is11·k·F + 4·a + 3(593 at the scoredn=5 m=5), there is no survival bonus since 0.19.0, and a mid-air terminates while every allowance merely truncates. - Fixed: the API reference still gave
collision=5.0as the default weight. It has been 10.0 since 0.18.0.
Slides¶
- New frame in the advanced deck, "The reward pays for the good move. The action space could not." — the 0.9% / 17.3% / macro table, the 0.0% false-negative result, and the one-line reason a threshold is an action-space bug rather than a reward bug. Body is now 50 pages, at the cap.
- Design-sprint Track B cites the measured macro result instead of listing macro-actions as a hunch; field note #5 notes that the macro set is a third answer to the interface question, reached by measuring rather than arguing.
[0.23.0] — 2026-08-26¶
Added — action_set="macro"¶
FL_INC2/FL_DEC2onLegacyFlightEnv— climb or descend two flight levels booked by one instruction, flown one level per step. Opt-in; the default"basic"and the existing"resume"set are untouched, so the frozen competition is unaffected.
The physics does not change. FL_INC2 at step k is byte-identical to
FL_INC at k plus FL_INC at k+1 — asserted over 8 seeds × 3 aircraft ×
both directions in the new tests/test_macro_action.py. What changes is that
the minimum effective intervention becomes one decision instead of two, and
costs one clearance instead of two.
Motivation, measured in 0.22.0: DALT is 12 and ALT_MIN_SEP is 24, so a
single altitude clearance leaves a co-level pair in conflict. One FL_INC
improves the sector in 0.9% of cases; two consecutive ones in 17.3%.
That is a threshold, and a policy sampling independently each step reaches it
with probability p².
-
tests/test_macro_action.py— the equivalence, the one-clearance cost, the rail refusal, opt-in isolation, and supersession by a later clearance. 154 tests pass insessions/03-advanced/airtraffic. -
info["climbing"]— aircraft with levels still owed.
Acceptance test — passed¶
Re-ran reward_probe.py with the macro in the command set, 46,080 injections,
24 public seeds:
| case | improves | no change | worse | mean Δcongestion |
|---|---|---|---|---|
| 1 altitude clearance | 0.9% | 96.9% | 2.2% | +0.053 |
| 2 altitude clearances | 17.3% | 81.2% | 1.6% | −0.407 |
| 1 MACRO clearance | 17.3% | 81.2% | 1.6% | −0.407 |
Exact reproduction on every ground-truth column, at half the clearance cost. The macro's own commitment profile is a smooth slope (marginal −0.407 then +0.052) — the threshold now sits inside one decision.
It lifts every reward, not only the shaped one. Mean Δ return at depth 1:
| variant | without macro | with macro |
|---|---|---|
| stock | −0.54 | −0.26 |
| endpoint | −0.14 | +0.18 |
| dominant | −0.17 | +0.48 |
| pairwise | +0.56 | +1.63 |
endpoint and dominant were negative-mean and are now positive; the help rate
for the unshaped rewards goes 3.0% → 7.8%. With the dense potential it stays
at ~21%. The two fixes are complementary and neither substitutes for the other:
the potential supplies signal, the macro supplies reachability.
Changed¶
- The eleventh per-aircraft feature is now "a standing instruction is in
progress" rather than "resuming" specifically — it covers a RESUME still
flying home and a macro climb with levels still owed. Deliberately reuses the
existing slot:
N_FEATSstays 11, the observation shape is unchanged, and old checkpoints still load. Underbasic/resumethe value is identical to before. OrderInvariantPolicy's clearance mask gatesFL_INC2/FL_DEC2on two levels of rail headroom rather than one.
Deliberate asymmetry, documented¶
At the rail the macro is invalid rather than half-executed. Two singles against the ceiling give one level plus an invalid — the exact half-manoeuvre the command exists to remove; the macro books nothing and charges the invalid. This is the only case where the equivalence breaks, and it breaks in the intended direction.
Still not established¶
That PPO converges with it. The probe measures reachability from a do-nothing policy, not optimisation. This is the point at which the training run is worth its 80 minutes.
[0.22.0] — 2026-08-26¶
Added¶
solutions/advanced/reference_solution/reward_probe.py— action-injection probes over the reward-design space, parallel over seeds, with a six-panel figure. Writesslides/data/air_probe.csv(tidy, 184,320 rows) andslides/figures/fig_probe.pdf.--quickfor a smoke test,--plot-onlyto re-draw without re-running.
It answers a question training cannot: does the reward pay for the first
useful thing an agent could try? From an all-NOOP rollout it injects one
command on one aircraft, held for depth consecutive steps, and scores every
candidate reward on the same trajectory — so cross-variant differences carry
no sampling noise at all.
Measured — the commitment threshold, confirmed¶
30,720 injections, 24 public seeds, n=5 m=5. Ground truth (d_congestion,
reward-independent), fraction of injections that improve the sector:
| depth 1 | depth 2 | depth 3 | depth 4 | |
|---|---|---|---|---|
| altitude | 0.9% | 17.3% | 17.6% | 17.8% |
| altitude, mean Δcongestion | +0.053 | −0.407 | −0.407 | −0.419 |
| speed | 5.2% | 8.2% | 9.9% | 10.2% |
| speed, mean Δcongestion | −0.020 | −0.124 | −0.185 | −0.195 |
One altitude clearance is not a weak move, it is the wrong move — it improves
the sector in 0.9% of cases and makes it worse in 2.2%, mean +0.053, the wrong
sign. Two improve it in 17.3%, a 20x jump; the third and fourth buy nothing
(marginal +0.001, −0.013). That is DALT 12 against ALT_MIN_SEP 24, seen
as a rate. 95% CIs exclude zero everywhere.
The prediction it was tested against was sharp and it held: the effect appears on
altitude and not on speed, because DVEL opens along-track separation
continuously. Speed shows a decelerating slope, no cliff.
Measured — the reward was never the bottleneck for recognising a good move¶
Splitting the same injections on whether congestion actually moved:
| case | improves | no change | worsens | FN | FP | signal on ties |
|---|---|---|---|---|---|---|
| altitude d=1 | 0.9% | 96.9% | 2.2% | 0.0% | 0.0% | 61.8% |
| altitude d=2 | 17.3% | 81.2% | 1.6% | 0.0% | 33.3% | 54.4% |
| speed d=1 | 5.2% | 90.9% | 3.9% | 0.0% | 15.3% | 59.3% |
FN = P(reward ≤ 0 | congestion improved) is 0.0% in every cell — the shaped
reward pays for every clearance that actually helped. It is not missing good
moves; there are barely any single good moves to miss.
signal on ties is what the dense potential buys: on the ~90% of injections
where the KPI does not move at all, the reward still says something 54–62% of the
time. That is the entire difference between a 3% and a 21% help rate.
Measured — the reward ladder¶
Help rate at depth 1, six variants scored on identical trajectories:
| variant | %help | mean Δ | what it adds |
|---|---|---|---|
| stock 0.20.0 | 3.0% | −0.54 | — |
| endpoint | 3.0% | −0.14 | Defect 2 fixed: timeliness as a potential |
| dominant | 3.0% | −0.17 | + congestion weight 10 → 20 |
| pairwise | 21.0% | +0.56 | + predicted-conflict potential over pairs |
| peraircraft | 19.6% | 0.00 | …over aircraft (max) instead of pairs |
Only the dense potential moves the help rate. Endpoint-charging removes 74% of the mean penalty and changes the help rate by nothing. Raising the congestion weight so one event dominates the worst timeliness bill — the lexicographic condition the leaderboard already applies — also changes it by nothing, and doubles the variance. Scale cannot create gradient where a term is exactly zero.
Two corollaries worth keeping:
- Per-aircraft decomposition of a scalar reward is a no-op.
Σᵢ ½ Σ_{pairs∋i} c = Σ_pairs c. It only buys anything if you stop re-adding
(per-head advantages in a custom loss) or change the functional form — and the
max-per-aircraft form measured worse than the pairwise sum, because max
discards every improvement except to an aircraft's single worst conflict.
- Endpoint-charging is what makes the dominance condition affordable. With a
running charge the worst timeliness bill is 3.947 × 50 = 197.4 and one
conflict dominating it needs collision ≈ 790; charged at the endpoint the
bill is bounded at 3.947 and collision = 16 suffices — 50× smaller.
Consequence — do not train against a new reward yet¶
The reward already pays for the good move; the agent cannot express it.
Committing to two consecutive steps on one aircraft is a p² event for a policy
that samples independently each step, which is exactly the observed v15
pathology: 31.5% of the agent's interventions were a single clearance, i.e.
structurally incapable of resolving anything.
The next lever is the action space — a macro-action (CLIMB_2, atomic over
two steps) or action repeat. reward_probe.py is its acceptance test: add the
macro-action to the command set and depth=1 should reproduce the depth=2 row.
[0.21.0] — 2026-08-26¶
Changed — slides¶
- Both decks cut to 50 pages in the body. Beamer's page count is
1 title + one page per section + one page per frame, so the section count is part of the budget:
| Deck | Body pages | was | Body frames | Sections |
|---|---|---|---|---|
intermediate |
48 | 64 | 39 (was 55) | 8 (was 8) |
advanced |
49 | 72 | 39 (was 60) | 9 (was 12) |
The advanced deck folds Module 4.5 into Module 4 and the post-mortem into Module 7, which buys back two pages before a single frame is cut.
-
Nothing is deleted. Every frame cut from a body is re-emitted under
\appendix / Extra slidesin the same deck — 10 frames in the intermediate, 28 in the advanced. The long-form derivations (DQN, policy gradients, the full PPO and SAC treatments), the individual field notes, theMaskablePPOdiscussion and both command cheat sheets are still there to jump to. -
The surviving frames were thinned, not just merged. Overfull
\vboxwarnings — content running off the bottom of a slide — went from 24 to 18 in the intermediate deck and the worst case from 122 pt to 19 pt; the advanced deck body has none over 18 pt.
Added¶
-
scripts/rebuild_intermediate.pyandscripts/rebuild_advanced.py. The decks are now generated from the pre-cut sources kept as*.tex.bak. Each script slices surviving frames out of its backup verbatim by line range and writes merged or rewritten frames out in full, so a frame is edited in exactly one place. AFIXUPSlist applies repo-wide facts that drifted, so a re-run cannot reintroduce them. -
\figh[width]{max height}{name}inpreamble/rlbootcamp.sty— as\figbut capped vertically as well, so a figure sharing a frame with two callout boxes cannot push them off the bottom.\figscales on width alone, which is what produced most of the overfull frames.
Fixed — slides that disagreed with the code¶
- The advanced deck no longer describes the
+100survival bonus as a live reward term. It was removed in 0.19.0. The Module 4 frame now lists the four live terms plus the mid-air terminal charge, and presents the bonus as the history it is — including what removing it did to the baselines (brick+75.04 → −524.00, controller+110.64 → −13.89). - The
LegacyFlightEnvcase-study frame is current: observation 593 at n=5 m=5 (was 552), congestion coefficient 10 (was 5), the three observation blocks, and mid-air as the only true terminal state. - The reference bar is re-measured at 0.20.0, 100 public seeds:
| agent | failed | congestion | timeliness | clearances |
|---|---|---|---|---|
| rule-based-resume | 7 | 258 | 0.0411 | 1667 |
| rule-based | 7 | 262 | 0.0958 | 2508 |
| noop | 83 | 904 | 0.0000 | 0 |
The brick now fails 83 of 100 episodes on mid-air termination, which makes
the failed_episodes-first rank key legible on the slide instead of abstract.
- envs/airtraffic/ → sessions/03-advanced/airtraffic/ in every command on
both decks.
- The advanced deck's title stamp no longer claims every number is 0.17.0; it
says which are 0.20.0 and which are not.
Known stale — not fixed here¶
The learned-agent tables (air_leaderboard.csv, airtraffic_litmus.csv, and
the intermediate deck's whole of Module 6) are still 0.17.0 measurements,
taken before DVEL 10 → 35 halved the congestion, before the survival bonus was
removed and before mid-air termination. The slides that quote them now say so.
Re-measuring needs a PPO retrain against the current observation space — old
checkpoints will not load, the shape changed.
[0.20.0] — 2026-08-21¶
Changed — breaking¶
-
The exit gate matches position, level and time — not speed.
N_FEATS12 → 11; the signed velocity-deviation feature is gone. Observation is 318 at n=2 m=3, 593 at n=5 m=5. The scored KPI never included speed, so this aligns the observation with what was already being measured.RESUMEstill restores speed as well as altitude — you must return to nominal or you keep accumulating schedule error. -
RESUMEis gated on the traffic picture. It is legal only when the aircraft is off its level, is not already resuming, and has no predicted conflict.
The gate exists because the action's value flips sign sharply and the asymmetry
is brutal. Injecting RESUME into a trained policy's rollout:
| from step | Δ shaped return | Δ congestion |
|---|---|---|
| 2 | −249.75 | +54 |
| 6 | −92.37 | +60 |
| 14 | −28.69 | +23 |
| 18 | −1.71 | +9 |
| 22 | +1.62 | +1 |
| 26 | +2.04 | 0 |
| 42 | +2.11 | 0 |
The downside is a hundred times the upside. A policy sampling it uniformly correctly learns "never" — right on average, wrong in exactly the states where it pays. Masking removes the bad half instead of asking the agent to learn its way around it.
RESUMEis inert rather than invalid when it would change nothing: already on plan, or already resuming. No clearance charged, no invalid, no action-history entry. Charging those as invalids was teaching the agent the command was dangerous during precisely the early phase when every aircraft is still on plan — before it had ever seen the case where the command pays.
Added¶
AutoEntropyCallback— SB3's PPO has noent_coef="auto"; the parameter is typedfloatandPPO.trainhas no auto path. Only SAC has one. This ports SAC's dual-variable mechanism:
The project has now failed in both directions with a fixed coefficient —
diffusing toward uniform when too high, collapsing onto NOOP when too low.
Neither failure was about the coefficient; both were about the entropy, and the
mapping between them shifts as the reward scale and advantage normalisation
change underneath. --auto-entropy, --auto-entropy-target.
--target-kl, default 0.03. SB3's default isNone, i.e. unbounded, so this is a new constraint rather than a loosened one; the epoch loop stops at1.5 × target_kl.--timeliness-scaleexposes the endpoint-potential weight (default 20).
Changed¶
--warmup-fracreplaces--warmup-steps, default 0.01 (15k of 1.5M, was 100k / 0.067).
[0.19.0] — 2026-08-20¶
Removed — breaking¶
- The survival bonus.
+MAX_CLEARANCESwas paid to every agent that reached the horizon, do-nothing included, which made it simultaneously the largest term in the return and the only one no policy could influence. Decomposed per episode at n=2 m=3 it was +100.00 of the brick's +75.04 total — the agent was being paid, overwhelmingly, for existing, and every real signal was a rounding error against it.ShapedFlightEnv's per-stepalive_bonus(the same money paid out differently) went with it.
Effect on the stock return, 20 public seeds:
| scenario | agent | with bonus | without |
|---|---|---|---|
| n=2 m=3 | do-nothing | +75.04 | −524.00 |
| n=2 m=3 | rule-based | +110.64 | −13.89 |
| n=5 m=5 | do-nothing | — | −138.50 |
| n=5 m=5 | rule-based | — | −35.33 |
Added¶
- Mid-air termination. Two aircraft at the same flight level within
COLLISION_RADIUS(100 m) end the episode withterminated=Trueand a hard charge. This is the only genuine terminal state in the environment — the thing it exists to prevent has happened, so the state's value is zero.
The radius was chosen by measurement, not assertion. Over 40 seeds, do-nothing
brings co-level traffic within 2–10 m; the rule-based controller's closest
co-level approach at n=2 m=3 is 435 m. At 100 m the brick collides in 97.5%
of episodes and the controller in none, so the threshold separates the two
behaviours rather than labelling both. Note this is a different question from
find_congestion, which fires at 1000 m and two levels — that is a procedural
violation you fly out of.
The penalty is max_steps × collision / divisor — one conflicting pair held for
the whole horizon, charged in the units of the congestion term it replaces.
Deliberately independent of when the collision happens: sizing it on the
steps remaining would make a mid-air at step 49 nearly free, which invites
reckless flying near the horizon.
info["collided"],_is_collision(),collision_penalty().
Changed — breaking¶
- Running out of an allowance now truncates rather than terminates. Both the
clearance budget and the invalid-action allowance set
truncated=True, and the old−MAX_CLEARANCEShard penalty for invalid overflow is gone. Only a mid-air terminates. The distinction is not cosmetic: a truncated state still has a future worth bootstrapping from, a terminal one does not. competition/kaggle/metric.pymirrors mid-air termination, verified by the existing harness-parity test.
Consequence worth watching¶
- A near-do-nothing policy now dies at around step 10. That is the truncation cliff reappearing in a new form — but this time the episode ends because the problem happened, which is the right moment to end it, rather than because the agent ran out of allowance before the problem arrived.
- The do-nothing baseline now fails 20 of 20 scored episodes and ranks last on
failed_episodes, which is the rank key working exactly as designed.
[0.18.0] — 2026-08-20¶
Added¶
action_set="resume"onLegacyFlightEnv— a sixth command,RESUME("resume own navigation"). One instruction, one clearance charged, and the aircraft flies itself back to its entry level and speed at one increment per step. Opt-in; the default"basic"is unchanged.
Motivation, measured: a trained policy flew the outbound manoeuvre correctly —
climbing exactly two flight levels, precisely ALT_MIN_SEP / DALT — and then
never came home. 0% of touched aircraft ended on plan against 95.8% for the
rule-based controller. A timed return costs zero extra congestion and
improves the shaped return, so the reward already wanted the behaviour; the
agent could not find it. Each clearance is drawn independently, so a
half-finished manoeuvre is as likely as a finished one.
PriorityLevelController(action_set="resume") uses it: same safety, same
timeliness, 34–36% fewer clearances.
-
Hierarchical policy head —
OrderInvariantPolicy(head="hierarchical"). Stage one picks an aircraft or idles; stage two picks that aircraft's clearance from its own embedding. Written aslog p(i) + log p(c | i)over the unchanged flat action space, so it remains a singleCategoricaland SB3'slog_prob, entropy and KL stay correct. Requiresaction_mode="single". -
Observable action history — a ring of the last
a_framesclearances issued,(valid, aircraft, command, age)each. A ring of events rather than one slot per step, which is whyageis explicit. The policy scatters each event back onto the aircraft it names, so permutation equivariance survives a block that refers to aircraft by index. -
invalid_headroom()as a third global scalar, and a resume-in-progress flag as the twelfth per-aircraft feature. The action ring shows that aRESUMEwas issued, never whether it completed.
Changed — breaking¶
DVEL10 → 35. At 10 a speed clearance opened 20 m of along-track offset per step against a 1000 m conflict radius — 50 steps of a 50-step episode — so neither the trained agent nor the controller ever issued one, and the along-track term of the timeliness KPI was always exactly zero.
It helped, and it did not make speed competitive. Conflicts cleared by holding one clearance for two steps: speed 0% → 35%, altitude 76–82%, do-nothing 29%.
It also halved the problem. Faster traffic transits the hotspot sooner:
| scenario | agent | was | now |
|---|---|---|---|
| n=2 m=3 | do-nothing | 180 | 88 |
| n=2 m=3 | rule-based | 41 | 11 |
| n=5 m=5 | do-nothing | 762 | 380 |
| n=5 m=5 | rule-based | 129 | 68 |
Every reference figure measured before this is stale.
-
Default congestion coefficient 5 → 10, forced by the above. With half the congestion the congestion term halved while the clearance cost did not, and the stock return ranked do-nothing above the rule-based controller at three of four configurations. 10 restores a clear margin everywhere. General rule: halve the congestion, double the weight.
-
The observation is entirely in
[-1, 1]and is now three blocks — flights, actions, globals.N_FEATS11 → 12,N_GLOBALS2 → 3. Shape is12·k·F + 4·a + 3: 343 at n=2 m=3, 643 at n=5 m=5. Old checkpoints will not load against the new space.
Fixed¶
agent_action_setread onlyagent.action_space. A hand-written controller has none, sorule-based-resumewas scored in abasicenvironment where everyRESUMEwas rejected as invalid and no aircraft was ever handed back its level — turning the best baseline into a worse one, silently. Agents may now declare their command set.
Measured, not changed¶
- Pinning
COLL_DIVISORto the scored value made things worse. At n=2 the divisor is 1 against 4 at n=5, which looked like a confound worth removing. Pinning it collapsed both runs to zero clearances (congestion 165) while the unpinned run reached 62. The apparent artefact was the only thing making safety loud enough to learn from. - With the divisor left alone,
RESUMEtook KPI timeliness from ~0.28 to 0.095 — but congestion rose from 5 to 62 in the same run. Coming home re-exposes an aircraft to traffic, and congestion ranks first.
Documentation¶
- New: The rule-based controller — the
full algorithm, both phases, the arithmetic behind
SEP_LEVELS = 2, theRESUMEvariant, what it deliberately does not do, and four specific weaknesses to attack.
[0.17.0] — 2026-08-17¶
Added¶
CollisionCourseSimulator.advance_into_airspace()— uniformly time-shifts a freshly built scenario until every flight is inside the airspace box.
reverse_to_limit deliberately rewinds each flight until it is outside the
sector, so that it flies across rather than starting mid-way through. Left
there, the episode opens on an empty screen: positions are normalised by
airspace_limit, so an aircraft that has not arrived yet reads outside
[-1, 1] and the agent is asked to plan around traffic it cannot properly see.
The shift is uniform — relative geometry, closing speeds and arrival order are
untouched, only the origin of the clock moves. Measured over the 20 default
seeds: 16–36 s of shift, every aircraft inside at t=0.
-
action_modeonLegacyFlightEnvandOrderInvariantPolicy. The default"simultaneous"is unchanged (MultiDiscrete([5]*n_flights));"single"givesDiscrete(1 + 4*n_flights)and commands at most one aircraft per step, which is closer to how a controller works and makes the clearance budget bite. Permuting aircraft permutes the four-logit blocks and leaves NOOP alone, so equivariance survives.decode_action()maps both onto the same command vector, so the simulator, KPIs, renderer and submission CSV are unaffected. -
budget_remaining()as a second global observation scalar — see the corrected 0.16.0 entry. The observation is 552 values at the defaults.
Changed — breaking¶
advance_into_airspacechanges every scenario. A seed no longer names the same initial state, so every reference number in the handbook was re-measured against current code. The orderings did not change; the magnitudes did.
| table | was | now |
|---|---|---|
| competition, rule-based congestion | 168 | 129 |
| competition, noop congestion | 778 | 762 |
| baseline n=5 m=5, rule-based return | 71.55 | 69.68 |
| baseline n=5 m=5, do-nothing return | 53.29 | 54.71 |
Fixed — documentation that disagreed with the code¶
- The observation was documented as
551values with one trailing scalar in three places. It is552with two. - The
noop_biasmeasurement was quoted from a pre-shift run throughout. Current numbers: a scenario contains 40.32 conflict events (was 41.57), the busiest stretch is steps 10–21 (was 20–35), 62.7% occur at step 15 or later (was 89.4%), and an unbiased policy experiences 16% of them (was 3.8%). - The claim that
noop_bias=3.5lifts first-iterationep_rew_meanfrom 22.0 to 71.2 did not reproduce. Measured on the reference setup it is −23.9 → 17.6 — still below the ~54.7 do-nothing baseline. The docs now say what the bias actually buys: presence in the second half of the episode, not competence. --collision-coeffwas documented as defaulting to1.0; it is5.0. Eight flags were missing from the training table, and theconf/default.yamlexcerpt did not match the file.- The environment docstring, the package README and
train.py --noop-biashelp all still described the 6-feature observation.
Reference numbers — the PPO retrain¶
Three runs, 1.5M steps each, n=5 m=5, seed 123, identical hyperparameters;
evaluated over 30 seeded deterministic episodes (best_model.zip):
| agent | return | ep_len | congestion / ep | clearances | touched |
|---|---|---|---|---|---|
| rule-based | 69.68 ± 9.73 | 50.0 | 8.0 | 32.8 | 4.0 |
| do-nothing | 54.71 ± 13.50 | 50.0 | 36.2 | 0.0 | 0.0 |
PPO multi-head, noop_bias=3.5 |
51.90 ± 14.94 | 50.0 | 36.1 | 0.9 | 0.6 |
| random control | −29.36 ± 9.47 | 14.5 | 6.7 | 115.1 | 10.0 |
| PPO multi-head, unbiased | −26.64 ± 8.38 | 17.9 | 9.0 | 104.1 | 9.9 |
PPO flat MlpPolicy |
−44.26 ± 35.56 | 14.4 | 4.4 | 131.5 | 10.0 |
Two distinct failures, and the distinction is the teaching point:
- The unbiased runs never reach the problem. They truncate around step 14–18, and the multi-head one is statistically indistinguishable from random actions.
- The biased run collapses onto the brick. It reaches the horizon and then
learns that the best thing it knows how to do is nothing: 0.9 clearances per
episode, 0.6 aircraft touched, congestion within noise of do-nothing. Its
final_model.zipis the do-nothing policy exactly — 0.0 clearances, return 54.57. The survival bonus is+100and unconditional, so the dominant term pays for existing.
Fixing the truncation got the agent to the end of the episode. It did not give it a reason to do anything once it got there.
[0.16.0] — 2026-08-14¶
Added¶
CollisionCourseSimulator.predict_conflicts(horizon)— the conflicts that will occur if nothing changes, in closed form. Absent a clearance every track is a straight line, so solving|r₀ + Δv·t| = hotspot_limitper pair gives the conflict window and its vertex gives closest approach; no rollout is needed.
Each record carries t_enter, t_cpa, duration, d_cpa, d_alt, center,
radius and severity. Severity decays exponentially in both separations,
so a pair passing head-on at co-altitude scores near 1 and one clipping the zone
boundary scores near e⁻¹ — the ordering that lets a controller triage.
Validated over 30 seeds: 342/345 predicted conflicts occurred, no real conflict
was missed, timing error median −1.21 s against DT=2. The 3 that did not
occur averaged severity 0.240 against 0.929 for those that did.
LegacyFlightEnv.conflicts()— the same list, cached per simulator advance, so the observation and any reward built on it share one evaluation.
Changed — breaking¶
- The observation is now 11 features per aircraft, not 6 (
552values atn=5, m=5, k_frames=5, was301). Every stored checkpoint is unloadable. The five new features:
| # | Feature | Why |
|---|---|---|
| 6 | worst predicted conflict severity | find_congestion only fires once a conflict has already happened — far too late to guide the clearance that would have prevented it |
| 7 | squashed time until that conflict | when is as actionable as whether |
| 8–10 | signed log-scaled error on the 5D exit gate — schedule, altitude, velocity | exactly what timeliness is scored on, so the agent can see its own scored quantity rather than infer it |
Deviations are signed (two levels high and two low need opposite
corrections; a magnitude cannot say which) and log-scaled (log1p is
steepest at zero, which is the target), clipped at the full physical range.
- A second global scalar:
budget_remaining(), the fraction of the clearance budget still unspent. The episode truncates when that budget runs out, so without it the agent is rationing a resource it has no reading of — and the horizon it is spending against is where the survival bonus pays out. Both globals bypass the order-invariant policy's pooling and enter the context directly, since neither belongs to any one aircraft.
Fixed¶
_signed_logwas applied per scalar, 31 times a step, and numpy scalar dispatch cost more than the conflict prediction it sits beside. Vectorised: the enriched environment now steps in 0.93 ms, marginally faster than the old one.
[0.15.0] — 2026-08-14¶
Fixed¶
render()did not show the trajectory that was actually flown. Itdeepcopy-ed the final simulator state and calledreverse_clock()back to the start — which only undoes position. Altitude and velocity are mutated in place by clearances and cannot be run backwards, and the recorded clearances were read only to pick a marker colour, never re-applied.
The result: every aircraft was drawn at its end-of-episode flight level from frame 0. A climb that resolved a conflict was invisible, because the separation looked like it had always been there. Measured on the rule-based controller, seed 3: altitude error up to 96 (eight flight levels), and only 4 of the 9 flight levels actually flown ever appeared. If an agent used velocity clearances the positions were wrong too, since the rewind used the final velocity.
LegacyFlightEnv now records a per-step state_history and render() replays it.
Altitude error is 0.000 and all flight levels appear.
Changed¶
- Conflict circles in the render are drawn once per frame rather than once per
aircraft (they were being layered
n_flightstimes), and atalpha=0.55instead of0.1so they are actually visible. - The render title now reports live conflicts alongside the running maximum.
Added¶
LegacyFlightEnv.state_history—(n_flights, 5)of(x, y, altitude, velocity, heading)per step, in raw simulator units.- Contract test asserting the recorded trajectory matches the flown one (42 total).
[0.14.0] — 2026-08-13¶
Added¶
- Reward design — a new reference page documenting the whole reward: what each of the four terms is for, the measured decomposition of where the episode return comes from, the two places the stock formulation works against the ranking it serves, and why potential-based shaping is the safe knob to turn.
Fixed¶
- The API reference's reward section documented three terms; the environment has had four since timeliness landed. Corrected, with the default weights and a pointer to the design page.
[0.13.0] — 2026-08-13¶
Added¶
OrderInvariantPolicy(noop_bias=...)— an initial logit bias towards NOOP, defaulting to0.0so no existing baseline changes.
It exists because of a measurement. A freshly initialised policy is near-uniform
over five clearances, so ten aircraft issue ~8 clearances per step and the
environment truncates at MAX_CLEARANCES around step 13. But the busiest
stretch is steps 10–21 and 62.7% of loss-of-separation events happen at step
15 or later. An unbiased policy therefore experiences 6.45 of the 40.32
conflicts in a scenario (16%) and never once reaches the horizon.
It is not failing to solve the problem so much as not being shown most of it. No reward change can fix that, because the states in question are never visited.
With noop_bias=3.5 (p(NOOP) ≈ 0.89) every episode reaches the horizon, 79%
of conflicts are experienced, and first-iteration ep_rew_mean goes −23.9 →
17.6. That is still below the ~54.7 do-nothing baseline: the bias buys
presence, not competence. Students hitting a flat learning curve on the Flight
Challenge should reach for it first anyway.
Changed¶
- Reference trainer (
solutions/, not shipped) gained a linear warm-up → half-cosine learning-rate schedule ported from the the prior ATC project single-agent ATC trainer, and now defaults to 2M steps.
Tests¶
- Two regressions on
noop_bias: that the default stays off, and that a biased policy reaches the horizon where an unbiased one provably does not (41 total).
[0.12.0] — 2026-08-13¶
Added¶
- Post-mortem harness (
competition/postmortem.py,competition/perturb.py,python -m competition.run postmortem). A leaderboard position is one measurement of one policy under one set of conditions; these passes ask what it cannot:
| Pass | Question |
|---|---|
| do-nothing | Does it beat the brick? |
| stochastic | How much of the score survives sampling instead of argmax? |
| next-best | Swap every decision for the runner-up — how much rested on thin margins? |
| ε-random | Corrupt actions with probability ε ∈ {0.05, 0.1, 0.25}. |
| best-extraction | k stochastic rollouts per seed, keep the best. |
-
Trophy computation (
award_trophies) for Golden Holding Pattern, Zero-Conflict Wings, Minimal-Intervention, Iron Stomach and The Brick with Wings. Black Box and Icarus are deliberately not computed — they are jury calls, and pretending a metric decides them would be worse than admitting a human does. -
15 post-mortem tests, covering perturbation semantics, pass skipping and trophy eligibility.
Design notes¶
- ε perturbation is per aircraft, not per step. The action factorises across flights, so corrupting the whole vector at once would be a far blunter instrument than the robustness curve is meant to be.
- Heuristics skip the passes that need an action distribution and say so,
rather than reporting a fabricated one. Their stochastic gap is zero because
they have nothing to sample from, which is why
Iron Stomachexcludes them — a robustness trophy must not go to something that was never perturbed. best_extractionseeds torch explicitly. Sampling draws on the global RNG, so without it "best of k" changes between runs and the number cannot be quoted.- The do-nothing baseline is not scored against itself: it reports as the bar rather than as a competitor that failed to beat itself.
Observed¶
Random perturbation makes the do-nothing baseline safer — congestion falls from 253 to 146 at ε=0.25 over 8 seeds. Random clearances scatter aircraft across flight levels, and scattered aircraft do not conflict. A participant reading "my agent got safer when I added noise" as evidence of robustness has found this effect, not a good policy, which is exactly why the pass is worth showing them.
[0.11.0] — 2026-08-13¶
Added¶
- Kaggle public-leaderboard metric (
competition/kaggle/).metric.pyis the custom evaluation metric;export.pywrites the solution file.
The metric cannot import this package — Kaggle's sandbox has no gymnasium, no matplotlib and no bootcamp code — so it is necessarily a second implementation of the rules, and drift is the standing risk. Two things contain it:
- The scenarios ship in the solution file. The metric never regenerates a scenario from a seed, it re-flies one it was handed, so the whole scenario generator is out of scope for divergence. 50,000 rows / ~4.6 MB for 100 seeds.
-
An equivalence test pins the rest.
test_kaggle_metric.pyasserts the metric and the real environment agree on every KPI across four action distributions and the rule-based controller. Change the dynamics or the KPIs and that test is what reports the metric went stale. -
Lexicographic ranking packed into one float. Kaggle sorts a single number, so
pack()folds(failed, congestion, timeliness, clearances)positionally: each component clamped to a documented bound, weighted by the product of all lower-priority ranges. The span stays under2**53so the value is exact in float64 — tested, because losing precision here would silently reorder the board rather than raise anything. Out-of-range values clamp rather than wrap, so an absurd submission saturates at the bottom instead of reappearing at the top.
The fifth key component, distinct aircraft touched, does not fit that budget and breaks ties on the private leaderboard only.
- Participant-facing errors are raised as
ParticipantVisibleErrorwith the fix attached (rebuild withcompetition.run submit, actions must be 0–4, and so on). - 15 metric tests, including one asserting the metric never imports torch, gymnasium, matplotlib or SB3.
[0.10.0] — 2026-08-13¶
Added¶
- Competition scoring harness (
sessions/03-advanced/competition/). We own the score; students own their environment and policy.
Ranking is lexicographic and every component is lower-is-better:
(failed_episodes, congestion_total, timeliness_bucket, clearances_total, involved_total)
└ safety ──────────────────────┘ └ timeliness ────┘ └ efficiency ─────────────────┘
Three decisions in there are load-bearing rather than cosmetic:
failed_episodesranks before safety. An episode that ends early accumulates fewer congestion events purely by existing for less time, so ranking on safety alone would make crashing out a strategy. This is also why completion is measured from the step counter and not fromtruncated— LegacyFlightEnv sets that flag both at the horizon and when the clearance budget runs out, so the flag cannot tell success from an early exit.- Timeliness is bucketed. It is a float, so exact ties never occur, so efficiency could never break one and the third objective would be decoration.
-
Replay is numpy-only. The public leaderboard re-flies a submitted action trace inside Kaggle's metric sandbox, where torch is unavailable;
agents.pyimports SB3 lazily and a test asserts torch never enterssys.moduleson the replay path. -
Action-trace submissions. One row per (seed, step, aircraft), so there is no aggregate number in the file to inflate — the score comes from re-flying the actions.
rollout_agentandreplay_actionsare verified to agree to the last digit oftimeliness_mean, which is what makes the public board trustworthy. - Seed lists. 100 public seeds ship with the repo; the private list is derived
from a salt, gitignored, and regenerable from the salt alone
(
python -m competition.make_seeds). - CLI —
python -m competition.run {submit,replay,compare}. - 19 scoring contract tests covering objective priority, the early-exit exploit, bucketing, submission validation and replay fidelity.
Reference numbers¶
20 public seeds, deterministic, score v1.0.0:
| # | agent | failed | congestion | timeliness | clearances | touched |
|---|---|---|---|---|---|---|
| 1 | rule-based | 0 | 129 | 0.0348 | 623 | 80 |
| 2 | noop | 0 | 762 | 0.0000 | 0 | 0 |
The brick is perfectly on time and perfectly efficient — it never touches an aircraft — and loses anyway, because safety is compared first.
[0.9.0] — 2026-08-13¶
Added¶
policies.OrderInvariantPolicy— now the default policy. Ported from the the prior ATC project project'sATCEncoder: a shared per-aircraft MLP with LayerNorm, masked mean/max pooling for traffic context, and one shared head per flight that sees[own embedding | context].
Traffic is a set, not a vector. SB3's flat MlpPolicy ends in a single
Linear(hidden, 5·n_flights), so the weights choosing flight 0's clearance are
separate from those choosing flight 7's — one idea has to be learned once per
slot, and swapping two aircraft produces an unrelated observation. The shared
encoder makes permutation equivariance hold by construction; the contract
tests assert it to 1e-5, and value invariance is exact.
Clearances that would run past an altitude or speed rail are masked at the
logits, which drives invalid clearances to exactly zero in rollout. NOOP
is always legal, so no row is ever fully masked.
train.py --policy mlp keeps the flat baseline for comparison, and --no-mask
isolates masking from weight sharing.
the prior ATC project's own policy is autoregressive (aircraft head → clearance head) because it issues one clearance at a time. LegacyFlightEnv commands every flight simultaneously, so there is no "which aircraft" decision to factor out — the encoder ports, the head does not. Adopting the autoregressive action space would cap interventions at
max_steps, and the rule-based baseline already needs 63.6 clearances atn=12, m=3.
- Ten new policy contract tests covering equivariance, value invariance, mask legality, zero probability on masked actions, and a training smoke test.
Changed¶
- Observation is now a 6-tuple per frame, adding normalised time-to-exit: how long an aircraft has left in the sector on its current track. Without it the agent can see where traffic is but not how long it stays a problem — and it is what makes "which aircraft should I delay?" answerable, since delaying one that is about to leave is nearly free.
Computed by ray-vs-box slab intersection (CollisionCourseSimulator.time_to_exit),
not "first wall crossing ahead": flights spawn outside the sector and fly in,
so the first crossing ahead is the entry, and reporting it would make an
aircraft that has not arrived yet look like one about to leave.
Squashed as t / (t + horizon) rather than clipped to [0, 1]. Clipping pinned
every flight at 1.0 for the first third of the episode — they all spawn outside,
so they all exit later than the horizon — leaving the feature with no gradient
exactly when the agent is choosing whom to touch.
k_framesdefault 10 → 5. With six features per frame the observation is6·5·7 + 1 = 211atn=5, m=2.
[0.8.0] — 2026-08-13¶
Changed¶
- Observation stacks
k_frames=10history frames instead of current+previous (LegacyFlightEnv(k_frames=...), class defaultK_FRAMES = 10). Per flight the layout is[now | now-1 | ... | now-(k-1)], newest first, so index 0 is still the current state andobs[:5]still reads as before.
A single frame is not Markovian here: position alone cannot say whether an
aircraft is accelerating, or how long two flights have been closing. One
difference gives velocity but not acceleration. Ten frames put the recent
trajectory of every aircraft in the observation, which widens the scope of the
MDP the policy is solving. k_frames=2 reproduces the old observation exactly.
[!WARNING] This invalidates every checkpoint trained before this release. For
n=5, m=5the observation goes from 101 to 501 dimensions and SB3 refuses to load:Unexpected observation shape (501,) ... please use (101,). The reference PPO runs undersolutions/need retraining, and the trained-agent rows in the baseline and syllabus tables are marked pending retrain until they are.
- Default congestion weight raised to
collision=5.0(was1.0). At the old weight a congestion event cost less than the clearances needed to prevent it, so doing nothing out-scored the rule-based controller — the reward ranked agents in the opposite order to the KPIs, and an agent trained on it learns to stop intervening. Safety is priority 1 in the competition ranking and the reward now says the same thing.
The old behaviour is one flag away and is now an exercise rather than an
accident: evaluate_baseline.py --collision-coeff 1.0 inverts the ranking on
demand (at n=12, m=3: noop 86.4 vs rule-based 64.7, while causing 2.7× more
congestion).
- The rule-based baseline flies aircraft home.
PriorityLevelControllerused to park each aircraft at its deconfliction level and freeze it there, which under the new timeliness KPI is a job left half done. It now has a second phase: once a flight is diverging and a hotspot radius clear of every other converging flight, it is returned to its entry level.
The separation trigger matters more than it looks. All converging flights share
an entry level, so a naive "recover once past the hotspot" rule re-creates the
conflict it just resolved — congestion at n=8, m=0 went up from 21.6 to
41.9 before the trigger was keyed on separation from peer traffic instead of
distance from the hotspot. Tuned (RECOVERY_SEP_FACTOR = 1.0) it now beats the
old non-recovering controller on both axes: congestion 19.6 vs 21.6, and
timeliness 0.08 vs 1.08.
Added¶
evaluate_baseline.py --collision-coeffand--k-framesfor reproducing the reward-inversion and history-depth comparisons from the CLI.- Six new contract tests covering stack depth, padding at reset, newest-frame-first ordering, and the KPI keys.
Fixed¶
decode_observation()no longer assumes a 2-frame observation. It takesn_flightsork_frames(exactly one) and derives the other; length alone cannot separate "many flights, few frames" from the reverse.
[0.7.0] — 2026-08-13¶
Changed¶
- Directory names no longer contain spaces. The two space-named session
directories made every
cd, path and shell snippet awkward. The repository is now laid out by session, with the shared AirTraffic package hoisted to the root:
| Was | Now |
|---|---|
Tutorial Hands-On Session: Tabular RL & Discrete MDPs/ |
sessions/01-fundamentals/ |
RL Intermediate .../Crippled Ant/ |
sessions/02-intermediate/crippled-ant/ |
RL Intermediate .../Airtraffic/ |
envs/airtraffic/ |
AirTraffic moved out of the intermediate directory because it is used by both
Session #2 (the litmus test) and Session #3 (the competition) — filing it under
one of them was misleading. sessions/03-advanced/ is new and holds the
competition material. Every path reference in the docs, notebooks and READMEs
was updated; the moves are pure renames, so history is preserved.
- Intermediate exercises renumbered
1–8. They were numbered by module (3.1,3.2,4.1, …), which meant the first thing a participant does was labelled "Exercise 3.1" — implying two earlier exercise sets that never existed, since Modules 1–2 are theory. Numbering now runs straight through the session.
Added¶
- Competition rules page (
docs/sessions/competition.md) — the student-facing statement of the Flight Challenge: the three ranked objectives, the submission zip format, the contract-test gate, and the public/private leaderboard split. - The scoring objective is now stated precisely: safety, then timeliness, then efficiency, compared lexicographically.
- Timeliness KPI and the 4D exit point (
LegacyFlightEnv._exit_deviation,timeliness_penalty). Every aircraft enters with a plan — a point, an altitude and a time it was headed for. A clearance is a detour, not a new destination: an aircraft slowed to open a gap must speed back up, one stepped down a level must climb back. The KPI is the log-scaled distance still remaining to that plan, and it closes the degenerate "slow every aircraft down" solution that would otherwise top a safety-only leaderboard. Untouched traffic scores exactly zero by construction. Also added as a reward term (rew_coeffs.timeliness). step()now returns a populatedinfodict —congestion_events,timeliness,exit_deviation,clearances,aircraft_involved,invalids. It previously returned{}, so every KPI had to be scraped out of environment internals. Four new contract tests assert the keys and their anchor behaviours, and a student environment that drops one cannot be scored.
[0.6.0] — 2026-08-07¶
Changed¶
- Setup is now documented in exactly one place (board task A1). This
repository had its own
docs/setup/— installation, Conda workflow and troubleshooting — that shadowed the public participant primer, and the two had already drifted apart. The three pages are replaced by a single Setup pointer carrying the quick-start commands, the notebook locations and the two problems that really are ours (No module named 'envs', the docs toolchain). Everything else links to the primer, which is year-agnostic, public and permanent. environment.ymlis now byte-identical to the primer's, so cloning either repository produces the same environment. The MkDocs toolchain moved out of it intorequirements-docs.txt— participants never needed it.- The AirTraffic and Crippled Ant quick-starts pointed at per-folder
requirements.txtfiles. Both were subsets ofenvironment.yml, and AirTraffic'sgymnasium>=0.29would have installed a pre-1.0 API. Removed; both READMEs now sayconda activate rlbootcamp. - The navigation is reorganised around three questions, replacing a single Tutorial Guides tab holding a flat list of eight pages in no particular order. Environments — what each MDP is (maze, Crippled Ant, AirTraffic). Running — the commands to train, evaluate, render and sweep. Sessions — what happens in the room. Within each tab, pages follow session order.
The two usage guides each described an environment and how to drive it, so
each was split at its natural seam: airtraffic-usage.md → the environment
page plus separate training and evaluation pages; crippled-ant-usage.md →
environment plus running. The maze MDP was extracted out of the Fundamentals
page so all three environments can be read side by side.
Removed¶
RL_bootcamp_2026_code_setup_information.pdfand its copy insideTutorial Hands-On Session: Tabular RL & Discrete MDPs/. Identical text, two different checksums, and both described avenv+requirements.txtinstall that no longer exists — plus a Colab flow that generates an SSH key and clones a private repository, which cannot work for a participant. The Fundamentals README and guide now link to the primer.
Added¶
validation: anchors: warninmkdocs.yml. Now that every setup link leaves this site, a renamed heading in the primer should break the build rather than quietly rot.
Open decision — reward restructuring. The LegacyFlightEnv survival bonus is
paid as a terminal lump at max_steps, but the episode also truncates once the
clearance budget is spent, which early policies burn in 13–17 steps. The
dominant reward term is therefore unreachable for most of training. Paying it
per step would fix this — but it changes the environment and invalidates every
reference number published in 0.5.0, so it needs deciding before the solutions
bundle is distributed.
[0.5.0] — 2026-08-06¶
Added¶
- A reference solution for AirTraffic exists at last. The Intermediate
syllabus has always promised "PPO, pre-trained checkpoint provided" while no
checkpoint existed and
**/runs/is gitignored — there was no distribution mechanism at all. There is now a solutions bundle (checkpoints, a standalone evaluation script, a renderer, pre-rendered episodes and a written analysis) distributed from the event website rather than from this repository, since the run takes ~80 minutes. - Track F — Policy Architecture in the Advanced syllabus, plus a summary on
the Advanced session page: a shared per-aircraft encoder
with symmetric pooling and factored aircraft → clearance heads, masking
inside the policy. Worth +50 return over the flat
MlpPolicyand it drives invalid clearances from 32.6 per episode to zero. - Module 5.5 — Field Notes: What Actually Breaks, six lessons carried over from a production RL prototype into the Advanced syllabus.
- Measured
n=5, m=5results on the Rule-based baseline page, covering both trained agents, both baselines and a random control, with the congestion-per-step normalisation and the reason the random row belongs in every table.
Changed¶
- Intermediate syllabus audience clarified: Session #2 is for RL users,
Session #3 for RL practitioners who design the MDP. Dead material removed
(an inline
CrippledAntsketch superseded by the realenvs/crippled_ant.py, a cut DDPG module, a resolved dependency question). - Exercise 6.1/6.2 now set expectations honestly: with the shipped
configuration, participants will produce a failing agent. That is defensible
as a deliberate lesson — but it is now stated, and
ep_len_meanis handed over as the diagnostic, so nobody concludes they broke something.
Fixed¶
Ant-v5's observation was documented as 27-dimensional; it is 105.- The Advanced session page still described the rule-based baseline controller as "in progress". It landed several versions ago and is the best-performing agent in the table.
- The same page presented
MaskablePPOwithout its central limitation: it consumes a flat mask and cannot express a mask conditioned on a sampled part of the action. Anyone following it into a factored action space hit a wall with no warning.
[0.4.0] — 2026-08-04¶
Added¶
- Session #1 (Fundamentals) is now part of this handbook. New
Fundamentals guide covering the maze notebooks,
their MDP formulation, how to run them locally rather than in Colab, and what
carries forward into Session #2. The session was previously invisible here —
index.mdlisted it as "owned by the Fundamentals team" with no link. - Notebook check in the installation guide, and a notebook section in
conda-environment.mdlisting which notebooks belong to which session.
Fixed¶
environment.ymlshipped no Jupyter at all, while Session #1 is delivered entirely as notebooks andconda-environment.mdclaimed JupyterLab was included. Anyone building the environment from this file and following the docs hitjupyter: command not found; the documented workaround was an ad-hocpip install jupyterlab ipykernel. Addedjupyterlab,notebookandipykernelas proper conda dependencies.notebookis included alongsidejupyterlabso bothjupyter labandjupyter notebookwork — under Notebook 7 they share one stack.- The
pip-only fallback install ininstallation.mdomitted Jupyter andgymnasium[classic_control], so it produced an environment that could not run Session #1 at all and could not render classic-control environments. index.mdstill advertised docs version0.0.1whileVERSIONread0.3.2.
Known issues (not yet fixed — see the volunteer task sheet)¶
- The Session #1 notebooks open with five Colab-only cells
(
drive.mount,ssh-keygen,git clone,!pip install). Run locally,from google.colab import driveraisesModuleNotFoundErroron the first cell. Verified: with those cells stripped, all five notebooks execute cleanly end-to-end inrlbootcamp. The guide documents the workaround; the notebooks themselves still need fixing. Solutions/contains only Simple maze solutions.ipynb; the folder's own README advertises four.
[0.3.2] — 2026-07-23¶
Fixed¶
pygamewas missing fromenvironment.yml. Rendering any classic-control environment (CartPole, Pendulum, MountainCar) raisedDependencyNotInstalled: pygame is not installed— including when recording video, not only when opening an on-screen window. Training is unaffected, so the failure surfaces late and looks unrelated to setup. Added viagymnasium[classic_control].
No Session #2 or #3 code path hits this (AirTraffic renders through
Matplotlib, Crippled Ant through MuJoCo), so this is preventive: participants
experimenting with the standard SB3/Gymnasium starter examples would have hit
it. Found while building the public participant primer.
- mkdocs.yml's extra.version default had drifted to 0.0.3 while VERSION
read 0.3.1, so the version chip in the docs header showed the wrong number
on any build without DOCS_VERSION set. Both now read 0.3.2.
[0.3.1] — 2026-07-16¶
Fixed¶
- VecNormalize statistics were mispaired with mid-training checkpoints.
SB3's
EvalCallbacksavesbest_model.zipbut not the normalisation statistics behind its score, so loading it with the end-of-trainingvecnormalize.pklfed the policy differently-scaled observations than it was evaluated with — andevaluate.py/transfer_benchmark.pyboth default to--model best. Measured on the 3M-step Ant: 2570 ± 55 with matched statistics vs 1964 ± 738 (worst episode 286) when mispaired — no crash, just quietly worse and wildly inconsistent.train.pynow savesbest_model/vecnormalize.pklon every new best (SaveVecNormalizecallback oncallback_on_new_best).utils.loading.find_vecnormalizeresolves the statistics belonging to a given checkpoint (periodic snapshot → best-model snapshot → run-level fallback); all three scripts use it and warn when only a fallback exists (i.e. for runs trained before this fix).- Four pairing tests; the gotcha is written up in the usage guide.
[0.3.0] — 2026-07-16¶
Added¶
- Rule-based AirTraffic baseline (
Airtraffic/agents/) — the litmus-test heuristic for Ex 6.1: priority-based altitude-slot assignment with just-in-time clearance issuance and outbound freezing. Works from the observation only, viadecode_observation; exposes the SB3predict()interface. Plus aNoopController(the do-nothing floor) andscripts/evaluate_baseline.py, which compares any mix of heuristics and checkpoints on identical seeds with safety KPIs (congestion-steps, clearances, invalids) alongside return. Beats do-nothing on return and safety at n=5 and n=8 — see the new Rule-based baseline guide for how to approach the environment programmatically and the reference numbers. - 10 baseline contract tests (decode round-trip, no invalid clearances, deconflicts and out-scores noop, stateless across resets).
[0.2.0] — 2026-07-16¶
Added¶
scripts/render_agent.py(Crippled Ant) — point it at any checkpoint.zip(best/final/periodic); it walks up to the run's saved Hydra config, rebuilds the training environment with the matching VecNormalize statistics (periodic checkpoints use their own stats snapshot), and records.mp4episodes. Injury overrides (--disabled-legs/-joints,--n-random-legs) render a healthy-trained policy on an injured Ant — transferability and robustness by eye.--stochastic,--episodes,--seedsupported.- Video filenames encode checkpoint + injury + mode; the auto-reset stub video after the last episode is no longer written.
[0.1.1] — 2026-07-16¶
Fixed¶
- Dead syllabus links. The session guides and the syllabus pages' source
banners linked to
github.com/.../blob/main/tutorial/..., which 404s (the files live on thetutorial-codebranch). The guides now link to the handbook's own Intermediate and Advanced syllabus pages — the full detailed module breakdowns, embedded verbatim fromtutorial/TUTORIAL_SYLLABUS_*.md. (Both pages and both source files were removed in 0.32.0; the links here are de-linked rather than rewritten, because a changelog records what happened.) Same fix for theenvironment.ymllink on the installation page.
[0.1.0] — 2026-07-15¶
Added¶
- Crippled Ant package (
Crippled Ant/) — the Session #2 reality-gap toolkit:CrippledAntwrapper (fixed joints, whole legs, or randomised legs per reset), Hydra-configuredscripts/train.py(PPO/SAC viaalgo=),scripts/evaluate.py(injury overrides, stochastic mode, video), andscripts/transfer_benchmark.py(healthy vs. 1-leg vs. 2-leg table + chart). Every syllabus exercise 3.1–5.1 is a one-line command — see the usage guide. - Test suites in both session packages (
pytest): wrapper/config contract tests and end-to-end training smoke tests for Crippled Ant, and an environment contract suite for AirTraffic that student modifications (Advanced session, competition submissions) must keep green. pytestandmoviepyadded toenvironment.yml.
Fixed¶
LegacyFlightEnv.reset(seed=...)is now actually reproducible — the seed is threaded through to the simulator's RNG (previously the simulator drew from the globalrandom/np.randomstate and ignored the seed). Foundation for the competition's seeded validation set.LegacyFlightEnvobservation space claimedBox(-1, 1)but produced values outside it — flights spawn outside the airspace, so positions legitimately exceed ±1 andgymnasium.utils.env_checker.check_envfailed. Bounds are now per-dimension and honest (details).
Removed¶
Crippled Ant/post_training_analysis.py— importedsrc.*from the 2025 repo and could not run here; superseded byscripts/evaluate.py. The stale pip-freezerequirements.txtwas replaced by a curated one.
[0.0.3] — 2026-06-05¶
Added¶
- Full Intermediate and Advanced syllabi as dedicated docs pages under
Tutorial Guides. They embed
tutorial/TUTORIAL_SYLLABUS_*.mdverbatim via the include-markdown plugin, so those files remain the single source of truth.
[0.0.2] — 2026-06-05¶
Changed¶
- Header wordmark is now solid white instead of the blue→green gradient — the green half was low-contrast against the dark header.
[0.0.1] — 2026-06-05¶
Initial documented release.
Added¶
- MkDocs Material documentation site (
mkdocs.yml,docs/) — wiki-style HTML handbook, dark/light brand theme matching the bootcamp website. - Brand stylesheet
docs/stylesheets/extra.cssanddocs/assets/slot for logo/favicon — single place to change colours, logo and icons. - Setup pages: multi-OS installation (Linux/macOS/Windows), Conda-environment workflow, and an OS-by-OS troubleshooting guide.
- AirTraffic /
LegacyFlightEnvusage guide — layout, import patterns, training, evaluation/rendering, configuration, simulator exploration, recipes. - Environment API reference for
LegacyFlightEnv,CollisionCourseSimulator,Flight. - Intermediate and Advanced session orientation pages.
- Root
environment.ymldefining therlbootcampConda environment (verified against the installed env: gymnasium 1.2, SB3 2.7, MuJoCo 3.3, torch 2.8). - Root
VERSIONfile (0.0.1).
Verified¶
- AirTraffic package imports and steps under gymnasium 1.2.0 / SB3 2.7.0 in the
rlbootcampenv (obs shape 71 forn=5, m=2;gym.make("LegacyFlightEnv-v0")OK).