Skip to content

Release: submissions become JSON - #130

Merged
thc1006 merged 80 commits into
mainfrom
develop
Jul 31, 2026
Merged

Release: submissions become JSON#130
thc1006 merged 80 commits into
mainfrom
develop

Conversation

@thc1006

@thc1006 thc1006 commented Jul 30, 2026

Copy link
Copy Markdown
Member

The leaderboard now takes JSON only. main still writes pickle, so until this
lands every competitor's submission is refused by the deployed server. That is
the reason for the timing rather than anything on this list.

Not merging this myself. It needs your approval.

What competitors get

#122 submissions are JSON, not pickle. The leaderboard reads it with json.load, which cannot execute code the way pickle.load could
#117 an action of the wrong shape or holding a non-finite value is refused with a message instead of hanging the integrator
#119 a failed write no longer leaves a closed descriptor behind
#107 pop detection no longer depends on which end of the step you call the start
#108 the submission checker took release eligibility from the schedule and failed honest scenario-0 runs
#105 CI installs the versions the lockfile pins, rather than checking one thing and testing another
#106 #109 #110 #118 #120 tests and checks, no behaviour

Scores do not move

I checked, because a release the day before the round is announced would be a bad
time to shift anyone's number.

One thing to decide first

#128 fixes random_seed: null raising AttributeError on the first reset, on the
very option the scenario file's own comment offers. The round is announced on an
arbitrary seed, so a competitor who follows that comment hits it. It is one word
and three tests, open against develop, and it would be better inside this
release than behind it.

#127 is the related question I have not answered: with null the seed that was
actually drawn is not written into the submission, so such a run cannot be
reproduced. That is a rules call rather than a code one.

Sequencing

Whichever of the release and the server update goes first, there is a window where
a competitor's file is the wrong format. Leaderboard #18 makes that window
survivable: a .pkl upload now answers

This leaderboard takes .json now. Update your BalloonPoppingChallenge checkout
and run the evaluation again to get one; GET /api/version says what this
deployment accepts.

and GET /api/version reports the deployed commit and the format it wants.

thc1006 added 30 commits July 29, 2026 01:50
The test job ran `uv lock --check` and then
`pip install -r requirements-dev.txt`. That file is two editable installs
and no version pins, so the check passed and said nothing at all about
what was then installed.

Measured on develop: the lockfile pins matplotlib 3.10.9, numpy 2.4.5
and scipy 1.17.1; pip resolves 3.11.1, 2.5.1 and 1.18.0. So a local run
and a CI run were not testing the same software, a green run was not a
statement about any particular set of versions, and a release on PyPI
could turn the branch red with nothing changed here.

The job installs with `uv sync --locked` now, which also fails when
uv.lock has gone stale against the ActiveRocketPy submodule, so the
separate check folds into it. Tests run through `uv run`, since
installing into that environment and then calling the system pytest
would test neither set.

Installing the newest dependencies is still worth doing. Matplotlib 3.11
took out the default renderer for anyone on the README's pip path (#89),
and the only reason CI saw it was that CI was on that path. Moving the
gate to the lockfile would have removed the one thing that caught it, so
the pip path keeps running as its own job, named for what it is, listing
what it resolved, and not blocking: a release somewhere else should tell
us something rather than stop unrelated work from merging.

The workflow is asserted rather than left to its comment, because this
is a line to undo by accident and nothing else would notice. Verified by
mutation: putting unpinned pip back in the gate, running pytest outside
the environment, deleting the early-warning job, and letting it block
all fail.

Closes #92

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The checker said the rocket path was the competitor's claim and left it
there. Replaying the agent would settle it and is out of scope, since the
agent's source is in the submission and running a competitor's code
server side is arbitrary code execution by design.

Short of that the recorded numbers still have to be a trajectory.
rocket_states carries position and velocity side by side out of one
integration, so they have to agree; the attitude quaternion has to be a
rotation; the flight has to start on the pad. Dragging the path onto a
set of balloons breaks the first by orders of magnitude, because a metre
of displacement over one 0.01 s step is 100 m/s of velocity recorded
nowhere.

Every bound is measured on a real run rather than chosen. Position and
velocity disagree by a median of 1.5e-06 m/s and never by more than
0.037 m/s anywhere in the interior, at the step where the solver changes
its step size near burnout; the tolerance is 1 m/s, about thirty times
that and orders of magnitude below what editing costs. The quaternion
norm holds to 5.3e-12 and the first flown position sits 2e-09 m from the
pad.

The rows after the flight ends are dropped first. The environment stops
advancing the state but keeps recording it, so a landing leaves the
position frozen while the velocity is still the impact velocity, which
differs from the differentiated path by 139.6 m/s. That is a real run,
not a fabricated one.

This does not establish that the run happened. It moves editing a result
from changing numbers to producing a consistent flight, and the module
docstring now says that rather than claiming more.

Adversarial pass found two survivors, both since closed. Unwiring the
check from verify() left every test passing, because they all drove the
function directly. And nothing covered the frozen tail: the fixture is a
40 step run that never lands, so the trim was untested and removing it
passed, while a complete flight, which is what competitors actually
upload, would have been reported as forged. Both cases are tests now.

Verified by mutation: unwiring it, widening the tolerance, disabling the
quaternion or pad check, and dropping the tail trim all fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review found a false green I introduced in this branch, and it is the
same class of bug the branch exists to fix.

uv picks its interpreter from .python-version, which pins 3.14, and it
does not care what setup-python put on PATH. So `uv sync --locked` in
the leg named 3.10 built a 3.14 environment and passed, and the floor
#75 exists to test would have stopped being tested by anything while the
run stayed green. Verified locally: `.python-version` is 3.14, uv run
gives 3.14.5, and UV_PYTHON=3.10 gives 3.10.20.

UV_PYTHON is set from the matrix now, and the job checks the interpreter
it actually got rather than trusting the variable, so this cannot go
quiet again.

Tests run with --no-sync. `uv run` re-resolves before running unless
told not to, which would have put what is tested back outside the
lockfile's hands one line after pinning it.

The early-warning job kept only 3.14, which is half of what the old pip
matrix covered. pip does not resolve the same stack on each: the
lockfile itself carries numpy 2.2.6 below Python 3.11 and 2.4.5 above
it. Both legs again.

It also called itself the README pip path and was not. The README gives
competitors `requirements.txt`, which installs ActiveRocketPy as a built
wheel; this installs `requirements-dev.txt`, which is editable, so it
cannot see packaging failures. Named for what it is instead, with the
gap written down rather than papered over.

Also: dropped the pip cache, which was keyed on uv.lock and cached
nothing that mattered once the install moved to uv; both jobs now print
the versions they resolved, which is what #89 cost time for the lack of;
and CONTRIBUTING no longer tells contributors to run a CI that stopped
existing in this commit.

The contract tests were weaker than they looked, and the review named
each hole. continue-on-error is required to be the literal boolean now,
since `${{ false }}` parses as a non-empty string and passed a
truthiness check while GitHub evaluated it to false. The early-warning
job has to actually invoke pytest. Commands are read line by line with
comments stripped, so a mention in a comment cannot satisfy an
assertion. And the interpreter check asserts, rather than merely reads,
the version: an adversarial pass caught that one still surviving.

Verified by mutation, all six caught: dropping UV_PYTHON, dropping
--no-sync, making the early-warning job blocking by expression, deleting
its test step, deleting its 3.10 leg, and gutting the interpreter check.

Closes #92

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Same geometry, two scores. On develop:

    rocket  (0, 0, 0)          -> (1, 0, 0)
    balloon (-1, 1.50000007, 0) -> (1, 1.49999999, 0)

returns 1.50000003 m, a miss against the 1.5 m radius, and writing the
rocket sweep down end to start returns 1.49999999 m, a pop. The real
closest approach is the endpoint distance, 1.49999999 m.

The cause is not the tolerance. When the two directions were too close
to parallel to solve for the stationary point, the code pinned s to zero
and solved only for t. That is right for exactly parallel segments and
wrong for merely near parallel ones: the separation then varies along s,
and zero is not where it is smallest, it is only where the caller
happened to start writing the segment down. Shrinking the tolerance
moves the boundary and keeps the failure.

The minimum of a convex quadratic over the unit square is either its
stationary point, when that lands inside, or on an edge, and each edge
is a one dimensional minimisation that clamping solves exactly. All five
candidates are evaluated and the smallest wins.

That makes orientation invariance structural rather than a property to
be tested for: reversing either segment swaps s = 0 with s = 1, and
exchanging the two swaps the s edges with the t edges, so the candidate
set maps onto itself. It also takes the tolerance off the correctness
path, since it now decides only whether the stationary point is worth
computing and the edges hold the true minimum either way.

Measured against a brute force oracle over 400 random pairs, half of
them forced near parallel, across three orders of magnitude of segment
length: worst relative error 8.9e-06, which is below the sampled
oracle's own resolution, and worst spread across the five orderings
9.7e-15.

Both golden masters are unchanged, so this corrects the rule without
moving a score.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
_branch_of duplicated the implementation's arithmetic to name which
branch a fixture reached. That drifted twice. The threshold here stayed
an absolute 1e-12 while production moved to a relative one, and now
production has no parallel branch at all, since it evaluates the
stationary point and all four edges and takes the smallest. Both times
the helper went on confidently naming a branch that was not there, and
the suite stayed green because the exactly-parallel fixture happens to
classify the same either way.

It describes the geometry now: whether either sweep is a point, whether
the directions are parallel, and where the unconstrained minimum falls
relative to the two segments. Those are facts about the fixture and stay
true however the minimum is computed.

What it is for is unchanged. Without it a case is only believed to
exercise what it was chosen for, which is how the batch below came to
claim four situations while covering two.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The checker accused the repository's own scenario 0 run.

Scenario 0 starts every balloon released at reset and its release
schedule never fires. It still builds one, and with the shipped seed
balloon 0 is scheduled for step 400 while the committed baseline pops it
at step 370. Comparing pops against the schedule therefore reported the
official submission as popping a balloon before it was released.

Eligibility now comes from the environment: a balloon is eligible from
the first step when reset already has it released, and from its
scheduled step otherwise. Read from the canonical reset rather than
special cased on the scenario number, so it stays right for whatever
scenario 2 does.

The same mask replaces the submitted status in the reachability check.
Reading the status there let a file mark a balloon released early, point
at a rocket pass from before the real release as its closest approach,
and flip to popped on the official step: the timing check saw a pop that
was not early and the reachability check saw a balloon it thought was
released, so both passed on a pop the environment would never have
detected.

Every existing test built its submission by hand from scenario 1 cut to
one balloon, and none ran a shipped scenario end to end. That is what
let this ship. There is a test now that runs scenario 0 with the example
agent and puts the result through verify(), which is the cheapest
possible statement that the checker is fit to judge a real submission.

Verified by mutation: the bare schedule and the submitted release mask
both fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ling

Review found the previous commit still wrong, and the comment I wrote
was the tell: it claimed that when the stationary point is skipped the
edges hold the true minimum. That is only true for exactly parallel
directions, where the separation is constant along the valley and its
smallest value is attained where the valley leaves the square. For a
pair that is merely close to parallel the stationary point can lie
strictly inside both segments, and then no edge contains it.

Measured with the 8 * eps cutoff still in place: a rocket sweep
(-5, 0, 0) to (5, 0, 0) against a balloon sweep (-5, -5t, z) to
(5, 5t, z), with t = sqrt(7 * eps) and z one ulp inside 1.5 m, has a
relative determinant of 1.637e-15, under the cutoff, and its closest
points are the two midpoints. The cutoff skipped them and returned
1.5000000000000127 m, a miss, where the truth is 1.4999999999999998 m,
a pop.

So the test is zero now rather than a smaller constant. The tolerance is
off the correctness path, which is what the previous attempt claimed and
did not deliver.

The determinant is ||u x v||**2 rather than a*e - b*b. They are equal in
real arithmetic, and the subtraction takes two nearly equal products and
loses most of their digits exactly when the directions are close to
parallel: on that pair it gives 1.637e-11 where the true value is
1.554e-11, out by five percent, and solves s = 0.4747 instead of 0.5.

Re-measured over the same 400 pair corpus, half forced near parallel:
worst error against the brute force oracle 7.4e-06, worst spread across
the five orderings 1.5e-15, which is 6.7 eps and down from about 44.
What remains there is rounding rather than a missing candidate. A pair
built to sit one ulp inside the radius can still change verdict on
reordering, and no floating point implementation avoids that.

Both golden masters are unchanged.

Verified by mutation: restoring the cutoff and restoring the cancelling
determinant both fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review pointed at the tests and it was right: none of them reached
_release_eligibility on the production path. Scenario 0 starts every
balloon released, the reduced scenario-1 fixture has one balloon and so
a release step of zero, and the forged-release test supplies its own
mask. All three produce an all-True mask either way.

Measured: returning ones(...) from that function, and moving its
comparison off by one, both left every test in the file green. So the
scenario-1 bypass this branch exists to close was pinned by nothing.

It takes the regenerated facts now rather than the scenario, which makes
the rule a pure function with its own boundary tests, and lets verify()
rebuild the canonical world once instead of once per caller. That
rebuild runs the balloon Monte Carlo, a hundred flights for scenario 1,
and it was being paid for twice per submission. The suite drops from 59
to 35 seconds, and a call-count test keeps the next check that needs the
world from making it three.

Two smaller ones from the same review. The new full-scenario test class
was defined after the unittest.main() guard, so running the file
directly skipped the only end-to-end check in it. And when eligibility
was missing the release check reported "every pop is on or after the
balloon's release step", which is an affirmative statement about a
comparison that never ran; it says it was not evaluated instead.

Verified by mutation: all-True eligibility, the off-by-one, an
unevaluated check reporting success, and a second regeneration all fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review found a way straight through the check this branch adds, and it
is the worst kind: it fails open.

The rows were filtered to those entirely finite. The environment writes
thirteen NaNs before launch and thirteen finite numbers after, so those
are the only two shapes a run makes, but a submission that put a NaN in
one body rate, a column nothing here reads, made every row fail that
filter. The function then reported "the rocket never launched" as a
passing finding, and the reachability check downstream reads only the
first three columns, so the fabricated positions were still trusted.

Measured: a path dragged sideways by a factor of 37 with its velocities
left untouched passed, because column 10 was NaN.

The two shapes are named now and anything else is refused. Exactly
thirteen columns. Each row is all-NaN or entirely finite; a row that is
neither is a partial row and is rejected rather than filtered out. The
pre-launch rows have to be a prefix, so a whole NaN row in the middle of
a flight is refused too, since dropping it would splice two samples
0.02 s apart into a series this differentiates at 0.01 s, and would let
a submission delete whichever rows disagree with it. A run counts as
never launched only when every row is a genuine pre-launch row.

A JSON null arrives here as NaN, so a partly null row is a partial row
rather than a pre-launch one, which is what it is. That matters for the
format change.

The frozen tail is trimmed only for the differentiation now. Truncating
the flight itself also removed those rows from the quaternion check, so
a repeated position carrying something that is not a rotation went
unexamined.

Verified by mutation: the loose shape bound, accepting partial rows,
accepting a mid-flight gap, and reading the quaternion from the trimmed
view all fail. Replacing states[first_flown:] with the old finite-row
filter survives, and is an equivalent mutant: once partial rows are
refused and the flight is known contiguous, the two select the same
rows.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Two paragraphs in the scaling test still described the implementation
this branch replaced: a parallel branch selected below a tolerance, and
s pinned to zero inside it.

One of them was not merely stale but wrong. It said the tolerance's
value was not observable anywhere in a wide range, on the reasoning that
a pair near enough to parallel for it to matter is one the parallel
branch answers correctly anyway. The interior counter-example added here
is exactly such a pair and is answered wrongly, which is why the
stationary point is always computed now.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Moving the denominator to ||u x v||**2 fixed half of it. Both numerators
are still subtractions of two nearly equal products, b*f - c*e and
a*f - b*c, and near parallel that is where the digits go.

So the verdict still depended on which end the caller called the start,
and my last commit message was wrong about why. It said the residual
ordering spread was one ulp and irreducible. Measured on an asymmetric
pair:

    u = (20, 0, 0), v = 20 * (cos t, sin t, 0), t = sqrt(10 * eps)
    closest points at s = 0.35, t = 0.70, separation 1e-14 inside 1.5 m

as written 1.4999999999999900, a pop; rocket sweep reversed
1.5000000000000087, a miss. The true separation is 45 ulp inside the
radius, so that was a real loss of digits and not the last bit.

Both numerators come from the same identity as the denominator now.
(p x q).(r x s) = (p.r)(q.s) - (p.s)(q.r), so they equal the algebraic
forms exactly in real arithmetic and reach them without the subtraction.
t is solved directly rather than back-substituted through s, which is
the same identity and keeps the two symmetric. Checked against the
algebraic form over 2000 random triples.

That pair now returns the same distance in all five orderings, to the
bit. Over the 400 pair corpus, half forced near parallel, the worst
ordering spread is 8 eps and the worst error against the brute force
oracle 2.6e-06.

The regression is the asymmetric pair, across all five orderings. The
symmetric midpoint one is kept beside it, because they are different
faults: the midpoint pair is below 8 * eps so it catches the cutoff
coming back, and the asymmetric pair is above it so it cannot. It also
asserts it sits more than ten ulp inside the radius, so a verdict that
flips there means something.

The branch classifier in the pop tests stopped copying the removed
8 * eps policy, which had it calling a non-parallel pair parallel, and
uses the same cross product identity.

Both golden masters are unchanged.

Verified by mutation: the old s numerator and the old cutoff both fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…landing

Two faults, one cause: the check differentiated the interior with a central
difference, `(p[k+1] - p[k-1]) / (2 dt)`, against `v[k]`.

Every comparison it makes is then between rows of the same parity, so adding a
constant to all the odd rows cancels exactly. Measured on a complete scenario-0
run: moving 2954 odd rows 1000 m east left all three findings passing, while
the pop check reads adjacent rows and so walked the offset path.

The tolerance was also wrong, and in the expensive direction. It was set to
1 m/s from a figure I misread off my own measurement. Rerun on complete flights
of both scenarios, the shipped check reports 22.66 m/s on scenario 0 and
25.67 m/s on scenario 1, so it rejected every honest complete submission.

That disagreement is entirely the last step: the integrator stops at the ground
partway through it, so the position advances less than the recorded velocity
implies. Every other interval on both scenarios is under 0.002923 m/s.

So compare adjacent rows instead, displacement against the mean of the two
velocities that bound it. No parity nullspace, both endpoints covered, and it
works with two points. The bound drops to 0.05 m/s, seventeen times the
measured worst.

The last interval gets a one-sided rule instead, since landing short is honest
and only going further is evidence. It is bounded by the previous displacement,
not by the recorded speed: `velocity[-1]` is the one row no two-sided
comparison constrains, so sizing the allowance from it would let a large number
written there buy a proportionally large jump.

Three smaller holes closed with it:

- the repeated-position tail is trimmed before differentiating, and the trim
  was unbounded. Teleport, then freeze, and the trim deletes the evidence. It
  is now limited to eight rows against a measured two.
- too few flown steps to check reported ok, which is what that freeze reduces a
  submission to. Insufficient evidence now fails.
- the interior and the one-sided check meet at a seam. A jump on the last
  interior interval raises the allowance the final interval is measured
  against, so an off-by-one there hides itself. Pinned by its own test.

Tests run a complete scenario-0 flight rather than the 40 step fixture, which
never lands and so contained none of this. Eight mutations, including the
central difference returning and the tolerance drifting back to 1 m/s, each
fail a named test; a control mutation survives.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The checker passed on three runners and had three ways of not looking at
a fourth. Each one was measured against hand written snippets and against
the repository as it stands.

The environment receiver was matched with `"env" in name.lower()`. A
runner that binds the environment anywhere else, `world = gym.make(...)`
or `self.world.step(a)` or `envs[0].step(a)`, was never discovered, so it
was never checked; an unrelated `envelope_follower.step(sample)` was
reported for flags it has nothing to do with. Receivers are now resolved
against what the module binds from `gym.make(...)` or
`BalloonPoppingEnv(...)`, with the plain `env` and `self.env` still
working with no binding in sight. The names travel with the loop, so
`unpack_problem` looks at the same step calls the discovery did instead
of finding none and reporting nothing wrong.

The step assignments were collected with `ast.walk`, which crosses every
scope boundary there is. An outer `while episodes_remaining:` around
`while not (terminated or truncated):` inherited the inner loop's step
and was reported for flags that are the inner loop's job. The walk now
stops at a nested function, lambda, class, comprehension or `while`.
`for` is not a barrier: a `for` is never an episode loop itself, so a
step inside one has no other owner.

The guard was evaluated with the flags in eval's locals, and a name
inside a lambda or a comprehension resolves through globals at call
time, so `while (lambda: not (terminated or truncated))():` raised
NameError and ended the run rather than returning a verdict. They go in
as globals now, and anything the two flags can still raise comes back as
a verdict.

The README scanner dropped an unterminated fence on the floor and
returned nothing, which made the README test pass over zero blocks.
It now raises, and reads ```Python, ```python title=x and ~~~python.

Two more, both of which were hiding real loops:

The scan covered `BalloonPoppingGymEnv/`, `doc/` and `scripts/`. Widened
to the repository, minus the vendored and generated directories, it
found two loops in `tests/` waiting on `terminated` alone.
`test_submission_serialization` discarded `truncated` entirely;
`test_sensor_determinism` was held short of the horizon by its own step
cap, which is there to keep the test quick and is not what makes a loop
end correctly. Both now wait on either flag. The two loops written as
`while True` with the flags checked in the body are read as what they
are rather than as conditions that never look at a flag.

The discovery guard asked whether a path had been discovered, which a
decoy loop anywhere else in the same file satisfied while the real runner
went unchecked. Runners are pinned to (path, enclosing function) now.

Every fix has a test that fails without it, proved by reverting each one
in place and watching a named test fail.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…lease oracle recomputing production

Two things the scenario 0 and 1 regression tests could not see.

The baselines carried no time axis and no launch step, and both runners
slice their positions from the first finite row, so the trajectory that
gets compared is launch-relative. Measured against the committed
baselines: moving the whole rocket flight later while keeping the episode
length passed every test up to 60 steps on scenario 0 and 62 on scenario
1, and 61 and 63 only failed on the downsampled row count, not on any
clock. Scenario 1 is the one that matters, since it pops nothing and so
has no absolute anchor for the rocket at all, while the balloons it is
scored against run from step 0. Both baselines now record the launch
step and both files assert it inside STEP_COUNT_ABS_TOL, which drops the
largest displacement that passes from 60 and 62 to 2.

The new field is the only change to either baseline. Regenerating them
here also moved the trajectory digits, up to 0.7 mm of 3D displacement,
but that is this machine against the machine they were taken on: HEAD's
own production code reproduces the same drift, and the rounding change
below is bit identical on both scenarios. So the field was inserted
rather than the baselines rewritten.

The release schedule oracle claimed to be derived from the scenario file
and was not: it evaluated int(release_interval / time_step), the same
float division the environment used, so an error there appeared on both
sides and cancelled. 0.3 / 0.1 is 2.9999999999999996, which truncates to
a spacing of 2 against a correct 3, and the comparison was 2 against 2.
The environment now rounds that quotient, the oracle compares in seconds
instead, and the conversion has its own test on intervals the shipped
scenarios do not use. 1 / 0.01 and 0.5 / 0.01 are exactly integral, so
neither shipped scenario moves; that is pinned too.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Found by mutating the workflow rather than reading it. Each of these left every
test in the file passing while the gating job stopped doing its job.

`BPC_RUN_SLOW_TESTS` is one line in the same `env:` block this change edits, and
deleting it is silent: the run goes from 325 passed with 2 skipped to 309 passed
with 18 skipped, and among the eighteen are the scenario 0 and 1 regressions,
which are the only thing standing between an ActiveRocketPy change and a moved
score. That is the same shape as the bug this file exists to catch, one line
that quietly stops testing what the name says.

`if: false` on the job, and the subtler `if: github.event_name == 'schedule'`,
each removed the gate entirely. actionlint reports the literal `false` and not
the second, so it is asserted here.

`continue-on-error` on the pytest step is the escape one level below the
job-level check that was already here, and it reports the job green with a red
suite.

The fourth is in this file rather than the workflow. `_commands` said in its
docstring that an `echo` could not satisfy an assertion about what a job does,
while the code only handled `#`. Replacing the install and the pytest line with
`echo` of the same strings passed everything. It drops echoes now, and since
that helper is the lens every other assertion looks through, it has tests of its
own: a widening lens turns every assertion above it into a statement about
nothing.

Five mutations, one per hole, each fail a named test; a control survives.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Adversarial review of this branch found the fix revertible at its call site with
a completely green suite. Replacing the mask with `ones_like` inside `verify()`
left all 27 tests passing, and the same stub accepted a fabricated score end to
end: forge the submitted status to released from row 0, park the rocket on a
balloon's ground position, flip to popped on the scenario's release step, and
the verdict comes back ACCEPTED with a score of 1.

The cause is the fixture rather than the tests. Every check either drives a
consumer directly and hands it a mask, or goes through `verify()` with a
submission whose mask is all True: scenario 1 cut to one balloon gives a
schedule of `arange(1) * 50`, which is `[0]`, and scenario 0 starts every
balloon released. So the mask had no observable effect at the call site and
nothing could tell whether it arrived.

Two balloons is the smallest fixture that can. The schedule becomes `[0, 50]`,
the run is 40 steps, so balloon 1 is on the ground for the whole submission and
a claim against it is refusable without reference to where the rocket went. The
fixture asserts that shape rather than assuming it, since a one-balloon
schedule would make the two tests beside it hold with the rule deleted.

The shape guard is pinned too. The mask is built from the scenario and the
status matrix comes from the submission, so their widths are a competitor's to
disagree with, and combining them anyway raises out of `verify()`, which
`main()` does not catch. One malformed file would end the batch.

Three mutations that were silent now each fail a named test: the mask stubbed
all-True inside `verify()`, the consistency consumer unwired, and the shape
guard removed. A control survives.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Adversarial review mutated the exit rule rather than reading it, and two
branches survived a full run of this file. Each admits a loop that genuinely
does not stop.

Widening the break-or-return test to accept any statement accepts
`if terminated or truncated: running = False`, which sets a name and goes round
again. Comparing the exit condition's behaviour to anything looser than
LEAVE_NOW accepts `if terminated and truncated: break`, which leaves only when
both are set, so it keeps stepping after either one fires. That is the failure
this file exists to prevent, in the shape the guard check already refuses.

Both now fail a named test, with a third for the ordinary form so they cannot
be satisfied by refusing everything. A control mutation survives.

Also correct the comment about `for`. It claimed a `for` is never an episode
loop and that a step inside one has no other owner, which is wrong on both
counts: a bounded runner is an episode loop, and it gets its enclosing `while`
reported for a condition that is the inner loop's business.

The repair was tried and backed out rather than shipped. Making `for` a barrier
and judging a stepping `for` on its exits fixes that, and then reports the two
loops in tests/test_coordinate_contract.py, which step inside `for _ in
range(600)` and answer both flags with `self.fail(...)`. Leaving by raising is a
real exit the rule does not model, and widening it to accept any call would
accept most things. The missing shapes do not exist in this repository and those
two correct loops do, so the comment now says what is true and what it costs.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Both found by adversarial review of this branch, and both are the shape the
second half of this PR is about: a check that cannot fail for the reason it
claims.

The displacement test asserted a bare `assertRaises(AssertionError)`, and
`launch_step` raises AssertionError for its own input guards too. So the test
passes when the helper rejects its arguments rather than when the clock is
wrong. Measured: dropping the tail trim in `displace_flight_in_time`, which
breaks the same-step-count contract its docstring promises, left the test
passing on both scenarios while the real failure was "the clock has 6012 entries
and the trajectory has 6022, so a row cannot be dated". Matched on the message
now.

The new baseline field had no semantic pin, so a regeneration could bless a real
delay. Measured: move the agent's launch_time from 1 s to 3 s, regenerate, and
the baseline records launch_step 302 with every test passing. This file already
guards popped_count against exactly that, on the stated grounds that a
regression must not be blessed by regenerating, and the field needs the same.
The new check derives the expected step from the agent's own configuration and
the scenario clock, so it is an independent statement rather than the baseline
restating itself, with the two-step pipeline lag named rather than folded into
the tolerance.

Both mutations were silent before and each now fails a named test; a control
survives.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Two real defects from an adversarial review of this branch, plus four blocks it
found nothing failed on.

A ragged `rocket_states` row made `verify()` raise an unhandled ValueError.
`np.asarray(..., dtype=float)` raises on an inhomogeneous list one line before
the shape guard that was written for exactly this, so the guard never saw it.
That is attacker-controlled input reaching an exception inside the thing whose
job is judging attacker-controlled input, and `main()` wraps only the load, so
one such file ends the batch. Introduced by this branch: develop handles the
same submission. It is a finding now.

The velocity bound is a rate, and what is being defended is a distance. Sitting
just under it every step for 58.5 s buys 2.778 m of lateral displacement with
the velocity column untouched, which is close to two balloon radii, and the
rationale in the docstring, that a metre in one 0.01 s step implies 100 m/s that
is not there, is true of one impulsive edit and costs a ramp nothing.

So the unexplained displacement is accumulated as well. Integration error
alternates in sign rather than pointing anywhere, so an honest run does not
accumulate: the largest running total over a whole flight is 1.04e-04 m on
scenario 0 and 8.55e-05 m on scenario 1. Five centimetres is around 500 times
that and 3% of a balloon radius. Measured after: a ramp at a twentieth of the
rate bound, which the per-step check has nothing to say about, is refused at
14.8 cm.

Four blocks nothing pinned, each now failing a named test:

- the first interval was outside the two-sided range and only the last seam had
  a test, so narrowing the front by one went unnoticed
- the final-step multiplier could be widened to 100, letting a hundred-metre
  jump through, while only the slack beside it was pinned
- the tail trim could take two rows at a time, because every fixture had an even
  tail. Asserted on the count, since both trims leave a plausible path
- the gap check shared the name "rocket path" with the velocity check, and a NaN
  row fails both, so the test asserting that name held whether or not the gap
  check ran. It has its own name now

Seven mutations, one per defect, each fail a named test; a control survives.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…g looked

Adversarial review deleted the `s = 1` edge from the candidate set and every
test passed, both golden masters included. Flipping the sign of `f` in the
s-edge minimiser did the same. The t-edge family was pinned by four tests, which
is the asymmetry a hand-picked set of geometries produces.

Neither is decorative. On the pair below, dropping the `s = 1` edge moves the
answer from 1.49990 m to 4.05960 m, which against a 1.5 m radius is a pop
becoming a miss:

    rocket  (0, 0, 0) -> (2.248642, 7.195445, 26.380041)
    balloon (-2.732624, 5.051852, 16.682862) -> (3.586303, 10.1662, 32.856179)

It hid because `s` and `t` are clipped componentwise, so for every geometry the
existing fixtures contain, the clipped stationary point lands exactly where that
edge would have put it.

That pair is now a test, checked against the brute force oracle rather than a
recorded number, so it says the answer is right and not merely unchanged. A
seeded corpus of 200 geometries goes with it, asserted one-sided: sampling two
segments can only overestimate their closest approach, so an exact answer has to
come out at or below the sampled one whatever the sample count, and a missing or
wrongly-minimised candidate returns some other stationary value. That is both
tighter than a tolerance and much cheaper, 16 s against 135 s for the same
corpus compared two-sided.

Three mutations, one per edge family and one for the sign, each fail a named
test.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Twelve of them, the longest 17 lines. The measured versions and counts stay,
the account of how each was found goes. No code touched.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The measured numbers stay: 1.637e-11, 1.5000000000000127 m, 1.49990 m
against 4.05960 m. What goes is the story of how each was found and the
lines that restate what the code says.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review asked for a file a rocket engineer can pick up. Every measured number
stays; what goes is the narrative around it and the restatements of the code.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Twenty of them, the longest 26 lines. The measured numbers stay, and so does
the reason a for loop is not a step barrier. The history of how each was found
goes. No code touched.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The measured numbers stay: step 400 against a pop at 370, the [0, 50]
schedule, 27 tests green under ones_like. What goes is the retelling of
how each was found.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Twenty-one of them, the longest 27 lines. Every measured number stays, and
three facts move to one-line comments beside the code they explain. No code
touched.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Without `skipUnless` it errors rather than skips when the simulation stack is
absent, which is the two-tier arrangement the rest of the file follows.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The "Two tolerances" block still described `parallel_relative_epsilon` and the
branch that pins s to zero, both of which this branch removed. One tolerance is
left, on squared lengths, and the denominator now gets none.

TestEveryCandidateEarnsItsPlace sat below `unittest.main()` and had no
`skipUnless`, so it would error rather than skip without the simulation stack.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The test file was trimmed but ci.yml still carried an 8 and a 16 line block.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A review of an earlier head raised these. Two are gone already, the central
difference and the frozen tail, and these are what was left.

Two rows have no interior, and reporting that as "too few to check" was a
fail-open: pad, target, target is trimmed to two positions while the pop check
still reads the pad-to-target segment. One interval is judged now.

A run that reaches the horizon reports truncated and stops with the rocket
moving, so it has none of the repeated rows the trim looks for. Every other test
here is built from a landing, so nothing covered it.

The bound was measured on one passive agent. A commanded roll puts thrust
transients and burnout under a different history, and stays inside it.

`verify()` sits outside the try in `main()`, so a submission that made any check
raise ended the batch and left every later file unread.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added 4 commits July 30, 2026 05:12
…-the-scenario

Take release eligibility from the scenario, not from the schedule
`__reset_balloon_release_sequence` read `_np_random`, the attribute behind the
`np_random` property. `Env.reset(seed=None)` never sets that attribute and only
the property draws a generator, so `random_seed: null` raised

    AttributeError: 'NoneType' object has no attribute 'shuffle'

on the first reset. The scenario files offer that value: `random_seed: 0 # Use
null to enable random seeding`. Present since before v0.1.0, on main and on
develop.

The drawn seed is still not written into the submission, which matters for a
round judged on an arbitrary seed. That is a payload question rather than this
one, and it is issue #127.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A run configured with `random_seed: null` draws its own seed, and the
submission recorded the null that was asked for. verify_submission rebuilds the
balloon field by resetting the environment with the seed it finds, so that lost
the only thing it needed: the world could not be reproduced at all.

The packer now writes `env.np_random_seed` into its own copy of the scenario
parameters, so the file describes the run that happened.

The verifier takes the seed out of the strict parameter comparison and uses it
as an input to the oracle instead: the shipped scenario with that one value
replaced. It refuses a seed it cannot reset with, which is what a submission
from an older build carries.

This reverses a deliberate earlier decision, recorded in the docstring of the
test it replaces: refusing any seed but the shipped one also refused an honest
run on any other seed. The property that mattered survives, and the rewritten
test pins it: a submission still has to hold the balloons the seed it names
produces, so editing the seed alone is caught by the trajectory comparison.

Includes #128, which this cannot be tested without.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The writer promises strict JSON and left the encoder's default in place, which
writes bare NaN. `_json_safe` covers the payload as it is, but it is a walk that
a new field can be added outside of, and then the file leaves as invalid JSON
rather than as a failure. `allow_nan=False` makes the serializer the boundary.

The checker's comment said an invalid rocket path is refused before the
hundred-flight rebuild. The return was never written, so a malformed file paid
for a scenario-1 Monte Carlo it was never going to survive.

The producer stamps format_version and the checker never read it, so a file with
no version, or version 0 renamed to .json, reached every check as though it were
the format they describe.

Also puts scripts/ into the lint, now that verify_submission.py is 1000 lines of
it, and takes the over-claim out of check_balloon_trajectories' docstring: it
compares positions, which its own comment already explained.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
v0.1.0 writes a pickle and this line writes JSON, and both said 0.1.0, so a
competitor holding the package had no way to tell which one they had. The
changelog's Unreleased section was empty for all 61 commits, and it says at the
top that it tracks the submission format.

The protocol version is the field in the payload and it did move, 0 to 1. This
is the package catching up so the two can be talked about together.

Three tests, because the drift happened once already and nothing looked: the
declared version has a section, the section says something, and it has a
comparison link.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added 10 commits July 31, 2026 06:56
The leaderboard reads that section, so a round judged on an arbitrary seed can
see which one each submission ran, and the parameters keep saying what the run
was configured with rather than being overwritten.

The checker takes the seed from there and cross checks the parameters against
it: null is a random run, an integer has to be the same number. Without that,
exempting the parameters from the strict comparison would leave a field a
submission can say anything in.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Version 0 is the payload v0.1.0 wrote, and its sections are the same ones this
file checks, so refusing it only stopped the submissions already on the
leaderboard from being checked. load_submission still opens those files.

The case the review raised, a version 0 payload renamed to .json, does not
reach here: pickle bytes are not JSON and the parse refuses them first.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
It builds the payload by hand and did not carry format_version or the recorded
seed, so the checker refused it. Only CI saw that: the test needs
BPC_RUN_SLOW_TESTS=1, which the workflow sets and a plain local run does not.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
`isinstance(configured, int)` meant `"5"`, `1.0` and `[1, 2]` went past the
cross check into a field the strict comparison no longer looks at. The rule is
what it always was in words: null is a random run, anything else has to be the
number that was used.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@zuorenchen

Copy link
Copy Markdown
Member

Ran manual tests locally with on develop branch #be27d30 + Leaderboard #985c6. Everything is working fine. Good to merge this release

@thc1006

thc1006 commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Thank you for testing it. One thing to flag before this goes, because the tree moved after you ran it.

You tested develop at be27d30, which is still where develop is, so the approval is accurate for what this PR holds right now. Since then a second round of review turned up three things that I think belong inside this release rather than behind it, and they are open against develop:

#132 the seed. You approved this one. Without it, an arbitrary-seed round cannot be verified: the checker refused every seed but the shipped 0, so a competitor who picks one is reported as having modified the scenario
#133 the writer promised strict JSON without allow_nan=False, the checker's comment claimed an early return that was never written, and format_version was stamped and never read
#134 this line changes the submission protocol and the package still says 0.1.0, with an empty Unreleased changelog

If those merge into develop first, this PR's head changes and your approval will be for a different tree, so I did not want to merge it on the strength of the earlier run.

Two ways to take it, and it is your call:

  1. Merge Record the seed a run used, and verify against it #132, Make three checks do what they say #133, Call it 0.1.1 and write down what changed #134 into develop in that order, then look at this again. It is the same release plus the three gaps closed.
  2. Release be27d30 as it stands and let the three follow in the next one. That works for everything except the arbitrary-seed round, which the checker cannot handle until Record the seed a run used, and verify against it #132 is in.

The leaderboard side is already on main: JSON only, and a .pkl upload now answers with what to do about it rather than only that it is wrong. GET /api/version reports the deployed commit and the format that deployment accepts, so the two sides can be compared without an ssh session.

@zuorenchen

Copy link
Copy Markdown
Member

Let's merge #132, #133, #134 into develop as you mentioned.
These are relatively small changes. Don't worry about the approved tree. We can always send a hot-fix if anything breaks

thc1006 added 5 commits July 31, 2026 08:24
Record the seed a run used, and verify against it
Call it 0.1.1 and write down what changed
`says which field and why` describes #131, which is still open. Without it the
log names the field it dropped and not the reason, so the line said more than
the release does.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The changelog claimed a change that is not in this release
@thc1006
thc1006 merged commit aa51afa into main Jul 31, 2026
10 checks passed
@thc1006

thc1006 commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Released as v0.1.1.

I took the tag through the whole path afterwards rather than trusting the merge:

A clean checkout of v0.1.1, scenario 0 with random_seed: null, which is the value that raised AttributeError before this. It runs, and the drawn seed reaches the file:

leaderboard_info.random_seed  283347153738980284119469032421419724358   (int)
scenario.random_seed          null

scripts/verify_submission.py on that file, 19 checks and all of them pass. The one that matters here:

[ok] the seed the run used: random_seed 283347153738980284119469032421419724358
[ok] balloon trajectories: largest position difference 0.000e+00 m over 6012 steps
[ok] release schedule: matches the schedule the seed produces

Exactly zero, so the world rebuilds from what the file carries.

Then the same file through the leaderboard at 40a24da: 200, listed at 10, replay served, and the 39 digit seed comes back out of the stored upload as an exact int rather than a float.

The evaluate.py integrity check printed nothing, which is what I hoped: that file did not change between v0.1.0 and this, so nobody on the old version starts seeing a warning because main moved.

The submodule pin is the same commit as v0.1.0, 473447d, so the physics is unchanged.

What is left is not ours: the server is still on the pickle build, GET /api/version there is a 404. @William-Mou when you fetch it, competitors need this release to be out first, which it now is.

@zuorenchen

Copy link
Copy Markdown
Member

Thanks for the effort. Let's update the server to accept json submissions.

@thc1006

thc1006 commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

The release is out, so the server can go whenever you and @William-Mou are ready. Leaderboard main is at 40a24da.

I checked the four things that could have made this awkward, and none of them bite:

Old replays do not need repairing. #21 stopped sanitising on every read and moved it to a one-off script, which only matters for files written before create_submission started sanitising. That landed in 7c6d3b4 at 16:32 on 2026-05-29, and the oldest submission on the server was written at 23:42 the same day, so every replay up there is already clean. I pulled the oldest one and checked: no NaN, parses as strict JSON. scripts/repair_old_replays.py is a no-op here and does not need running.

No new runtime dependencies. The only addition to pyproject.toml is a dev group with httpx and pytest, for the tests.

team_secrets.json is untouched by this change, so a pull will not walk over the team list on the server.

The viewer needs a rebuild if you want your label fix visible. trajectory-viewer/dist/ is gitignored, so a pull leaves whatever was built there. Nothing breaks without it; the replay just still says 成績 instead of score.

So:

git pull
uv sync
# restart uvicorn
cd trajectory-viewer && npm ci && npm run build   # only for the label

Worth checking after it comes up, since both are new and neither existed before:

GET /api/version   ->   {"commit":"40a24da...","upload_suffix":".json","accepted_format_version":1}
GET /api/submissions   ->   the 13 rows still there

If /api/version still 404s, the restart did not take the new code, which is the thing that bit you last time.

The upload path is JSON only from that moment, so a competitor still on v0.1.0 gets a 400 until they update. That is the window you already said is fine, and v0.1.1 is out for them to update to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants