Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
e44017d
Move the steering law out of the component that publishes it
edufford Aug 12, 2026
4c46a1d
Merge main to pick up CLAUDE.md
edufford Aug 13, 2026
cf7f842
Write the laws for whatever obeys them, not for cars
edufford Aug 13, 2026
b18f97f
Name the pursuit parameters after the quantities they are
edufford Aug 13, 2026
2e4b947
Wrap an angle by peeling its sign off first
edufford Aug 13, 2026
d197d40
Let the follower pick its own lead out of a scan
edufford Aug 13, 2026
72996f5
Give the follower the law it follows with
edufford Aug 13, 2026
7a663ff
Keep a detection's two numbers in one value
edufford Aug 13, 2026
7a65d17
Carry the equation's own symbols in the parameter names
edufford Aug 13, 2026
40bbf4e
Floor the gap at a number rather than at a distance
edufford Aug 13, 2026
9e65fcf
Keep the equation as published under one adaptation
edufford Aug 13, 2026
84b7100
Derive what the tests were asserting from constants
edufford Aug 13, 2026
e489735
Let a road cross as the data it was built from
edufford Aug 13, 2026
43f783e
Settle how long the FMU's arrays may be
edufford Aug 13, 2026
c1b57a8
Package the control laws as an FMU
edufford Aug 13, 2026
39821a0
Keep each array bound where its reason lives
edufford Aug 14, 2026
fb04dcf
Name the steering tuning set beside the following one
edufford Aug 14, 2026
64e648b
Build the road only once its parameters are final
edufford Aug 14, 2026
ab70e8a
Refuse a road that is not one
edufford Aug 14, 2026
2fcac62
Refuse parameters the laws divide by
edufford Aug 14, 2026
f574a4c
Let a host retune a car between steps
edufford Aug 14, 2026
4cfd8c7
Move the errors and the bundle lookup out of lib.rs
edufford Aug 14, 2026
2ec7fa9
Say package rather than bundle
edufford Aug 14, 2026
06004ca
Leave lib.rs as the crate's front door
edufford Aug 14, 2026
e4728b7
Declare every start value from one place
edufford Aug 14, 2026
e549280
Settle that the packaged FMU is built optimized
edufford Aug 14, 2026
2367314
Say which law each parameter belongs to
edufford Aug 14, 2026
6e1c297
Name what the tests are holding
edufford Aug 14, 2026
85c87cb
Say why the lint table looks wrong to an editor
edufford Aug 14, 2026
5d504ad
Add an xtask, so packaging an FMU is one command
edufford Aug 14, 2026
b44cda1
Package and validate the FMUs in CI
edufford Aug 14, 2026
7655c7e
Say what the FMU is where a host actually reads it
edufford Aug 14, 2026
e7315e8
Call them FMU crates
edufford Aug 14, 2026
79bf108
Group the CI steps by what they are
edufford Aug 14, 2026
46d26c7
Say why an FMU is packaged optimized, and name the profile it is not
edufford Aug 14, 2026
7135633
Keep each platform's packaged FMU as an artifact
edufford Aug 14, 2026
70f01d9
Record the dependency that will stop compiling
edufford Aug 14, 2026
6379f92
Start only the parts of pygame that draw
edufford Aug 14, 2026
4f2d5d3
Open each script with a line that says what it runs
edufford Aug 14, 2026
a48548c
Check the profiles agree without building a release test binary
edufford Aug 14, 2026
5c673f4
Note that the viewer counts components and says actors
edufford Aug 15, 2026
ec63f62
Combine FMUs carrying different platforms' binaries into one
edufford Aug 15, 2026
48ff150
Publish one FMU per model, carrying every platform
edufford Aug 15, 2026
90ea18f
Build the fixed proc-macro-error2 rather than warning about it
edufford Aug 15, 2026
ded0421
Link what the patch came from and why there is no upgrade
edufford Aug 15, 2026
7894efa
Note the verify task the xtask should grow
edufford Aug 15, 2026
36d5723
Compare the packaged FMU against the laws it carries
edufford Aug 15, 2026
da3ca91
Gate the comparison in the file rather than in the manifest
edufford Aug 15, 2026
1ff3710
Say what the vacuity guard checks rather than encoding it
edufford Aug 15, 2026
8c6eb59
Point the dev-dependency comment at what actually follows it
edufford Aug 15, 2026
b320ca0
Put more than one car in the scan the FMU chooses from
edufford Aug 15, 2026
dfc1af8
Put the car where nobody thought to put it
edufford Aug 15, 2026
6ca1509
Select the comparison by target name rather than by package
edufford Aug 15, 2026
c3cffe7
Ask for the feature by name rather than for all of them
edufford Aug 15, 2026
bccc377
Ask CI why the comparison rebuilds what it already has
edufford Aug 15, 2026
e04c639
Revert "Ask CI why the comparison rebuilds what it already has"
edufford Aug 15, 2026
7154546
Stop building what the next step throws away
edufford Aug 15, 2026
3684ad9
Revert "Stop building what the next step throws away"
edufford Aug 15, 2026
41d471b
Build the comparison in a step that says it is building
edufford Aug 15, 2026
a7e43c7
Revert "Build the comparison in a step that says it is building"
edufford Aug 15, 2026
0b46f11
Run the comparison where the tests already are
edufford Aug 15, 2026
21a542b
Say in the README what the FMU crate needs from the reader
edufford Aug 15, 2026
9a2d4ab
Record what PR B settled
edufford Aug 15, 2026
28e9b02
Check the equation against somebody else's implementation
edufford Aug 15, 2026
f0160fb
Say which of the laws the native side actually calls
edufford Aug 16, 2026
591fbb3
Name whose parameters the cross-check used
edufford Aug 16, 2026
ea16628
Land the FMU merge workflow with a manual trigger alone
edufford Aug 16, 2026
4f30e89
Install fmpy in an environment of its own
edufford Aug 16, 2026
88bfea3
Run each packaged-FMU situation as a run of its own
edufford Aug 16, 2026
0ea3086
Name the two FMI states an imported FMU rests in
edufford Aug 16, 2026
b87dddf
Record why the packaged-FMU comparison resets between situations
edufford Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# `cargo xtask <task>` runs the workspace's own tool. Cargo has no
# user-defined targets and its aliases cannot chain commands, so a task
# that is more than one invocation lives in a binary, and this is what
# makes it something a person types.
[alias]
xtask = "run --package xtask --"
187 changes: 156 additions & 31 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,37 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Install Rust (stable)
# Everything a step later needs is installed first, so a missing tool is
# reported before any of the work rather than partway through it. All
# three are cached; only the second ever compiles anything.
- name: "Install: Rust (stable)"
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

# Ordered cheapest first, so an unformatted file is reported in seconds
# rather than after the workspace has been compiled. Steps only run when
# every earlier one succeeded.
- name: "Lint: format"
run: cargo fmt --all --check
# Pinned by version, since a packager that changed under us would
# rewrite modelDescription.xml on a commit that touched nothing. The
# cache key carries the runner because the binary is native, and a miss
# costs a release build of cargo-fmi, which is why it is cached at all.
- name: "Install: cargo-fmi (cached)"
uses: actions/cache@v6
id: cargo-fmi-cache
with:
path: |
~/.cargo/bin/cargo-fmi
~/.cargo/bin/cargo-fmi.exe
key: cargo-fmi-0.3.0-${{ runner.os }}-${{ runner.arch }}

- name: "Install: cargo-fmi (on a cache miss)"
if: steps.cargo-fmi-cache.outputs.cache-hit != 'true'
run: cargo install cargo-fmi --version 0.3.0 --locked

# The viewer installs with plain `pip`, not `uv`. `uv.lock` is committed
# as a convenience and nothing requires it, so installing the ordinary
# way is what keeps that true.
- name: Install Python
- name: "Install: Python (cached)"
uses: actions/setup-python@v7
with:
python-version: "3.12"
Expand All @@ -68,14 +82,35 @@ jobs:
working-directory: python
run: python -m pip install -e . pytest ruff

- name: "Lint: ruff"
working-directory: python
# fmpy validates what the FMU steps below package, and is nothing to do
# with the viewer, so it gets an environment of its own. It brings numpy
# and lxml among others, and sharing one would run the viewer's tests
# against a tree nobody who installs the viewer has, where an accidental
# import of any of it passes here and fails for them.
- name: "Install: fmpy"
shell: bash
run: |
ruff check .
ruff format --check .
# Install fmpy in a virtual environment, named for the later steps.
python -m venv .venv-fmpy
if [ -x .venv-fmpy/bin/python ]; then
fmpy_python=.venv-fmpy/bin/python # Linux and macOS
else
fmpy_python=.venv-fmpy/Scripts/python # Windows
fi
"$fmpy_python" -m pip install fmpy
echo "FMPY_PYTHON=$fmpy_python" >> "$GITHUB_ENV"

# Lints run before anything is built for real, cheapest first, so an
# unformatted file is reported in seconds rather than after a compile.
# Steps only run when every earlier one succeeded.
- name: "Lint: format"
run: cargo fmt --all --check

# `--all-features` so a target held behind one is still linted. The
# packaged-FMU comparison is such a target, and it would otherwise be
# the one file in the workspace nothing here reads.
- name: "Lint: clippy"
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

# Catches broken intra-doc links, which nothing else does: the crates
# cross-reference each other heavily, and a renamed item leaves a link
Expand All @@ -85,20 +120,98 @@ jobs:
env:
RUSTDOCFLAGS: -D warnings

- name: "Lint: ruff"
working-directory: python
run: |
ruff check .
ruff format --check .

# A build of its own, so a compile error is reported as one rather than
# as whichever test step reached it first. `--all-features` for the same
# reason clippy takes it: a target behind a feature compiles here rather
# than in whichever later step first needs it.
- name: "Build: debug"
run: cargo build --workspace --all-targets --all-features

# Before the tests, because one of them loads the `.fmu` this writes.
# Packaging after them would need a second cargo run for that one
# test, and asking for a single target resolves fewer packages, which
# changes their dependencies' features and rebuilds much of the graph.
#
# Names no package: the task finds every `continuo-fmu-*` crate
# itself, so a second FMU crate needs no edit here.
- name: "FMU: package"
run: cargo xtask package-fmus

# Validate with fmpy to get an independent check on the correctness of
# the packaged FMUs. The glob is guarded because one matching nothing
# expands to itself, and naming the empty directory beats fmpy failing
# to open a path with a star in it.
- name: "FMU: validate"
shell: bash
run: |
# Validate every packaged FMU with fmpy.
packaged=(target/fmu/*.fmu)
if [ ! -e "${packaged[0]}" ]; then
echo "ERROR: no .fmu in target/fmu; the packaging step produced none"
exit 1
fi
for fmu in "${packaged[@]}"; do
"$FMPY_PYTHON" -m fmpy validate "$fmu"
done

# `--lib` and `--test '*'` partition the suite: the first runs the unit
# tests inside each lib, the second the integration targets in tests/.
# Note `--tests` would *not* work as the second half, because it
# includes lib unit tests too, so the two steps would re-run them.
#
# `--all-features` matches the build above, so neither step compiles
# anything: a changed feature list alone makes cargo rebuild a crate.
# It is also what runs the packaged-FMU comparison, one of the
# integration targets.
- name: "Test: unit"
run: cargo test --workspace --lib
run: cargo test --workspace --all-features --lib

- name: "Test: integration"
run: cargo test --workspace --test '*'
run: cargo test --workspace --all-features --test '*'

- name: "Test: viewer"
working-directory: python
run: pytest -v

# Release from here on, so the smokes run against the profile the
# packaged FMU was built at and will be loaded beside. Packaging above
# asks for `--release` itself, since a host loads the binary it finds
# and cannot rebuild it.
- name: "Build: release"
run: cargo build --release --workspace --examples

# The world hash is pinned by a test that runs debug, and everything
# after this point runs release, so this is what carries the pin across
# to the other profile. rustc does not reassociate float arithmetic at
# any optimization level, which is why the two agree; this is what would
# notice if that stopped being true, on whichever agent it stopped on.
- name: "Test: the world hash holds in release too"
shell: bash
run: |
# Run the demo under both profiles and compare the hash it prints.
debug_hash=$(cargo run -q -p continuo-examples --example traffic \
| sed -n 's/.*world hash \([0-9a-f]*\).*/\1/p')
release_hash=$(cargo run -q --release -p continuo-examples --example traffic \
| sed -n 's/.*world hash \([0-9a-f]*\).*/\1/p')
echo "debug $debug_hash, release $release_hash"

# Two empty captures would otherwise match, so a demo that stopped
# printing its hash would pass this rather than fail it.
if [ -z "$debug_hash" ]; then
echo "ERROR: no world hash in the demo's output; this check would be vacuous"
exit 1
fi
if [ "$debug_hash" != "$release_hash" ]; then
echo "ERROR: the world hash differs between profiles"
exit 1
fi

# Smoke steps run the shipped examples end to end, covering what the
# test suites cannot: that the examples themselves build and run, and
# that the artefacts one produces are what the next consumes. They are
Expand All @@ -108,13 +221,13 @@ jobs:
#
# New end-to-end checks belong here, with the same "Smoke:" prefix.
- name: "Smoke: traffic demo"
run: cargo run -p continuo-examples --example traffic
run: cargo run --release -p continuo-examples --example traffic

- name: "Smoke: record a run and verify the unmodified log"
shell: bash
run: |
cargo run -q -p continuo-examples --example traffic_record -- ci-run.jsonl
cargo run -q -p continuo-examples --example traffic_verify -- ci-run.jsonl
cargo run -q --release -p continuo-examples --example traffic_record -- ci-run.jsonl
cargo run -q --release -p continuo-examples --example traffic_verify -- ci-run.jsonl

# `verification_stops_at_the_first_divergence` in the highway tests
# already checks that a tampered log is rejected, where it can be run
Expand All @@ -124,27 +237,29 @@ jobs:
#
# Neither `if` below can become `! cmd`. Bash exempts an inverted
# command from `set -e`, so the short form would never fail the step.
#
# The awk corrupts one pose x-coordinate mid-run, at the first match on
# or after line 6000 (roughly sim time 14 s of 30), keeping the JSON
# valid, so verification checks a long clean prefix before diverging
# and stops there rather than running to completion.
- name: "Smoke: verification detects a modified log"
shell: bash
run: |
# Corrupt one pose x-coordinate mid-run, at the first match on or
# after line 6000 (roughly sim time 14 s of 30), keeping the JSON
# valid, so verification checks a long clean prefix before
# diverging (and stops there rather than running to completion).
# Corrupt one pose mid-run, then check verification rejects the log.
awk 'NR>=6000 && !done && /"x":/ { sub(/"x":[-0-9.eE]*/, "\"x\":99.9"); done=1 } { print }' \
ci-run.jsonl > ci-tampered.jsonl
if cmp -s ci-run.jsonl ci-tampered.jsonl; then
echo "ERROR: awk matched nothing, log was NOT modified; this check would be vacuous"
exit 1
fi
if cargo run -q -p continuo-examples --example traffic_verify -- ci-tampered.jsonl; then
if cargo run -q --release -p continuo-examples --example traffic_verify -- ci-tampered.jsonl; then
echo "ERROR: verification PASSED on the modified log; divergence was NOT detected"
exit 1
fi

- name: "Smoke: open-loop resimulation from the recorded log"
shell: bash
run: cargo run -q -p continuo-examples --example traffic_resim -- ci-run.jsonl
run: cargo run -q --release -p continuo-examples --example traffic_resim -- ci-run.jsonl

# The viewer's own tests build their logs from synthetic events. This is
# the only check that it reads what the conductor actually writes, which
Expand Down Expand Up @@ -188,12 +303,22 @@ jobs:
# A smoke, not a benchmark: a shared runner cannot hold a rate steady
# enough to assert one, so this checks that a large world still runs and
# leaves the numbers for a human reading the log.
#
# Release, because the numbers it prints mean nothing from a debug build,
# and `--no-default-features` so that costs a release build of 60 crates
# rather than the 911 the viz feature brings in. This example needs none
# of them.
- name: "Smoke: a scaled world"
run: >
cargo run --release -p continuo-examples
--no-default-features --example traffic_scale
run: cargo run --release -p continuo-examples --example traffic_scale

# Last, because publishing is the one thing here that leaves the job.
# A step only runs when every earlier one succeeded, so an FMU reaches
# the merge workflow only from a run that passed all of it.
#
# Every FMU the workspace packages, each carrying this platform's
# binary alone. One artifact per platform rather than per FMU, because
# an upload step produces one artifact and a workflow cannot loop over
# steps, so a name per FMU would mean a step per FMU to keep updated.
# The merge workflow groups them by file name instead.
- name: "FMU: upload"
uses: actions/upload-artifact@v7
with:
name: fmus-${{ runner.os }}-${{ runner.arch }}
path: target/fmu/*.fmu
# A step that collected nothing would otherwise pass.
if-no-files-found: error
72 changes: 72 additions & 0 deletions .github/workflows/fmu-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Merge FMUs

# CI packages every FMU crate on each of its four agents, and each archive
# carries only the binary for the platform it ran on. This combines each FMU's
# four builds into the one archive FMI 3.0 expects, so a user downloads a
# single file per FMU that loads wherever they are rather than picking the
# right one of four.
#
# Run by hand, naming the CI run whose artifacts to take. The `workflow_run`
# trigger that fires it on every green run on main follows separately: a
# workflow cannot be dispatched until it sits on the default branch, so landing
# both at once would make the first run of this an automatic one that nothing
# had tried. A dispatch against a real CI run is what tries it.
on:
workflow_dispatch:
inputs:
run_id:
description: "The CI run whose FMU artifacts to merge"
required: true

# Reading another run's artifacts needs `actions: read`, which the default
# token does not carry. Naming the two it uses also drops the rest.
permissions:
actions: read
contents: read

jobs:
merge_fmus:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: "Install: Python"
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: "Install: fmpy"
run: python -m pip install fmpy

# One directory per platform, named as CI named them.
- name: "Download: every platform's FMUs"
uses: actions/download-artifact@v8
with:
pattern: fmus-*
path: artifacts
run-id: ${{ inputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

# Given the download directory rather than each artifact inside it, so
# no shell has to expand a glob, and the script fails by itself when
# nothing was downloaded.
- name: "Merge: each FMU's builds into one"
working-directory: python
run: python scripts/merge_fmus.py ../artifacts --out-dir ../merged

# The same second opinion CI asks of each platform's FMU, asked again of
# the ones assembled from them, since merging rewrites the archive.
- name: "Validate: the merged FMUs"
shell: bash
run: |
# Validate every merged FMU with fmpy.
for fmu in merged/*.fmu; do
python -m fmpy validate "$fmu"
done

- name: "Upload: the merged FMUs"
uses: actions/upload-artifact@v7
with:
name: fmus-all-platforms
path: merged/*.fmu
if-no-files-found: error
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# the same reasoning that pins the Rust ones. It is only a convenience for uv,
# and nothing here requires uv, since `pyproject.toml` alone is what pip or any
# other installer reads.
.venv/
.venv*/
__pycache__/
*.pyc
.pytest_cache/
Expand Down
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ rather than after the workspace has compiled:

```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --all-features -- -D warnings
RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps
cargo test --workspace
cargo run -p continuo-examples --example traffic
Expand All @@ -77,6 +77,19 @@ and a renamed item leaves a broken intra-doc link that still compiles.
CI splits the test step in two, `--lib` then `--test '*'`, so neither reruns
the other's tests. One `cargo test --workspace` covers both locally.

A change reaching an FMU crate or the laws it links needs two more commands,
in this order, because a `.fmu` carries its own compiled copy of everything it
links and the suite above compares nothing against it:

```sh
cargo xtask package-fmus
cargo test --workspace --all-features
```

`continuo-actors` is the one to watch, since editing a control law there
leaves the packaged FMU a build behind and `cargo test --workspace` stays
green. Packaging needs `cargo install cargo-fmi` once.

Python changes also need `ruff check .`, `ruff format --check .` and `pytest`
from `python/`.

Expand Down
Loading