Repository navigation
Export the car controller as an FMI 3.0 FMU - #24
Merged
Merged
Conversation
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.
edufford
commented
Aug 13, 2026
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>
edufford
commented
Aug 13, 2026
edufford
commented
Aug 13, 2026
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>
edufford
commented
Aug 14, 2026
`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>
edufford
commented
Aug 15, 2026
edufford
commented
Aug 15, 2026
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>
This reverts commit bccc377.
`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>
This reverts commit 7154546.
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>
This reverts commit 41d471b.
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>
edufford
commented
Aug 16, 2026
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
marked this pull request as ready for review
August 16, 2026 00:40
edufford
commented
Aug 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_HASHdoes not move andthe existing pin asserts that. Traffic starts driving on the FMU in the next
step, which is where the hash moves.
DECISIONS.mdcarries the reasoning, in eight entries dated 2026-08-15 and2026-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, besidenearest_detection, which picks alead out of an unordered scan. Pure pursuit moved there out of
PathFollowController, which now calls it; the demo hash not moving is theproof that was inert.
Waypointsgainedpoints()andis_closed(), sincea road has to cross the boundary as the data it was built from.
continuo-fmu-controller-idmis a shell holding the FMI interfacedeclaration and nothing else, delegating every answer to those functions.
cargo xtask package-fmusbuilds it intocontinuo_fmu_controller_idm.fmu,finding every
continuo-fmu-*crate by prefix so a second one needs no editanywhere.
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.
PathFollowControllercallspure_pursuit_yaw_rate; nothing native callsidm_accel, because traffic will get its longitudinal behavior through theFMU. PLAN.md defers the native follower that would finish the argument.
What the split costs is that a
.fmucarries a compiled snapshot of thelaws: 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 alreadynames, so the parameter set is the published five and the law invents no
number of its own. The plan's
b_maxof 4.0 and itsGAP_FLOORare bothgone. 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_xandroad_yat a fixed length with aroad_point_countbeside them, rather than as arrays sized by a structuralparameter, because
fmi-export0.3.0 cannot declare one. The same crate hasno
minormaxkey, so the model description states no bounds and a host'schecker 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/InitialUnknownswas empty because the derive emits thoseentries only when
initial = Calculatedis spelled out, and everydescription 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 validateover everything packaged, which is the barrier thatwould 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 whenit 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.pyand a workflow combine them into one FMU permodel carrying all four. The merge checks the model descriptions agree
first: two packagings of one commit differ in
generationDateAndTimealone,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_runtrigger arriving with it would makethe 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-fmistays a CI-installed binary rather than a dependency, andproc-macro-error2is patched to the two-line fix rustc's own helpsuggests, 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.rsdrives thepackaged
.fmuacross the FMI boundary and compares it, withto_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
fmi3Resetbetween them, sincethey 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
fmi3DoStepneeds an interval behind it that astart time does not have, so both paths to an output get compared. A single
step would have taken
fmi3DoStepout of the sweeps entirely while costingno 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-fmufeature so a plaincargo test --workspaceneeds nothing packaged, gated by a#![cfg]on thefile rather than
required-features, because CI runs--test '*'and aglob 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::resetwrites the mapping's structural parameters again forthat reason. Without it a reset StateSpace would come back at the 3 its
description declares for
m,nandrwhile the bindings went onaddressing the 2 the mapping asked for.
Asking cargo for a single test target resolves fewer packages.
--test packaged_fmuresolved 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 everycrate 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 CIwas 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.