Tutorial · Stage 3 of 3

Walk 3: How the loop was closed

A walk through the retropulsion descent, where the table is read in flight and the flow state itself is forked. Six stations, including the leg boundary that would destroy the state under test and the bands that were corrected after they looked like results.

march ... untilfork of a marched statecontinue_forreduceThrottleGuidance

Run it

cargo run --release -p avionics_examples --example plasma_blackout_retropulsion
You will be able to
Identify a leg boundary that would discard the state a counterfactual needs, and read a constants file as a record of corrections.
Runtime
355.6 s against an 1800 s budget
Consumes
The corridor descent and weather_table.csv, read in flight.
Produces
A landing at 1.78 m/s, and the family's closed loop.

Keep these open while you walk

Walk 1 forked a paused state to select a command. Walk 2 alternated worlds to build a table. This walk forks a state whose flow field is the quantity under test.

Start the run before reading. It takes about five and a half minutes.

cargo run --release -p avionics_examples --example plasma_blackout_retropulsion

One physical result governs three of the stations below. Firing an engine forward into a supersonic freestream does not add thrust to a fixed drag; the plume displaces the bow shock and destroys the aerodynamic drag the vehicle was using. Jarvinen and Adams measured the collapse in 1970. A vehicle that lights its engine too hard therefore arrives faster than one that coasts.

That result rules out a parameter sweep. Each candidate throttle changes the flow, and the flow determines whether the candidate was any good.

Station 1: Act 0 and the placement of a flight fact

Open retropulsion/main.rs. The first act reads the artifact walk 2 produced:

let table = model::load_dispersion_table().map_err(leg_err("setup: weather table"))?;
let informed = model::day_belief(&table, utils::ft(constants::MEASURED_D_TEMP));
let uninformed = model::standard_day_belief(&table);

The measured day sits 32 K below standard. Its row sizes the ignition margin, supplies the density scale, and scales the accelerometer bias.

The next block handles the case where the measured day falls outside the tabulated range:

let mut seed_field = world::powered_initial_field();
if informed.clamped {
    seed_field.log_mut().add_entry(&format!(
        "dispersion row CLAMPED: measured departure {:+.1} K lies outside the tabulated \
         range, so the row flown is the nearest end row rather than an interpolation",
        constants::MEASURED_D_TEMP
    ));
}

Outside the tabulated range the interpolation becomes an extrapolation clamped to an end row. The clamp is a fact about the flight, and the source states the rule it follows: flight facts are stamped where flight facts live. Printing the clamp to the console would leave an extrapolated row invisible to every gate and absent from the recorded run.

The rule generalizes. Anything a reviewer needs six months later belongs in provenance rather than in stdout.

Station 2: Act 1 and stages composed inert

let onset = CfdFlow::march(&corridor_world)
    .couple(world::powered_descent_coupling(
        informed.bias_departure, 0, informed.margin_m,
    ))
    // ...
    .until(|field, _| field.regime().map(|r| r.gnss_denied).unwrap_or(false))?;

This repeats walk 1’s corridor leg with one change: the coupling is powered_descent_coupling, which already holds the thrust and plume stages. The throttle is commanded to zero.

Those stages are strictly inert at zero throttle, so composing them early costs nothing. It keeps the safety envelope live on every step from the start. A gate that begins to exist at ignition cannot catch a violation before ignition.

Gate 1 then checks this leg against walk 2’s prediction for the measured day:

[PASS] (1) corridor inheritance: blackout onset at 10.50 s against the
       table's 10.54 s for this day (error 0.040 s) and dwell 59.50 s
       against 59.41 s (error 0.087 s), tolerance 0.50 s

The comparison target is the table rather than the corridor’s own recorded window. WINDOW_PREDICTION_TOL_S in constants.rs records why. The corridor flies the standard day and this example flies a cold one, so demanding equality there “would have asked the descent to ignore the weather it exists to consume.”

Station 3: One march call across coast, commit, and burn

Acts 2 and 3 are a single march call:

let burn = CfdFlow::march(&burn_world)
    .couple(world::powered_descent_coupling(
        informed.bias_departure, 0, informed.margin_m,
    ))
    .from(onset.state())
    .until(|field, _| {
        // Pause inside the burn: the engine is lit and the plume is on the layer.
        field.scalar("ignited")
            .and_then(|s| s.first().copied())
            .is_some_and(|v| v > 0.0)
    })?;

Coast and burn are separate flight phases, so two legs would be the natural construction. The source records why they are one:

A coupling stack is fixed per march call and `MarchState` carries the coupled
field but not the marched fluid tensor, so a leg boundary at ignition would
re-seed the flow and the fork below would fork a state from which the plume
had already been discarded.

A pause carries the coupled field across a leg boundary but not the marched fluid tensor. Splitting at ignition would start the next leg from a re-seeded flow, and the fork at station 4 would then fork a state carrying no plume. The program would run, produce numbers, pass most gates, and measure nothing.

The leg boundary therefore sits inside the burn rather than at its start, and the predicate waits for ignited to latch. That pause is the one the fork requires.

The general rule follows: leg boundary placement is a correctness decision. Determine what state the next step requires, then check that the boundary preserves it.

Station 4: Forking a marched state

let branches = CfdFlow::study("mid-burn throttle roster")
    .cases(model::throttle_roster())
    .fork(&burn)
    .branch(|case| model::branch_world(case, fork.fraction, informed.rho_scale))
    .continue_for(constants::BRANCH_STEPS)
    .reduce(move |run| model::score_branch(run, fork))
    .record(model::branch_table_path())
    .gates(model::branch_gates())
    .verdict()?;

Syntactically this repeats walk 1’s fork. The shared state now includes a marched flow field carrying a plume.

The branch table in your output:

branch         cmd    flown  preserved  axial m/s2    prop kg     dv m/s  dv frozen
coast         0.00   0.0000          —     10.5950       1.81    139.519      0.144
low           0.20   0.2000     0.2510      7.4718      62.05     88.399     49.490
mid           0.40   0.4000     0.1238      9.9667     122.30    120.335     99.984
high          0.60   0.6000    -0.0161     12.8932     182.54    151.991    151.413
hard          0.85   0.7931    -0.0611     17.1977     256.80    212.085    216.209

Read the axial m/s2 column top to bottom. Coasting decelerates at 10.595 m/s². Lighting the engine to 0.20 throttle decelerates at 7.472, which is less. The plume destroys preserved drag about as fast as thrust replaces it. Preserved drag turns negative at the harder throttles, reaching the correlation’s wake-type branch.

The cmd and flown columns differ on the hard branch alone. The envelope admitted 0.7931 of a commanded 0.85, as walk 1’s 40° bank was clamped.

Gate 4c justifies the construction. The dv frozen column gives what each branch would have shed with the drag closure held at the fork’s value. That is the result a parameter sweep over thrust would produce:

[PASS] (4c) coupling load-bearing: branch trajectories depart the frozen-drag
       prediction by up to 139.3755 m/s over the continuation (threshold 100)
       — thrust-only kinematics does not predict the outcome

The 139.4 m/s is the measured difference between forking the state and forking a parameter.

Fork economics are gated as well, with one correction recorded in the source. The two sharing flags compare a clone against the Arc it was cloned from, so no input can falsify them; they guard against a future edit that materializes the state instead of sharing it. The quantity that varies with the run is post-fork bond growth, which the gate bands and which measured 0. An earlier README presented a reference count as positive evidence, and that claim was withdrawn.

Station 5: Act 4, where the fork does not apply

The landing is flown twice from the same baseline, differing only in the margin the guidance was sized with. The two landings form a counterfactual, implemented as two plain marches:

let terminal = CfdFlow::march(&terminal_world)
    .couple(world::powered_descent_coupling_with(
        informed.bias_departure, 0, informed.margin_m, true,
    ))
    .from(burn_out.state())
    .until(|field, _| field.regime().map(|r| r.touchdown).unwrap_or(false))?;

let uninformed_terminal = CfdFlow::march(&uninformed_world)
    .couple(world::powered_descent_coupling_with(
        informed.bias_departure, 0, uninformed.margin_m, true,
    ))
    .from(burn_out.state())
    .until(|field, _| field.regime().map(|r| r.touchdown).unwrap_or(false))?;

The source states the constraint:

Two march calls rather than a forked study, because the two beliefs differ in
the **coupling** and a coupling stack is fixed per march call — the fork path
varies the *world config*, which cannot carry a guidance parameter, so these
worlds carry no `!!ContextAlternation!!` marker.

The fork path varies world configuration. The margin is a guidance parameter held in the coupling, which places it outside that path’s reach. The program uses two marches and withholds the alternation marker from worlds that were not alternated.

The result:

world margin lights landing burn contact propellant
informed 73.41 m 153.19 m 1.78 m/s +13.45 kg
uninformed 52.98 m 129.75 m 1.85 m/s

Notice

The flown separation is 23.45 m while the two margins differ by 20.43 m. ignition_altitude_kernel solves a stopping distance rather than adding an offset, so the extra margin also changes the mass and speed the burn starts from. One quantity is arithmetic and the other is a flight; gate 5 reads the flight.

The source also records where the margin does not bind. At the ignition commit the navigated sigma is 0.38 m against margins of 73.41 and 52.98 m, so both beliefs commit on the same step. A gate reading the commit would have reported the two worlds as identical.

Station 6: The constants file as a record of corrections

Open retropulsion/constants.rs at DRAG_COLLAPSE_MIN. The doc comment records that the band was re-earned after two corrections:

- the closure normalized its thrust coefficient against a construction-time
  12 kPa while the safety gate used the sensed ~2.2 kPa, which kept `C_T` in
  the correlation's shallow range and produced fractions of 1.000 -> 0.217. It
  reads the sensed dynamic pressure now, so `C_T` reaches the range where the
  correlation actually collapses;
- the coast endpoint was a hardcoded `1.0`. A coasting branch has no plume and
  now reports no fraction at all, so the collapse is measured across the
  branches that actually applied a decrement.

Both defects produced plausible output. A test asking only whether the program ran would have passed on both.

Three further corrections are recorded the same way, and each presented as a result before it was identified as an error.

The SRP envelope does not apply subsonically. The C_T ≤ 3 cap derives from a dataset spanning Mach 0.4 to 2.0. Carried to the deck it forbade the engine around 3.7 km, and the vehicle landed at 160 m/s. That figure measured a constraint applied outside its validity envelope.

Continuous throttle wastes propellant. The closed form a_cmd = v²/2h + g degenerates to thrust balancing weight at large h, so the vehicle nulled its descent rate at altitude and hovered. It ran dry at 10.5 km. Meditch showed in 1964 that fuel-optimal soft-landing control is bang-bang, which makes coast-then-burn the optimal structure and any sustained intermediate throttle wasteful by construction.

A lander arrives moving. The stopping law targets the gear contact plane at a commanded speed rather than the geocenter at rest.

A constants file holding only numbers records what the program does. This one also records what was tried, what failed, and how the failure presented.

Try it yourself

1. Change the day. Set MEASURED_D_TEMP to +15.0 in constants.rs. The interpolated row changes, the margin shrinks, and the informed guidance should light its landing burn lower. Gate 5 requires at least 10 m of separation between the two beliefs; check whether a warm day still provides it.

2. Verify station 3’s claim. Move the coast-and-burn predicate to stop before ignition. Every branch then reads a zero preserved-drag fraction, gate 4b finds no collapse, and gate 4c’s frozen-drag departure vanishes. That reproduces the discarded-plume failure.

3. Clamp the table. Set MEASURED_D_TEMP to -80.0, outside the tabulated range. The row clamps and station 1’s provenance entry appears in the log. Confirm that it is stamped in the run rather than only printed.

What the three walks established

Walk 1: a leg boundary is an event the run locates; a counterfactual forks a paused state so every branch carries one past; gates read consts and the caller owns the exit code.

Walk 2: alternate a world when branches must share a description and differ in one declared way; an ensemble separates a mechanism from a draw; the output is an artifact with a provenance trail.

Walk 3: a leg boundary can discard the state the next step requires. Forking a marched state measures 139.4 m/s of behaviour a parameter sweep cannot express. Where the fork path cannot carry the parameter under test, two plain marches with no alternation marker is the accurate construction.

The validation records give the reference case for every solver these programs use. The capability boundaries give the four hypotheses this project refuted by measurement, including the AMBER verdict on plume imprint fidelity behind station 4’s correction.