Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ jobs:

- uses: Swatinem/rust-cache@v2

# Cargo.lock pins every dependency, and some of them are pinned for a
# reason: libm because a different version is a different fingerprint,
# proptest because a persisted regression seed replays its case only
# under the generator that drew it. A build that found the lock stale
# would update it quietly and use whatever it resolved. Fetching with
# `--locked` refuses to touch the lock and fails the job when it would
# have to, so every cargo step after this builds the committed lock.
- name: "Install: dependency crates (locked)"
run: cargo fetch --locked

# 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
Expand Down
17 changes: 10 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,16 @@ fingerprint, and no compile error would say so. `.gitattributes` marks
archive somewhere far from the cause. `DEMO_WORLD_HASH` is pinned because a
world hash that moves quietly is a change nobody sees. CI corrupts a recorded
log on purpose, because a verifier that accepts a tampered log fails only
when it matters. `string-literals.yml` reads for a run of four spaces inside
a string literal, which is what a line-continuation backslash lost from the
end of a source line leaves behind: the literal is still valid Rust, and
`rustfmt` does not reformat what is inside a string, so it accepts the
over-wide line as well. Ruff's `ISC` rules are the same barrier on the
viewer, where the mistake takes Python's own shape: a comma missing from a
collection joins two of its entries rather than separating them.
when it matters. CI fetches the dependency crates with `--locked` before its
first build, because a stale `Cargo.lock` would otherwise be updated quietly
and built against whatever it resolved. `string-literals.yml` reads for a run
of four spaces inside a string literal, which is what a line-continuation
backslash lost from the end of a source line leaves behind: the literal is
still valid Rust, and `rustfmt` does not reformat what is inside a string, so
it accepts the over-wide line as well. Ruff's `ISC` rules are the same
barrier on the viewer, where the mistake takes Python's own shape: a comma
missing from a collection joins two of its entries rather than separating
them.

`string-literals.yml` is a heuristic where the rest are exact, so it is a
workflow of its own and stays out of the required checks: a rule that can be
Expand Down
110 changes: 108 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ fmi-export = "0.3"
# rounds to. Pinned through Cargo.lock, since a different version of it is
# a different fingerprint.
libm = "0.2"
# Property-based testing: a strategy generates the cases and a failure
# shrinks to the smallest one it can find. Test-only, so nothing it draws
# reaches a fingerprint. Pinned through Cargo.lock like libm, since a
# persisted regression seed replays its case only under the generator
# that drew it.
proptest = "1"
Comment thread
edufford marked this conversation as resolved.
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"] }
thiserror = "2"
Expand Down
36 changes: 36 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1479,3 +1479,39 @@ it got there, including the roads not taken.
has a length to divide by. A `TODO` records that the panic wants to
be a `Result` once roads arrive as imported data rather than Rust
literals.

- **2026-09-03**: **Property tests pin what `frenet` promises on any road,
and two of its promises are narrower than the unit tests made them
look.** The roads are generated: an open road is a random walk turning by
up to 150 degrees at a vertex, a closed one a star-shaped polygon, and
both keep segments that do not meet three lane widths apart, since closer
than that they would share lane space.

- **The offset steps where a nearer segment takes over from the line an
open road holds past its end.** The 2026-08-29 entry has the offset go
on measuring across that line, but the nearest segment is chosen by
distance to the road, so at the boundary the value switches from a line
distance to a road distance, and the offset jump in this condition is
expected.

- **The signed offset is continuous beside the road, not everywhere.**
The sign is which side of the nearest segment a point is on, taken
along that segment's direction, and a road that comes back around in
another direction runs in opposite senses along different parts of its
outline. A point far off one side of the outline is then right of one
part and left of another, and where the nearer part changes the sign
flips with the distance still in the hundreds of meters, since the
offset passes through zero only on the road itself. Within half the
clearance, any two segments a point is nearest to meet at a vertex and
agree on its side. Roads that never turned back hid this until a review
asked why they could not.

- **A proptest seed file is committed, the fix lands as a unit test, and
the file is deleted when proptest moves in the lock or a generator
changes.** A seed replays its case only while both stay as they are,
and nothing says when that stops. Rejected: an exact version pin, since
the lock is this repository's pin and covers half the drift at most;
ignoring the file or turning persistence off, which lose the replay
that makes a rerun fail the same way until the fix. CI now fetches the
dependency crates with the lock held before its first build, so a
stale lock fails the job at the cause.
4 changes: 3 additions & 1 deletion crates/continuo-actors/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }

# Dev-dependencies serve the determinism integration test.
# The conductor and the transport serve the determinism integration test,
# and proptest the property tests.
[dev-dependencies]
continuo-conductor = { workspace = true }
continuo-transport = { workspace = true }
proptest = { workspace = true }
serde_json = { workspace = true }
54 changes: 33 additions & 21 deletions crates/continuo-actors/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,9 @@ impl Waypoints {
/// and an external caller like the FMU controller can check before
/// building rather than be panicked.
pub fn check_for_too_short_segments(points: &[(f64, f64)], is_closed: bool) -> Option<usize> {
let segments = if is_closed {
points.len()
} else {
points.len() - 1
};

// Return the waypoint that's too close to its previous neighbor, since
// that would be the one to take out.
(0..segments)
(0..Self::count_segments(points.len(), is_closed))
.find(|&i| {
let a = points[i];
let b = points[(i + 1) % points.len()];
Expand All @@ -97,13 +91,7 @@ impl Waypoints {
);
}

// A closed path has one segment per point (the last closes the
// loop); an open one has a segment between each adjacent pair.
let num_segments = if is_closed {
points.len()
} else {
points.len() - 1
};
let num_segments = Self::count_segments(points.len(), is_closed);
let mut cumulative = Vec::with_capacity(num_segments + 1);
let mut total = 0.0;
cumulative.push(0.0);
Expand All @@ -125,12 +113,22 @@ impl Waypoints {
}
}

/// How many segments the arc-length table covers.
fn num_segments(&self) -> usize {
// Return the segment count. The table holds each segment's end
// plus the leading zero, so it is one longer than the count.
// Whether the path closes is already baked into it by `build`.
self.cumulative.len() - 1
/// How many segments the path has: one per point on a closed road,
/// where the last closes it, and one fewer on an open road.
pub fn num_segments(&self) -> usize {
Self::count_segments(self.points.len(), self.is_closed)
}

/// The same count for points that are not a path yet, which is what
/// the builder and the refusal check use.
fn count_segments(num_points: usize, is_closed: bool) -> usize {
// Return one segment per point when the last closes back to the
// first, and one fewer when the road stops.
if is_closed {
num_points
} else {
num_points - 1
}
}

/// An axis-aligned ellipse approximated by `samples` points.
Expand Down Expand Up @@ -279,7 +277,11 @@ impl Waypoints {
/// line the road was holding. A car that has run out of road is then
/// still in its lane and still a known distance ahead of the one
/// behind it, where stopping `s` at the end would pile every car
/// beyond it onto one arc length and hide them from each other.
/// beyond it onto one arc length and hide them from each other. Any
/// segment nearer than that line still wins, so where the road bends
/// back toward its own extension the offset steps as a car crosses
/// from one to the other, from the distance to the line to the
/// distance to the road.
///
/// **Inside a bend the arc length steps.** Both segments reach a
/// point there and the nearer one changes at the bisector, so `s`
Expand Down Expand Up @@ -482,6 +484,16 @@ mod tests {
Waypoints::build_closed(vec![(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)])
}

#[test]
fn a_closed_road_has_a_segment_per_point_and_an_open_road_one_fewer() {
assert_eq!(square().num_segments(), 4);
assert_eq!(left_corner().num_segments(), 2);
assert_eq!(
Waypoints::build_straight((0.0, 0.0), (100.0, 0.0)).num_segments(),
1
);
}

#[test]
fn arc_length_lookup() {
let p = square();
Expand Down
Loading
Loading