Skip to content

Export the car controller as an FMI 3.0 FMU - #24

Merged
edufford merged 71 commits into
mainfrom
fmu-controller-idm
Aug 16, 2026
Merged

edufford merged 71 commits into
mainfrom
fmu-controller-idm

Conversation

@edufford

@edufford edufford commented Aug 12, 2026 •

Copy link
Copy Markdown
Owner

Milestone 6's second step: the control laws the demo's cars will run, and an
FMI 3.0 Co-Simulation FMU that carries them across the standard's boundary.
Nothing here is wired into the demo, so DEMO_WORLD_HASH does not move and
the existing pin asserts that. Traffic starts driving on the FMU in the next
step, which is where the hash moves.

DECISIONS.md carries the reasoning, in eight entries dated 2026-08-15 and
2026-08-16. This is the shape of what landed, and what it cost to find out.

Where the laws live

IDM for following and pure pursuit for steering are pure functions in
continuo-actors::control_laws, beside nearest_detection, which picks a
lead out of an unordered scan. Pure pursuit moved there out of
PathFollowController, which now calls it; the demo hash not moving is the
proof that was inert. Waypoints gained points() and is_closed(), since
a road has to cross the boundary as the data it was built from.

continuo-fmu-controller-idm is a shell holding the FMI interface
declaration and nothing else, delegating every answer to those functions.
cargo xtask package-fmus builds it into continuo_fmu_controller_idm.fmu,
finding every continuo-fmu-* crate by prefix so a second one needs no edit
anywhere.

The point of the split is one implementation serving both sides rather than
two kept in step by hand, and half of it is demonstrated today.
PathFollowController calls pure_pursuit_yaw_rate; nothing native calls
idm_accel, because traffic will get its longitudinal behavior through the
FMU. PLAN.md defers the native follower that would finish the argument.

What the split costs is that a .fmu carries a compiled snapshot of the
laws: the cdylib links them statically and nothing calls back into the host.
Editing a law without packaging again leaves the two disagreeing, so
packaging is part of the edit-test loop, and the comparison below is what
notices when it is skipped.

The law is the published equation with one departure

A wanted gap does not go below zero. A lead pulling away contributes a
negative closing term, and the equation as published lets that take the
wanted gap negative, where squaring turns it back into braking: at 20 m/s
with 20 m of gap behind a lead pulling away to 30, it commands -1.280 m/s^2
where the right answer is +1.204. A guard on the output cannot fix that,
since only the sign is wrong. Cross-checked once against highway-env's
IDMVehicle, which implements the unclamped form and, fed our parameters,
agrees to the last bit or two wherever the wanted gap stays positive.

The command is held inside [-b, a], the two rates the equation already
names, so the parameter set is the published five and the law invents no
number of its own. The plan's b_max of 4.0 and its GAP_FLOOR are both
gone. A car therefore brakes no harder than comfortably, so the ego-lane
step needs more spawn distance than the plan budgeted
, which assumed
braking at 4.0.

What the boundary cost, and what building it found

The road crosses as road_x and road_y at a fixed length with a
road_point_count beside them, rather than as arrays sized by a structural
parameter, because fmi-export 0.3.0 cannot declare one. The same crate has
no min or max key, so the model description states no bounds and a host's
checker has nothing to check; the model refuses at run time instead, through
FMI's status and log rather than by panicking, since an unwind through the C
interface would take the host process with it.

fmpy refused the first FMU packaged, and was right three times over: both
outputs declared a start value while being initial="calculated",
ModelStructure/InitialUnknowns was empty because the derive emits those
entries only when initial = Calculated is spelled out, and every
description was truncated at the first line of its doc comment. All three are
fixed, the first-line constraint is written on the struct, and CI now runs
python -m fmpy validate over everything packaged, which is the barrier that
would have caught them. fmpy installs into an environment of its own, so the
viewer's tests keep running against the dependency set someone installing
the viewer actually gets rather than one that carries numpy and lxml too.

Packaging and distribution

Packaging passes --release, since how an FMU is optimized is settled when
it is packaged and a host loads the binary it finds. Both profiles answer bit
for bit alike, checked through fmpy over a following command, a steering
command that goes through atan2, and a near-cancellation landing at
-1.5e-15; CI checks the same property every run by comparing the demo's world
hash between profiles.

Each agent packages only its own platform's binary, so
python/scripts/merge_fmus.py and a workflow combine them into one FMU per
model carrying all four. The merge checks the model descriptions agree
first: two packagings of one commit differ in generationDateAndTime alone,
and the instantiation token is a v5 UUID of the model name. It was verified
against four real CI artifacts rather than only fabricated ones.

That workflow arrives with a manual trigger alone, taking the CI run to read
artifacts from as an input. A workflow cannot be dispatched until it sits on
the default branch, so a workflow_run trigger arriving with it would make
the first run of it an automatic one that nothing had tried. The trigger
follows once a dispatch has shown the cross-run download works.

cargo-fmi stays a CI-installed binary rather than a dependency, and
proc-macro-error2 is patched to the two-line fix rustc's own help
suggests, since the crate is archived and there is nothing to upgrade to.

How the FMU is checked

crates/continuo-fmu-controller-idm/tests/packaged_fmu.rs drives the
packaged .fmu across the FMI boundary and compares it, with to_bits,
against the same laws called in-process. Both sides are computed afresh;
nothing is stored, and the claim is that two builds of one implementation
agree. Three sweeps share a grid of situations over a straight road, a
five-bend polyline and a closed ellipse, plus 250 more per road drawn from a
fixed seed and the workspace's own generator, so a failure reproduces on
every agent. Each sweep asserts it provoked acceleration, braking and a turn
each way, because a comparison of two zeros passes whatever is wrong. Beside
them sit the padding past the point count, a short scan against a padded one,
a tunable parameter changed between steps, the nearest of several detections
named rather than searched for, and the run-time refusals.

Each situation is a run of its own, with fmi3Reset between them, since
they are picked to reach corners of the input space and no car could drive
from one to the next. Then two steps apiece: the first after a reset is
Initialization Mode, and fmi3DoStep needs an interval behind it that a
start time does not have, so both paths to an output get compared. A single
step would have taken fmi3DoStep out of the sweeps entirely while costing
no measurable time. Resetting is what the standard offers here and costs
about a hundredth of a fresh instance, which extracts the archive and loads
the library again.

The comparison sits behind a packaged-fmu feature so a plain
cargo test --workspace needs nothing packaged, gated by a #![cfg] on the
file rather than required-features, because CI runs --test '*' and a
glob names every target it matches.

Three things that will cost time again if forgotten

A reset does not restore an FMU's size. It lands in Instantiated, which
is before the instance was ever configured, and Configuration Mode closes
before Initialization Mode opens, so the next step is already too late.
FmuComponent::reset writes the mapping's structural parameters again for
that reason. Without it a reset StateSpace would come back at the 3 its
description declares for m, n and r while the bindings went on
addressing the 2 the mapping asked for.

Asking cargo for a single test target resolves fewer packages.
--test packaged_fmu resolved 142 units where --test '*' resolves 399,
the whole Zenoh tree among the missing, so their dependencies unified
features differently, the crate linked a different thiserror, and every
crate between the two rebuilt. Cargo reports it as info of dependency thiserror changed. That cost 12 to 31 seconds a run on every agent until CI
was reordered to package before it tests, which lets the comparison run as
one of the ordinary integration targets with nothing to rebuild.

A single CI run says almost nothing about timing. The step at issue
measured 79, 70, 71, 37 and 92 seconds across five runs of materially
identical work. Two wrong diagnoses on this branch came from trusting
single-run comparisons, and a third from reasoning about cargo instead of
asking it with -v.

edufford and others added 2 commits August 11, 2026 20:32
Pure pursuit lived inside `PathFollowController::step`, where the only way to
reach it was to run a component. Milestone 6's controller FMU runs the same
law inside a shared library built from this crate, and a second copy of an
equation is a second thing to get wrong, so it moves to a `control_laws`
module: pure functions over their arguments, with no inbox, clock or
component in the signature.

`PurePursuitParams` names the four numbers the law is tuned by, and the
controller holds one of those in place of four loose fields, so its struct
now separates what steers from what it takes to be a component.

The module is public rather than flattened into the crate root the way the
rest of this crate is. It is the surface something outside this process
compiles against, and a named module is what says which functions those are.
It is also what makes splitting the laws into their own crate a file move,
once a second FMU crate makes that worth doing.

None of the arithmetic changed, the same operations in the same order, so the
demo world hash is untouched and the existing pin says so.

The law had no tests of its own before, being reachable only through a
running world, and has five now: a car on its lane commands nothing, a car
off it turns back, a lane offset is what gets held rather than the road, the
clamp holds in both directions, and an error past half a turn steers the
short way round. That last one is what `wrap_pi` is for, and it moved along
with the law.

Mirrored positions do not give exactly mirrored commands, which that test
allows for rather than hides: a negative heading error comes back through
`rem_euclid` and a positive one does not, so the two part company in the last
bits.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch was cut before the conventions file landed, so the working tree had none. Nothing else arrives with it: PR #25 touched CLAUDE.md alone.
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/controller.rs Outdated
edufford and others added 5 commits August 12, 2026 16:31
Pure pursuit turns anything that follows a path, so the module's prose
should not narrow it to the one caller the demo happens to have. An FMU
compiling these functions into a shared library makes that concrete:
what crosses the boundary is a law and its arguments, with no car in
sight.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A struct of tuning constants is read one field at a time, often far from
the doc comment, so each name has to carry its own units and meaning.
"lateral" alone says a direction rather than a quantity, and "gain" says
nothing about what it scales in a law that could gain several things.
"lateral_tgt" and "gain_yaw_rate" say the target offset and the yaw rate
per radian of error.

The field holding the set is "pursuit_params" for the same reason: it
holds a parameter block, not a pursuit.

Inert: renames only, and the pinned demo hash still passes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folding a negative angle up into [0, TAU) and back down rounds it twice,
where a positive angle is not rounded at all, so a mirrored pair of
heading errors disagreed in the last bits. That is what the mirror
test's tolerance was hiding: two followers the same distance either side
of a lane steered back at slightly different rates.

Wrapping the magnitude and putting the sign back sends both signs
through one calculation, so the answer is exactly odd, and nothing in it
rounds. A remainder is exact, the subtraction runs only when more than
half a turn is left so its two sides are within a factor of two of each
other, and 1 and -1 multiply exactly. Positive angles come back with the
bits they had before. Taking off the nearest whole turn would be odd
too, but its quotient rounds at the boundary, so an angle a hair under
half a turn would come back a hair over it with the sign flipped.

The name is wrap_to_pi now. An angle is what gets wrapped, and pi is
what names the range it lands in.

This is not inert. Over angles formed the way the law forms them, the
two wraps disagree on about a quarter of inputs, by up to 4.44e-16 rad.
The demo hash holds anyway, and not because that is small: the demo's
road is straight and every car spawns exactly on its lane, so the
heading error is identically zero and this code never sees an angle to
wrap. A temporary assertion in the law proved that rather than leaving
it inferred, which also means the pin guards nothing here and these unit
tests are all the law has.

All three tests touching the wrap fail against the old implementation.
One of them nearly did not: an angle summed as -8.0 plus a multiple of
the step has its low bits already clear and survives a fold up and back
unchanged, so the sweep only discriminates once its angles are products.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sensor reports what it found in no particular order, because
relevance is the consumer's idea and not the sensor's. Car-following
means the nearest thing ahead, a learned successor may want the set
sorted some other way or not sorted at all, so the choosing belongs
with whoever is following.

Two things fall out of standing for nothing detected with a range of
1e9 rather than with an absent value. A free road computes as an
ordinary lead so far away that the room the law wants is nothing beside
it, so no following law needs an empty case. And an array padded out to
a fixed length reads correctly with no count beside it, since a padded
slot loses to anything real. That is what the scan looks like once it
has crossed into an FMU, where an array has one length and always that
length.

Ties go to the earlier slot. Which car gets followed matters less than
that a scan answers the same way every time it is read, since the
answer reaches the world hash.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
idm_accel is the Intelligent Driver Model: a gap and an approach rate in,
an acceleration out. Both inputs are relative, which is what a radar
measures. The lead's own speed appears nowhere in the law, so a Doppler
measurement is the natural input rather than a concession.

The room it asks for is a minimum gap, plus a headway of travel, plus
enough to shed the approach rate against a brake between comfortable and
hardest. An open road is the same expression rather than a case of its
own, since at the free-road range the room asked for is nothing beside
the room there is.

Two of the six parameters are the values Treiber's own simulator lists
for a car, the headway and the minimum gap. Its acceleration and braking
are picked to bring stop-and-go waves out of a crowded road, which is a
different thing to show than one car following another, so those two are
this project's. So is the bound on braking, IDM having none, and so is
the floor under the gap, which does nothing but keep a division finite
for a pair that has already collided. The doc comments say which is
which, because a parameter that reads as a knob is where a wiring
mistake goes to hide.

Add, multiply, divide and sqrt only, which IEEE 754 pins. The fourth
power is two squarings rather than powi, whose expansion is the
compiler's business rather than the standard's, and this law is headed
for a world hash that four platforms have to agree on.

The spot value is worked from the published equation by hand. Dropping
the 2 from 2*sqrt(a*b), taking the exponent from four to two, and
halving the headway each fail a test, so the equation is pinned and not
merely exercised.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs Outdated
Comment thread crates/continuo-actors/src/control_laws.rs
edufford and others added 8 commits August 12, 2026 20:51
A range and a range rate describe one thing a sensor found, so passing
them as two slices was the wrong shape twice over. It admitted a pair
that cannot mean anything, a scan one range longer than its rates, with
nothing to say about which half is wrong. And it made both numbers
positional at every call, where a range arriving as a rate puts a lead
somewhere it is not. A Detection carries them together, so neither
mistake can be written down and nothing has to check for it or warn.

The type is plain data that a sensor fills and a law reads, so it sits
in core beside Pose rather than at either end. That also settles a
dependency before it is load-bearing: the radar lands next to the laws
in continuo-actors for now, and when the laws split into a crate of
their own, neither has to reach for the other to name what passes
between them.

The free-road range keeps its value and loses a wrong claim: 1e9 meters
is a million kilometers rather than the kilometer the doc said. What it
is, is a number picked against two constraints, and the doc gives them
now. Far enough that IDM reads it as an open road, which at this range
costs under 2e-12 m/s^2. Small enough to stay finite through whatever is
done to it, since a scan travels as JSON, which has no infinity, and
something downstream may take a car length off it or hand it to a
32-bit float.

The approach rate's sign moves to the law whose convention it is. The
paper defines the approaching rate as the follower's speed minus the
lead's, so it is positive while the gap closes, and a detection measures
the same quantity the other way up.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reader checking this law against the paper has to match six numbers to
six symbols, and a doc comment saying which is which only helps where
the doc comment is. Prefixing each name with the symbol carries that to
every use: v0_speed_tgt is the paper's v0, and there is nothing to
remember at the call site.

It also marks the one parameter that is ours. Five names carry a symbol
and decel_max does not, because the equation has no bound on braking, so
the odd one out says so by its shape.

s0 keeps "min" rather than becoming a target. It is the gap held at a
standstill, a floor under everything else the law asks for, while the
gap actually aimed at is s*, which moves with speed and closing rate.
Calling s0 the target would take the name of the thing that is one.

The line about the parameters not being knobs goes. Where they came from
is already written above it, and what to do when a demo misbehaves is a
matter for the scenario that misbehaves rather than for the struct.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tenth of a meter is a real distance, close enough that two cars can be
that far apart without having touched, so a law that quietly used it in
place of what it was handed was answering a question nobody asked.

The divisor is floored at the smallest positive double instead. That is
not a distance and cannot be mistaken for one, and every gap a road
produces now divides as it stands. What the floor is for is keeping a
zero out of the divisor, since a wanted gap of zero over a gap of zero
is a NaN that spreads into the physics, the hash and everything after.
A gap of nothing gives an enormous quotient instead, and the clamp takes
that to the braking limit.

Nothing observable changes. Swept over speeds, closing rates and every
gap from below zero up through a tenth of a meter, the two floors give
the same answer at every point, because with these parameters anything
that close is already braking as hard as it may.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The law carried two departures from the published equation, both of the
kind other implementations make. For an algorithm that is here to be
representative rather than to drive well, one departure is better than
two, and it should be one this project can justify on its own terms.

The one that stays is that a wanted gap does not go below zero, stated
of the whole expression rather than of the speed-dependent part inside
it. Something has to say it. A lead pulling away contributes a negative
closing term, and the equation as published lets that take the wanted
gap negative, where it squares back into braking, so a car brakes as the
road ahead clears. That is not an extreme case: at 20 m/s with 20 m of
gap and a lead pulling away to 30, the published form commands -1.28
where the answer wanted is +1.20, and -9.98 if the lead reaches 35. A
limit on the output cannot fix it, since -1.28 is an ordinary number to
command and only its sign is wrong.

The braking bound is no longer a parameter. The command is held inside
the two rates the equation already names, so the parameter set is the
published five exactly, and the law invents no number of its own. What
that costs is the emergency stop: a car now brakes no harder than
comfortably, which is a worse driver and a truer transcription. The
spawn distances in the ego-lane work will have to account for it.

The spot check moves from a 40 m gap to 60 m, since the tighter bound
clamps the old one, and a clamped value would check the clamp rather
than the equation.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The equilibrium test held a gap of 35.722003561692034 m with nothing to
say where it came from, which is a number a reader can only take on
trust. It is derived in the test now, from the same cancellation that
defines it: the two terms balance when the crowding term equals the
open-road term, so the gap is the wanted gap over that term's root. The
derivation is the paper's algebra rather than a second call into the
law, so it still checks something.

A second case runs the same balance under the paper's own stated highway
values rather than this project's, over the range of speeds it quotes
them for, and pins the one number the paper states outright: from rest
on an open road, a car accelerates at a. The paper tabulates parameters
and plots figures, and puts no worked acceleration on the page, so its
algebra with its numbers is as close to a reference condition as it
offers. Checking against an independent implementation is a separate
job and stays on the list.

The tuning helper is idm_tuning, beside the pure-pursuit tuning it sits
next to. "following" named the maneuver rather than the thing it
returns.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A controller inside an FMU cannot be handed a `Waypoints`, so the road
travels as values and is rebuilt on the far side. Every field is private
and only two of them are inputs: the arc-length table is derived by the
builders, which leaves the points and the closed flag as the whole of a
path.

The flag is the half that would go missing quietly. The demo's road is
open, and a loop rebuilt as a road answers the same everywhere except off
its ends, so nothing here would notice for a while. So the test compares
by bits rather than by tolerance, over a bend and a closed square,
sampling past both ends where the two kinds part company. That is also
what would catch any future `Waypoints` state that its builders do not
derive.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan preferred a structural parameter sizing the road arrays, so the
road would cross as exactly as many points as it has. `fmi-export` 0.3.0
cannot do it, which a probe against the crate says rather than its
documentation: the causality is accepted and declared, but nothing joins
one to a dimension, and the only path to a variable dimension is a `Vec`
field that names value reference 0, the derive's own `time` variable, and
implements neither of the get and set traits. So the road takes the
fallback form, a fixed array with a count beside it, and the count is an
ordinary parameter rather than a structural one, which would claim a role
it does not have.

Four smaller findings from the same probe are recorded beside it, since
each is a compile error waiting for whoever writes the FMU crate: the
exporting crate needs `fmi` itself as a dependency, `fmi-export` pulls
bindgen in through it, a dotted `name` gives the pose inputs their
structured names for free, and there is no way to declare a 64 bit
integer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The laws already answer every caller alike, so what was missing was the
packaging: a crate whose whole job is to say what crosses an FMI boundary
and to translate either side of it. Nothing here decides anything. A pose
comes back together out of seven scalars, a scan out of two arrays, and
every number after that comes from `control_laws`, which is what makes
the native controller and this copy agree by construction rather than by
somebody keeping them in step.

Two constants join `continuo-actors`, where the radar will read them too.
Both are bounds rather than working numbers, and `MAX_WAYPOINTS` exists
only because an array cannot be trimmed: the count beside it says where
the road stops and the padding starts, since repeating the last point
through the tail would hand `project` a segment of zero length.
`IdmParams::highway_car` becomes a `const fn` so the declared start
values name those numbers rather than copying them.

The road is built at two places on purpose. `configurate` runs as
initialization ends, when the parameters are final, but `calculate_values`
can run before it in that same call and earlier still if a host reads an
output during initialization, so it builds the road when it has none and
`configurate` replaces what it built. The count is checked before
`Waypoints` sees it, because a failed assertion unwinding through the C
interface would take the host process with it, and a refusal carries the
same information where the standard can hold it.

Testing through the exported C interface would mean `unsafe`, which this
workspace does not have, so the substance sits in two pure functions and
the FMI-facing methods are wrappers over them. The arrays are declared
from the constants themselves, so there is no second number to assert
against; the hazard worth guarding is a stale bundle, which no
compile-time check can see and which the importer already catches when a
mapping's pointer count meets the declared dimension.

Bundling and fmpy found what the Rust side did not. Both outputs declared
a start value while being calculated, no initial unknowns were listed
because the derive wants `initial = Calculated` spelled out, and every
description was cut off at the first line of its doc comment. All three
are fixed, and what upstream does is written down in M6-PLAN.md. The
barrier is `python -m fmpy validate`, which exits nonzero on exactly
these, and it lands with the CI bundling step that has somewhere to run
it.

The demo hash does not move, since nothing loads this yet.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread crates/continuo-actors/src/lib.rs Outdated
Comment thread crates/continuo-actors/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/src/lib.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/Cargo.toml
edufford and others added 11 commits August 13, 2026 15:58
`MAX_WAYPOINTS` was never about roads. It exists because `fmi-export`
0.3.0 cannot size an array by a parameter, which is a fact about one
interface, and the FMU is its only user. So it moves into the crate that
declares the array, where the reason for it is in front of whoever reads
it.

`MAX_DETECTIONS` stays in actors, since the radar reads it too, but its
doc stops arguing for the number. The lane length and the car count were
worked backward from a sensor range nothing has settled, which reads as
evidence when it is guesswork. What can be said now is that it is a
placeholder for demo development, picked high enough that truncation is
not a question yet.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`IdmParams::highway_car` already said what this project drives cars with,
so the steering half sitting as four literals in the FMU was the odd one
out. `PurePursuitParams::highway_car` puts it next to its counterpart,
which leaves the FMU picking exactly one number of its own, the speed a
car holds when nobody says otherwise.

Where a parameter has to be positive now says so on the parameter, since
three of IDM's five are divisors and a zero among them arrives as a NaN
rather than as a wrong number, which then spreads into every command the
car makes. Saying it on the struct serves whoever builds one in Rust. It
does nothing for whoever loads the packaged FMU in another tool, and the
next commit is what answers them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Building it lazily meant building it from whatever a host had set so far.
`fmi3ExitInitializationMode` recomputes dirtied values before it calls
`configurate`, and a host may read an output during initialization
earlier still, so the first build could land on half a road and be kept.
Replacing it afterward hid that rather than fixing it.

So `configurate` is the one place a road is built, and a step with no
road to steer along commands nothing and leaves the outputs at the values
they were made with. Nothing downstream has to ask which parameters the
road came from, because there is only one answer.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A road of no length now fails alongside a count no road could have. The
point arrays start full of zeros, so a host that sets the count and
forgets the points sends a road whose every point is the origin, which
builds without complaint and holds a car still at one spot. That is a
wiring mistake, and hearing about it beats driving along it.

Two refusals want one type, so `BadPointCount` becomes an enum, which
also drops the `least` field from the case that never used it. Its text
carries the value that arrived, since a host setting a parameter from
another tool has no other way to see what it sent, and FMI gives it a
status and a log line rather than anything structured. Writing that text
as the error's own Display leaves `refuse` to log whatever it is handed,
which is what the next commit needs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A doc comment protects whoever builds `IdmParams` in Rust. It does
nothing for whoever loads the packaged FMU in another tool, sets
`v0_speed_tgt` to zero, and gets a car commanding NaN at every step
afterward. That reader never sees this source, so the FMU has to say it
back.

The declaration cannot say it: `fmi-export` 0.3.0 has no `min` or `max`
key, so the model description states no bounds and a host's own checker
has nothing to check against. What is left is refusing at run time, which
is what the four divisors do now, each refusal quoting the value that
arrived. Zero, negative, infinity and NaN go the same way, since what
they have in common is that no answer computed from them means anything.

Packing each law's parameters where they are checked gives the two a
place to live, and it is what turns commanding into something that can
fail. `commands` says `calculate_commands` now, since it answers with a
result rather than simply holding one.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declaring the law parameters fixed said a car's driving could never
change once it started, which is not what a scenario wants: a car that
picks up speed, or moves to another lane, is asking for exactly this.
Tunable is FMI's way to say so, and it costs nothing to allow.

It also settles where the parameters are read. A step reads them afresh
and checks them there, which under `fixed` would have been work for a
value that could not have changed, and under `tunable` is what makes a
change take effect at all and keeps a new zero from getting through.

The road stays fixed, and the split is real rather than cautious. Each
instance builds its `Waypoints` once and keeps it, so a road set again
later would be taken and then ignored, and `fixed` is what tells a host
that before it tries.

Driving the bundled FMU through fmpy shows it working: a car at 20 m/s
asked for 30 accelerates, asked for 20 commands nothing, asked for 40
accelerates harder, and asked for 0 is refused with the reason in the
log. That was a throwaway script rather than a check anyone can rerun, so
the FMI-level half stays unguarded until the golden tests load the bundle
through `continuo-fmi`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things in that file answered questions the rest of it does not ask.
`error.rs` now holds both refusals, following the convention
`continuo-core` and `continuo-fmi` already set, even though the two face
opposite ways: `BadInput` is what the FMU tells a host at run time in the
one channel FMI gives it, and `BundleError` is what a Rust caller gets
when a build step has not been run. `require_positive` stays with the
model, being a check the controller runs rather than a description of a
failure.

`bundle.rs` gains a doc saying what uses any of it, since nothing does
yet: the golden tests that run the bundled FMU against the laws it was
built from, and the demo scenario in a later PR. Its failure now names
the directory the search started in and says it walked upward, rather
than leaving "above" to be guessed at, and it no longer calls whoever
asked a test.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An FMU is a package, and this crate says so in three places: the file it
looks for, the error when it is missing, and the module holding both. Two
words for one thing was one too many.

`cargo fmi bundle` keeps its name, being cargo-fmi's rather than ours,
and it is quoted as a command rather than used as a verb. The command
this project owns becomes `cargo xtask package-fmus`, renamed while it is
still only a plan and nothing has to be rewritten to follow.

DECISIONS keeps the one remaining "bundled", which is about a Visual
Studio toolchain and has nothing to do with any of this. That is the
reason renames here go site by site: an ordinary English word does not
belong to whoever used it last.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The model and its tests were most of that file, so a reader met six
hundred lines of interface declaration before learning what the crate
contains. `lib.rs` now holds the module list, the re-exports and the
macro generating the C entry points, which is all anyone needs before
deciding where to look next. The macro stays there because it exports by
name rather than by module path, so it reads as the front door whichever
file the model lives in.

The model becomes `FmuController`, since what the struct is is the FMI
interface, and the laws behind it are what the crate name distinguishes.
A sibling crate carrying a learned model would declare the same variables
and want the same name for the same reason. Nothing moves in the package:
FMI takes its model name from the crate and its identifier from the
cdylib, so both are unchanged, which the repackaged modelDescription
confirms.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The starts and the `Default` impl were two lists of the same twenty-four
values, kept in step by hand and by nothing else. Each declaration now
reads its own field out of `Self::default()`, so there is one list, and a
value can only be changed where it is written. The repackaged model
description carries the numbers it did before, which is what says this
moved where they live rather than what they are.

`set_start_values` expands to one assignment per variable, so the
expression is evaluated twenty-four times, and it runs when an instance
is made and when one is reset, never inside a step. Measured rather than
assumed: 23 ns in release, where the optimizer folds the constructions
down to less than a single `default()` costs, and 3.8 us in the dev
profile the packager builds by default. A car joining a world pays that
once, against an FMU load and a zip extraction in the same breath, so it
does not register.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmi bundle` builds the dev profile unless told otherwise, so the
`.fmu` packaged so far is unoptimized. That does not matter for making an
instance, which happens once per car, but `calculate_values` runs for
every car at every control instant, and PR E puts an FMU in each of them.
So the xtask passes `--release`, recorded here because that is where it
will be written.

Whether it may is the part worth checking rather than assuming, since the
world hash is pinned across four platforms and the golden tests compare a
packaged FMU against natively built laws. Driving both profiles through
fmpy gives bit-identical answers: a following command, an open-road
command, a steering command that goes through `atan2`, and a
near-cancellation landing at -1.5e-15. rustc does not reassociate float
arithmetic at any optimization level, which is why, and the golden tests
are what would notice if that ever changed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread .github/workflows/ci.yml Outdated
Comment thread crates/continuo-fmu-controller-idm/Cargo.toml Outdated
Comment thread crates/continuo-fmu-controller-idm/tests/packaged_fmu.rs
Comment thread crates/continuo-fmu-controller-idm/tests/packaged_fmu.rs Outdated
Comment thread crates/continuo-fmu-controller-idm/tests/packaged_fmu.rs Outdated
edufford and others added 16 commits August 14, 2026 23:19
The guard took a field selector and a sign, multiplied the two and
compared against zero, so `1.0` and `-1.0` sat in argument positions where
they read as thresholds rather than as directions. Anyone asking whether
it looked for an acceleration of 1.0 was reading it the way it was
written. It then turned its answer into an `Option<()>` for each assertion
to turn straight back into the bool it started as.

Four direct assertions say the same thing with nothing to decode: an
acceleration above zero, one below it, and a yaw rate either way. The one
property worth keeping is kept, since a NaN satisfies neither comparison,
so a command that diverged cannot stand as evidence that anything was
commanded. That is now said rather than left to be noticed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
It opened "the comparison below", where what is below is the dependency
list and the comparison is in another file. Naming
`tests/packaged_fmu.rs` says whose dependencies these are, which is the
question a reader of a manifest is asking.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every scan crossing the boundary held at most one real detection, so
nothing distinguished picking the nearest from picking the only one. The
sweep now also sends three at once with the nearest in neither the first
slot nor the last, two at the same range closing at different rates, and
one filling every slot the FMU declares, where nothing is padding and the
whole array is set from the message rather than from the mapping.

Those alone would not be enough, because the sweep runs
`nearest_detection` on both sides of the boundary and would agree on the
wrong car as readily as on the right one. So a test beside them names the
answer instead: given three, the command has to be the one the nearest
alone would give, and it has to differ from what each of the other two
would have given, or the assertion could not tell them apart. A tie is
checked the same way, since ties go to the earlier slot and that is what
makes a scan answer the same way every time it is read.

`one_detection` becomes `scan_of`, taking as many detections as a caller
wants to place.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every situation the sweeps ran was one somebody chose, and a chosen case
is aimed at something it already suspects. So each sweep now runs 250 more
that are aimed at nothing: anywhere from a fifth of a road before its start
to a fifth past its end, twelve meters off to either side, pointing any
direction at all, at any speed up to 45, seeing up to five things ahead at
gaps from smaller than a car to three hundred meters. None of that is a
world this project runs. All of it is a value a host in another tool can
set, and the FMI boundary is where such a host arrives.

From a fixed seed, and from the workspace's own generator rather than a
crate, which are the same decision twice. A failure nobody can reproduce
is a failure nobody can fix, so the situations have to be the same on
every agent and every run, and SplitMix64 is integer arithmetic that
answers alike on all four platforms. The seed is arbitrary; what matters
is that it never changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The step named `continuo-fmu-controller-idm`, so a second FMU crate would
have needed a second line beside it, which is the edit `package-fmus`
exists to avoid making anywhere else. Cargo can select across a workspace
by target name, so the step asks for `--test packaged_fmu` and runs
whichever crates have one.

That rests on every FMU crate spelling it the same way, the comparison in
`tests/packaged_fmu.rs` behind a `packaged-fmu` feature, which is the
convention this crate sets and the step now states. Cargo fails outright
when no package has that target, listing what does exist, so the
convention cannot lapse into a step that quietly checks nothing. What it
does not catch is a crate spelling it differently, which would be skipped
in silence. Discovering `continuo-fmu-*` crates in the xtask and running
each would catch that, and is not bought here: it would be a second way to
run tests where cargo already has one, for a crate that does not exist
yet.

CLAUDE.md's local command moves with it, so what a person is told to type
is what CI types. The last failure on this branch came from those two
being different commands.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--all-features` reached the comparison by happening to include it, which
is a weaker thing than asking. Cargo refuses a feature no selected package
defines, so a rename fails this step, where the blunt flag would enable
whatever the feature became and leave a target compiling to nothing that
passes. Clippy catches that same rename through `unexpected_cfgs`, so this
is a second guard rather than the only one, and it is the one that fails
where the comparison is meant to run.

It also stops the step widening on its own, since `--all-features` would
take in any non-default feature a workspace crate grows later.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Temporary, and this commit is meant to be reverted once the run reports.

The packaged-FMU step rebuilds fourteen crates every run, `fmi-sys` among
them, which is 52 of its 71 seconds on Windows. Nothing about it
reproduces off CI: replaying the whole sequence locally, release build and
packaging included, rebuilds one crate in two seconds. The flag form is
not the cause either, since the step measured 79 seconds naming the
package, 70 with `--all-features` and 71 asking for the feature.

So this asks rather than guesses. `-v` makes cargo print its own reason
before each rebuild, which is what disproved my first theory locally. The
three probes count `fmi-sys` fingerprints and rlibs after the cache
restore, after the debug build, and immediately before the comparison, so
the readings together separate a cache that never carried those units from
a run that takes them away.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Build: debug` took `--all-features`, so it compiled the packaged-FMU
comparison and linked its cdylib, and then `Test: unit` ran without the
feature, changed the crate's feature list and made cargo rebuild it. In
the run measured closely that compile was 26 of the step's 31 seconds on
Windows, and nothing ever read its output.

The flag was there so a target behind a feature would not rot uncompiled.
Clippy covers that and covers it earlier: it keeps `--all-features`, and
`cargo clippy -v` confirms the file is passed to clippy-driver only when
that flag is present. A type error there now fails at `Lint: clippy`
rather than at a test step, which is where the build step wanted it
reported anyway.

This does not touch the fourteen crates the comparison step rebuilds.
Those are the feature being turned on, which is a separate question.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comparison step spent most of its time compiling and the rest running
nine tests that take well under a second, so the CI listing read as though
the tests were slow. This splits the two, and the run that follows
compiles nothing.

The two selections have to match exactly, and why is worth writing down.
Asking for one test target rather than all of them resolves fewer
packages, 142 units against 399, with the whole zenoh tree gone. Their
dependencies then unify features differently, so this crate links a
different `thiserror` and every crate between the two rebuilds. Cargo
reports it as `info of dependency thiserror changed`, which is how the
cause was found: it survives removing the feature entirely, so the feature
was never what triggered it.

Nothing is expected to get faster. The same work happens in a step named
for what it is, which is what makes the cost measurable at all, and this
step has ranged from 37 to 92 seconds across five runs of materially
identical work.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comparison ran as its own step asking for one test target, and asking
for a single target resolves fewer packages than the steps before it: 142
units against 399, with the whole zenoh tree gone. Their dependencies then
unify features differently, so the crate linked a different `thiserror`
and every crate between the two rebuilt. The previous commit measured that
at 12 to 31 seconds, on every agent rather than only Windows.

Packaging and validation move above the tests instead, and both test steps
take `--all-features`, so the comparison runs as one of the integration
targets under the selection everything else already uses. Replaying the
sequence locally, both test steps now compile nothing and the nine tests
run inside `Test: integration`. Two steps go.

The upload moves to the end, because publishing is the one thing here that
leaves the job, and a step runs only when every earlier one succeeded.

CLAUDE.md's local command follows, since naming the single target would
tell a contributor to pay the rebuild this removes.

One claim corrected while nearby. `FMU: validate` said an unmatched glob
would check nothing and pass. It would not: fmpy exits nonzero on a path
that does not exist, so the step failed either way. The guard is worth
keeping for what it says rather than what it catches, and it now says the
packaging step produced none.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the crates table and the usage block had stopped covering.

`continuo-actors` is described as sample components, which it still is and
is no longer all it is: the control laws live there so the native
controller and the exported FMU call one implementation rather than two
kept in step by hand. A reader deciding where a law belongs cannot tell
that from a list of components.

And packaging was documented with nothing that reads what it writes. The
comparison is the reason to run it, so the pair appears together, with the
note that a plain `cargo test --workspace` needs nothing packaged and
skips it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven entries for the work this branch did: that the FMU is the whole
controller with its laws left in `continuo-actors`, and what a compiled
snapshot of those laws costs; the one place the following law departs from
the published equation, and why its parameters are the published five with
no emergency braking of our own; the road crossing as fixed arrays because
the derive cannot size one by a parameter; the model refusing at run time
what its description has no way to state; the packaging task and the merge
that gives one FMU per model rather than one per platform; how the
comparison is gated and where it runs; and why the archived
`proc-macro-error2` is patched rather than silenced.

Three of them record something that cost real time to learn and would cost
it again. Asking cargo for a single test target resolves fewer packages
and so rebuilds a good part of the graph. The plan's `b_max` and
`GAP_FLOOR` were both answered by flooring the wanted gap instead, which
also tells the ego-lane work its spawn distances need revisiting. And the
CI step at issue measured 79, 70, 71, 37 and 92 seconds across five runs
of identical work, so anybody optimizing here should distrust a single
run, including their own.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan asked for the IDM spot values to be cross-checked once against
highway-env, and this is the result rather than the intention. Its
`IDMVehicle` is the unclamped form: `desired_gap` floors nothing and
`acceleration` limits nothing, which is what makes it the useful
comparison rather than a mirror.

Fed our parameters it agrees to the last bit or two at every sampled point
where the wanted gap stays positive, and where it disagrees it disagrees
by exactly the two adaptations this branch documents: it commands -1.280
m/s^2 at 20 m/s behind a lead pulling away, where flooring the wanted gap
gives +1.204, and it runs to -9.977 where the command is held at the
comfortable braking rate.

Nothing changed in the laws, which is the outcome worth having. The
numbers quoted in the entry above were checked against it too, so the
figures this branch argues from are its arithmetic rather than mine.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread DECISIONS.md Outdated
Comment thread DECISIONS.md Outdated
edufford and others added 2 commits August 15, 2026 14:15
The entry claimed all three are called by the native controller too. Only
one is. `PathFollowController` calls `pure_pursuit_yaw_rate`, and nothing
native calls `idm_accel` or `nearest_detection`, because M6 gives traffic
its longitudinal behavior through the FMU and leaves that controller
purely lateral.

That matters because the shared call is the whole argument for keeping the
laws in `continuo-actors`, and it is currently half demonstrated. So the
entry says which half, and PLAN.md defers the component that would finish
it, with the shape left open: a second component keeps the existing one
lateral, which M6 made it deliberately so a learned longitudinal FMU can
run beside native steering, while teaching the existing one to follow is
less code and gives that up. It would also buy a native baseline, since
nothing but the FMU runs IDM in a world today and no scenario can be run
both ways to see what the boundary costs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Its own numbers" left the reader to work out whose, and the sentence sat
after a run of figures that were ours. What was compared is the equation
rather than the tuning, so highway-env was fed our parameters throughout,
and its own are named as the ones not used: a standstill gap of 10 m
against our 2, since it folds a car length in, an acceleration of 3.0
against 1.5, and comfortable braking of 5.0 against 2.0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@edufford
edufford marked this pull request as ready for review August 16, 2026 00:40
Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/fmu-merge.yml Outdated
Comment thread crates/continuo-fmu-controller-idm/tests/packaged_fmu.rs Outdated
edufford and others added 5 commits August 16, 2026 09:08
A workflow cannot be dispatched until it sits on the default branch, so
landing the `workflow_run` trigger beside the workflow would make the first
run of it an automatic one that nothing had tried. Taking the CI run to read
artifacts from as an input instead leaves that first run a chosen one,
against a named run, and the trigger follows once a dispatch has shown the
cross-run download works.

The trigger took two things with it. The conclusion guard existed to keep a
failed CI run's partial artifacts out of a merged FMU, which is a question
only the automatic path asks, since a dispatch names its run deliberately.
And the run id fell back to `github.run_id` when no `workflow_run` event
supplied one, which is the merge run's own id and holds no artifacts, so the
manual path could never have worked.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
fmpy validates what the FMU steps package and has nothing to do with the
viewer, but one environment put its tree, numpy and lxml among others, on the
path where the viewer's tests run. Nothing in the viewer imports any of it
today, so the hazard is prospective: an accidental import would pass here and
fail for everyone who installed the viewer alone.

A virtual environment rather than a second pip invocation into the same one,
because a single resolution either finds a consistent set or refuses, where
two sequential ones can each succeed and leave the environment inconsistent.
The interpreter sits in Scripts on Windows and bin everywhere else, so the
install picks it and names it for the validate step rather than leaving that
step to repeat the choice.

It stays with the other installs, ahead of anything that compiles. fmpy
carries no version bound, so it is the step likeliest to fail on a day
nothing changed, and that is worth hearing in seconds rather than after the
workspace has built.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweeps drive situations picked to reach corners of the input space, and
no car could drive from one to the next, so running them as consecutive
steps asked the model to account for a trajectory that never happened. That
was sound only because these laws carry nothing across a step, which is a
property of the implementation rather than something the test established.
Resetting before each one takes the assumption out.

`fmi3Reset` is what the standard offers for this: it returns an instance to
Instantiated without paying to load the library again, which measures around
a hundred times what a reset costs, and initialization has to happen before
it can step. Instantiated is also where an FMU is before it has been sized,
and Configuration Mode closes before Initialization Mode opens, so the
structural parameters go back in during the reset rather than at that next
step. Without it a reset StateSpace would come back at the size its
description declares while the bindings went on addressing the size the
mapping asked for. Both sets of values are kept now rather than spent on
first use, and BouncingBall and StateSpace each gained a test, checked by
breaking the thing it covers.

Each situation is then stepped twice, because the first step after a reset
is Initialization Mode and `fmi3DoStep` needs an interval behind it that a
start time does not have. The same inputs and the same road are behind both,
so both must command the same thing, and holding the elaborate path to what
a plain step answers is what `fmu_commands` now does. A single step apiece
would have quietly removed `fmi3DoStep` from the sweeps altogether.

Doing the whole of one situation there is also what retires the tick every
caller used to invent: a reset FMU starts over, so each situation runs at the
same pair of instants. The two tests beside it that were stepping
independent situations in sequence are reset now as well, and the contexts
this file builds carry the `dt` a conductor would supply rather than always
claiming a first step.

The comparison costs about 1.2 s where it cost 0.77 s.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`step` does two different things depending on whether the instance has been
initialized, and an `Option<SimTime>` made that read as a null check when
what it models is FMI's own state machine. A named enum says so: the match
arms are Uninitialized and Stepping rather than None and Some, and `reset`
assigns a state rather than clearing a time.

The two are the states an instance rests in between calls. The standard's
machine has more, but with event mode off the rest open and close inside a
single one: Configuration Mode within construction, Initialization Mode
within the step that performs it. That is the distinction a reader needs to
tell the arms apart, and it is worth saying where the field is declared.

Behavior is unchanged, and the demo's world hash not moving is the proof.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two facts in this are the kind that cost a day to rediscover. A reset does
not restore an FMU's size, because it lands in Instantiated and
Configuration Mode has already closed by the time the next step runs. And
resetting rather than rebuilding is what the standard offers for running a
model repeatedly, which measures at about a hundredth of the cost, since
construction extracts the archive and loads the library.

The third is a shape worth recognizing rather than a fact: resetting and
stepping once would have cost no measurable time and would have removed
`fmi3DoStep` from the sweeps entirely. A change that buys safety for free
is worth looking at twice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@edufford
edufford merged commit 2540aed into main Aug 16, 2026
4 checks passed
@edufford
edufford deleted the fmu-controller-idm branch August 16, 2026 22:49
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.

1 participant