From 9a047de3f5c396600a1622fffedd85b6ec7d36c5 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 16:41:29 -1000 Subject: [PATCH 01/11] Pin what frenet promises on any road with property tests The unit tests in path.rs check frenet on four roads drawn by hand. This adds proptest as the workspace's first property-testing dependency and six properties over generated roads, turned and shifted anywhere in the plane: a point on the road reads back its own arc length, a lane point round trips while it stays within the bend's reach, the offset is the distance to the road, the offset changes no faster than a walking point does, a loop's arc length stays within one lap, and an open road's arc length runs on past both ends with the lane offset holding. Every road is generated so that it cannot cross itself, since a crossing puts a point on two stretches at once and rounding then picks between them. An open road steps at least a meter along one axis per point; a closed one is a star-shaped polygon walked in angular order, with no angular gap reaching half a turn. That last bound was the one lesson the stress runs taught: a wider gap sends a segment across the center and through the far side, which showed up as an offset changing sign a kilometer from the road and was the generator's fault rather than frenet's. The property tests reach only the public API, so they live as an integration test. Proptest's default regression-file location assumes a crate root the test does not have, so the config places it beside the source. The tests cost the actors crate's clean test build under a second and run in a few hundredths of a second. The demo world hash is unchanged, since nothing here reaches a fingerprint. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 110 ++++- Cargo.toml | 4 + crates/continuo-actors/Cargo.toml | 4 +- .../tests/frenet_properties.rs | 414 ++++++++++++++++++ 4 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 crates/continuo-actors/tests/frenet_properties.rs diff --git a/Cargo.lock b/Cargo.lock index 23edbf8..507cdc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.1" @@ -425,6 +440,7 @@ dependencies = [ "continuo-core", "continuo-transport", "libm", + "proptest", "serde", "serde_json", "tracing", @@ -2305,6 +2321,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.11" @@ -2413,10 +2454,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -2438,6 +2489,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2447,6 +2508,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -2462,6 +2532,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -2721,6 +2800,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3583,6 +3674,12 @@ dependencies = [ "spin 0.10.1", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3702,6 +3799,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4192,7 +4298,7 @@ dependencies = [ "aes 0.8.4", "hmac 0.12.1", "rand 0.8.7", - "rand_chacha", + "rand_chacha 0.3.1", "sha3", "zenoh-result", ] diff --git a/Cargo.toml b/Cargo.toml index a97c6f4..598d86c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ 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. +proptest = "1" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"] } thiserror = "2" diff --git a/crates/continuo-actors/Cargo.toml b/crates/continuo-actors/Cargo.toml index 3fdb67b..43e835c 100644 --- a/crates/continuo-actors/Cargo.toml +++ b/crates/continuo-actors/Cargo.toml @@ -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 on the road. [dev-dependencies] continuo-conductor = { workspace = true } continuo-transport = { workspace = true } +proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs new file mode 100644 index 0000000..4970218 --- /dev/null +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -0,0 +1,414 @@ +//! Property tests on `Waypoints::frenet`: what holds on any road, rather +//! than on the handful of fixtures the unit tests in `path.rs` draw by +//! hand. +//! +//! Every road here is generated so that it cannot cross itself. A crossing +//! puts one point on two stretches of road at once, and which of them is +//! nearer is then a matter of rounding. An open road is a walk that never +//! turns back along one axis, and a closed one is a star-shaped polygon +//! around a center, and both are then turned and moved anywhere in the +//! plane so that no property passes by leaning on an axis. + +use std::f64::consts::TAU; + +use continuo_actors::Waypoints; +use proptest::prelude::*; +use proptest::test_runner::FileFailurePersistence; + +/// How close two answers must be to count as the same. +/// +/// Coordinates here run to a thousand meters or so, where a rounding +/// error is around 1e-13, and no distance a car cares about is anywhere +/// near a nanometer. +const TOLERANCE: f64 = 1e-9; + +/// The distance from `(x, y)` to each segment of `road`, in road order. +/// +/// Worked out from the points on their own, so it shares nothing with +/// `frenet` beyond the geometry and stands as the oracle for its offset. +fn distance_to_each_segment(road: &Waypoints, x: f64, y: f64) -> Vec { + let points = road.points(); + let segments = if road.is_closed() { + points.len() + } else { + points.len() - 1 + }; + + // Return one distance per segment, each to the nearest point of that + // segment rather than of its line. + (0..segments) + .map(|i| { + let (ax, ay) = points[i]; + let (bx, by) = points[(i + 1) % points.len()]; + let (dx, dy) = (bx - ax, by - ay); + let along = (((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0); + let (px, py) = (ax + dx * along, ay + dy * along); + ((x - px).powi(2) + (y - py).powi(2)).sqrt() + }) + .collect() +} + +/// The same distances, nearest first. +fn distances_nearest_first(road: &Waypoints, x: f64, y: f64) -> Vec { + let mut distances = distance_to_each_segment(road, x, y); + distances.sort_by(f64::total_cmp); + + // Return them sorted, so the nearest segment is first and the runner-up + // second. + distances +} + +/// The distance from `(x, y)` to the line through `from` and `to`, rather +/// than to the segment between them. +fn distance_to_line(from: (f64, f64), to: (f64, f64), x: f64, y: f64) -> f64 { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + + // Return the perpendicular distance, the cross product over the + // length. + ((dx * (y - from.1) - dy * (x - from.0)) / (dx * dx + dy * dy).sqrt()).abs() +} + +/// Which stretch of an open road's arc length `s` falls on. The offset is +/// continuous within each stretch and can step between two of them, +/// where the line a road holds past its end gives way to a nearer bend. +#[derive(Debug, PartialEq, Eq)] +enum Stretch { + BeforeTheStart, + OnTheRoad, + PastTheEnd, +} + +fn stretch(road: &Waypoints, s: f64) -> Stretch { + // Return the stretch. A loop has no ends, so all of it is one. + if road.is_closed() || (0.0..=road.total_length()).contains(&s) { + Stretch::OnTheRoad + } else if s < 0.0 { + Stretch::BeforeTheStart + } else { + Stretch::PastTheEnd + } +} + +/// A turn and a shift, taking a road drawn against the axes anywhere in +/// the plane facing any way. +#[derive(Debug, Clone, Copy)] +struct Placement { + angle: f64, + shift: (f64, f64), +} + +impl Placement { + fn apply(&self, (x, y): (f64, f64)) -> (f64, f64) { + let (sin, cos) = (libm::sin(self.angle), libm::cos(self.angle)); + + // Return the point turned about the origin and then shifted. + ( + cos * x - sin * y + self.shift.0, + sin * x + cos * y + self.shift.1, + ) + } +} + +fn placement() -> impl Strategy { + (0.0..TAU, -500.0..500.0, -500.0..500.0).prop_map(|(angle, dx, dy)| Placement { + angle, + shift: (dx, dy), + }) +} + +/// A point anywhere a road can reach and well beyond. +fn anywhere() -> impl Strategy { + (-1000.0..1000.0, -1000.0..1000.0) +} + +/// An open road of 2 to 8 points that never turns back on itself. Each +/// point lies at least a meter further along one axis than the last and +/// anywhere within a hundred meters of it on the other, so a bend can be +/// as sharp as it likes short of doubling back, and no segment can be +/// short enough for the road to refuse. +/// +/// Every range here excludes what a float can otherwise be, since a +/// road with a NaN in it is not a road, and the refusal check guards the +/// builder all the same: a road it would refuse is a case to discard +/// rather than a panic to report. +fn open_road() -> impl Strategy { + (2..=8usize) + .prop_flat_map(|n| { + ( + proptest::collection::vec(1.0..100.0f64, n - 1), + proptest::collection::vec(-100.0..100.0f64, n), + placement(), + ) + }) + .prop_map(|(steps, ys, placement)| { + let mut x = 0.0; + let mut points = Vec::with_capacity(ys.len()); + for (i, y) in ys.into_iter().enumerate() { + if i > 0 { + x += steps[i - 1]; + } + points.push(placement.apply((x, y))); + } + + // Return the walk, placed. + points + }) + .prop_filter("a segment the road would refuse", |points| { + Waypoints::check_for_too_short_segments(points, false).is_none() + }) + .prop_map(Waypoints::build_open) +} + +/// A closed road of 3 to 12 points around a center, each at its own +/// angle and radius and walked in angular order, so it cannot cross +/// itself. Half of them are walked the other way round, so a corner +/// turns right as often as it turns left. +/// +/// The angles come from proportional gaps. The widest gap is under what +/// the others add up to, so no gap reaches half a turn: a segment +/// spanning that much would pass across the center and through the +/// segments on the far side, and the polygon would no longer be simple. +/// No two points are closer than about three degrees apart at five +/// meters out, a quarter of a meter, which keeps every segment far longer +/// than the road's minimum. The filter guards the builder all the same, +/// as `open_road` says. +fn closed_road() -> impl Strategy { + (3..=12usize) + .prop_flat_map(|n| { + ( + proptest::collection::vec(1.0..(n - 1) as f64, n), + proptest::collection::vec(5.0..100.0f64, n), + any::(), + placement(), + ) + }) + .prop_map(|(gaps, radii, clockwise, placement)| { + let whole_turn: f64 = gaps.iter().sum(); + let mut turned = 0.0; + let mut points: Vec<_> = gaps + .iter() + .zip(&radii) + .map(|(gap, radius)| { + let angle = TAU * turned / whole_turn; + turned += gap; + placement.apply((radius * libm::cos(angle), radius * libm::sin(angle))) + }) + .collect(); + if clockwise { + points.reverse(); + } + + // Return the polygon, placed and walked either way round. + points + }) + .prop_filter("a segment the road would refuse", |points| { + Waypoints::check_for_too_short_segments(points, true).is_none() + }) + .prop_map(Waypoints::build_closed) +} + +/// Either kind of road, since most of what `frenet` promises holds on +/// both. +fn any_road() -> impl Strategy { + prop_oneof![open_road(), closed_road()] +} + +proptest! { + // Each case is a few segments' worth of arithmetic, so a thousand of + // them cost less than one compile, and every run draws a fresh + // thousand. A failure's seed lands in `proptest-regressions` beside + // this file, to be committed so the case is rerun from then on; the + // default location is relative to a crate root, which an integration + // test does not have. + #![proptest_config(ProptestConfig { + failure_persistence: Some(Box::new(FileFailurePersistence::WithSource( + "proptest-regressions", + ))), + ..ProptestConfig::with_cases(1024) + })] + + #[test] + fn a_point_on_the_road_reads_its_own_arc_length( + road in any_road(), + fraction in 0.0..1.0f64, + ) { + // The total itself is where a loop wraps back to zero, so the + // fraction stops short of it. + let s = fraction * road.total_length(); + prop_assume!(s < road.total_length()); + + let on_road = road.point_at(s); + let (back_s, offset) = road.frenet(on_road.x, on_road.y); + prop_assert!((back_s - s).abs() < TOLERANCE, "{s} came back {back_s}"); + prop_assert!(offset.abs() < TOLERANCE, "{s} read {offset} off the road"); + } + + #[test] + fn a_lane_point_round_trips_within_the_bends_reach( + road in any_road(), + fraction in 0.0..1.0f64, + share_of_reach in -0.95..0.95f64, + ) { + let s = fraction * road.total_length(); + prop_assume!(s < road.total_length()); + let on_road = road.point_at(s); + + // How far a lane can sit off this point of the road before some + // other stretch of it is nearer than the stretch it is on: half + // the distance to the nearest other stretch, since moving a point + // by a distance brings it at most that much closer to anything. + // The nearest stretch of all is the one it is on, at no distance, + // so the runner-up is the one that bounds the reach. A point on a + // vertex is on two stretches at once and has no reach at all, and + // one within a micron of a vertex has none worth a lane. A road + // with nothing else near has more reach than any lane needs. + let distances = distances_nearest_first(&road, on_road.x, on_road.y); + let reach = distances.get(1).copied().unwrap_or(f64::INFINITY) / 2.0; + let reach = reach.min(50.0); + prop_assume!(reach > 1e-6); + + let lateral = share_of_reach * reach; + let point = road.point_at_offset(s, lateral); + let (back_s, back_lateral) = road.frenet(point.x, point.y); + prop_assert!((back_s - s).abs() < TOLERANCE, "s {s} came back {back_s}"); + prop_assert!( + (back_lateral - lateral).abs() < TOLERANCE, + "lateral {lateral} came back {back_lateral}" + ); + } + + #[test] + fn the_offset_is_the_distance_to_the_road( + road in any_road(), + (x, y) in anywhere(), + ) { + let (s, offset) = road.frenet(x, y); + let nearest = distances_nearest_first(&road, x, y)[0]; + match stretch(&road, s) { + Stretch::OnTheRoad => { + prop_assert!( + (offset.abs() - nearest).abs() < TOLERANCE, + "({x}, {y}) read {offset} off a road {nearest} away" + ); + } + end => { + // Past an end the road carries on as the line its last + // segment holds, and the offset measures across that line. + // The line passes through the road, so it can only be + // nearer than the road is. + let points = road.points(); + let (from, to) = match end { + Stretch::BeforeTheStart => (points[0], points[1]), + _ => (points[points.len() - 2], points[points.len() - 1]), + }; + let to_line = distance_to_line(from, to, x, y); + prop_assert!( + (offset.abs() - to_line).abs() < TOLERANCE, + "({x}, {y}) read {offset} off a line {to_line} away" + ); + prop_assert!(offset.abs() <= nearest + TOLERANCE); + } + } + } + + #[test] + fn the_offset_changes_no_faster_than_the_point_moves( + road in any_road(), + (x, y) in anywhere(), + heading in 0.0..TAU, + step in 0.001..5.0f64, + ) { + // A straight walk of a few dozen steps from anywhere, so it + // crosses the road, rounds corners and runs off the ends of it. + // The offset may change by at most as far as the point moved, + // except where the point steps from one stretch of an open road + // to another. Within the road, or within the line it holds past + // an end, the offset is a distance and a distance cannot step. + const STEPS: usize = 64; + let (dx, dy) = (step * libm::cos(heading), step * libm::sin(heading)); + let mut previous = road.frenet(x, y); + for k in 1..=STEPS { + let (px, py) = (x + dx * k as f64, y + dy * k as f64); + let current = road.frenet(px, py); + if stretch(&road, previous.0) == stretch(&road, current.0) { + let change = (current.1 - previous.1).abs(); + prop_assert!( + change <= step + TOLERANCE, + "offset jumped {change} at ({px}, {py}) where the point moved {step}" + ); + } + previous = current; + } + } + + #[test] + fn a_loops_arc_length_stays_within_one_lap( + road in closed_road(), + (x, y) in anywhere(), + ) { + // The total itself is on the path, as `point_at` treats it: a + // point outside the seam's corner is measured to that corner from + // whichever of its two segments rounds nearer, and the last one + // answers with the total where the first answers with zero. + let (s, _) = road.frenet(x, y); + let total = road.total_length(); + prop_assert!((0.0..=total).contains(&s), "({x}, {y}) read {s} on a lap of {total}"); + } + + #[test] + fn a_roads_arc_length_runs_on_past_both_ends( + road in open_road(), + share_beyond in 0.0..0.9f64, + share_lateral in -0.9..0.9f64, + past_the_end: bool, + ) { + // A point set down past one end of the road, in what would be its + // lane had the road gone on: so far along the line the end + // segment holds, and so far to the left of it. The arc length has + // to carry on counting along that line and the offset has to + // hold, or every car past the end would pile onto one arc length. + let points = road.points(); + let (from, end) = if past_the_end { + (points[points.len() - 2], points[points.len() - 1]) + } else { + (points[1], points[0]) + }; + + // Only where that end is the nearest road, with room to spare. A + // road bending back toward its own extension puts another stretch + // nearer, and a point there is in that stretch's lane instead, + // which is the right answer and not this one. The room is half + // the distance from the end to the nearest other stretch, since + // going that far from the end brings the point at most that much + // closer to anything, and the two shares together stay inside it. + // An open road's end is at least a meter from every other stretch + // by construction, so there is always some, and a road with + // nothing else near has more than any lane needs. + let mut distances = distance_to_each_segment(&road, end.0, end.1); + distances.remove(if past_the_end { distances.len() - 1 } else { 0 }); + let nearest_other = distances.into_iter().fold(f64::INFINITY, f64::min); + let room = (nearest_other / 2.0).min(100.0); + let beyond = share_beyond * room; + let lateral = share_lateral * (room - beyond); + + // Out along the end segment's own direction, then to its left. + let length = ((end.0 - from.0).powi(2) + (end.1 - from.1).powi(2)).sqrt(); + let (ux, uy) = ((end.0 - from.0) / length, (end.1 - from.1) / length); + let (x, y) = if past_the_end { + (end.0 + beyond * ux - lateral * uy, end.1 + beyond * uy + lateral * ux) + } else { + // The road runs the other way here, so its left is this + // direction's right. + (end.0 + beyond * ux + lateral * uy, end.1 + beyond * uy - lateral * ux) + }; + let expected_s = if past_the_end { + road.total_length() + beyond + } else { + -beyond + }; + + let (s, offset) = road.frenet(x, y); + prop_assert!((s - expected_s).abs() < TOLERANCE, "({x}, {y}) read {s}, not {expected_s}"); + prop_assert!((offset - lateral).abs() < TOLERANCE, "({x}, {y}) read {offset}, not {lateral}"); + } +} From f5e966ed3619c1284cfa88a8cb3328ccede43b21 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 18:58:25 -1000 Subject: [PATCH 02/11] Expose how many segments a path has The property tests' oracle counted a road's segments from its points and whether it closes, which the path already knew and counted in three places of its own: the builder, the refusal check and a private method reading it off the arc-length table. One associated function now answers all of them, the method is public with a unit test pinning both counts, and the oracle calls it. Inert by intent: the demo world hash is unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/continuo-actors/src/path.rs | 48 +++++++++++-------- .../tests/frenet_properties.rs | 7 +-- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/continuo-actors/src/path.rs b/crates/continuo-actors/src/path.rs index baf9cf0..a0b308f 100644 --- a/crates/continuo-actors/src/path.rs +++ b/crates/continuo-actors/src/path.rs @@ -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 { - 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()]; @@ -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); @@ -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. @@ -482,6 +480,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(); diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index 4970218..6d6f316 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -28,15 +28,10 @@ const TOLERANCE: f64 = 1e-9; /// `frenet` beyond the geometry and stands as the oracle for its offset. fn distance_to_each_segment(road: &Waypoints, x: f64, y: f64) -> Vec { let points = road.points(); - let segments = if road.is_closed() { - points.len() - } else { - points.len() - 1 - }; // Return one distance per segment, each to the nearest point of that // segment rather than of its line. - (0..segments) + (0..road.num_segments()) .map(|i| { let (ax, ay) = points[i]; let (bx, by) = points[(i + 1) % points.len()]; From fb5887609b5bc96d0bd7a2a2d2b7688b29883937 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 19:28:06 -1000 Subject: [PATCH 03/11] Let a generated road turn back on itself An open road was a walk that always advanced along one axis, which cannot cross itself but cannot double back either. It is now a random walk turning by up to 150 degrees at each vertex, held to one rule: any two segments that do not meet stay three lane widths apart, since closer than that they would share lane space and a lane point beside one would be beside the other. Closed roads keep the same clearance. About a third of open roads are redrawn for it, so proptest's redraw allowance is raised to suit a stress run. Roads that double back showed the continuity property was overstated. Half a kilometer from a hairpin, a point switches from the right of the outbound leg to the outside of a right turn, and the offset flips sign with no zero crossing; no signed offset can be continuous far from a road facing both ways. The property now holds the sign only beside the road, within half the clearance, where any two segments a point is nearest to are close enough that they must meet at a vertex, and holds the magnitude everywhere. The one pair it leaves unchecked is a walk changing stretch and nearest segment at once, where the line a road holds past its end gives way to a nearer segment, which a short return leg ending within lane distance of the outbound leg can bring beside the road. The same sentences carry "rotated" for "turned", a wider point range with its reason, and "segment" wherever a stretch of road was meant, so that the word names only the parts of an open road's arc length. Co-Authored-By: Claude Fable 5.1 --- .../tests/frenet_properties.rs | 295 +++++++++++++----- 1 file changed, 223 insertions(+), 72 deletions(-) diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index 6d6f316..817ca7e 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -2,12 +2,13 @@ //! than on the handful of fixtures the unit tests in `path.rs` draw by //! hand. //! -//! Every road here is generated so that it cannot cross itself. A crossing -//! puts one point on two stretches of road at once, and which of them is -//! nearer is then a matter of rounding. An open road is a walk that never -//! turns back along one axis, and a closed one is a star-shaped polygon -//! around a center, and both are then turned and moved anywhere in the -//! plane so that no property passes by leaning on an axis. +//! Every road here keeps its segments a road's width clear of one +//! another except where they meet, since two segments closer than that +//! share lane space, and a lane point beside one is then beside the +//! other too. An open road is a random walk, turning as sharply as a +//! road can, and a closed one is a star-shaped polygon around a center. +//! Both lie anywhere in the plane facing any way, so no property passes +//! by leaning on an axis. use std::f64::consts::TAU; @@ -22,6 +23,77 @@ use proptest::test_runner::FileFailurePersistence; /// near a nanometer. const TOLERANCE: f64 = 1e-9; +/// The width of a lane: three and a half meters, a standard lane and +/// what the unit tests place one at. +const LANE_WIDTH: f64 = 3.5; + +/// How close two segments of one road that do not meet may come: three +/// lane widths, which is what a road carrying a lane each side of its +/// centerline spans. Closer than that they would share lane space, and +/// a lane point beside one would be beside the other too, so nothing +/// said here about the nearest segment could hold. +const CLEARANCE: f64 = 3.0 * LANE_WIDTH; + +/// How far from the road a point still counts as beside it: half the +/// clearance, the far edge of the lane each side. Any two segments both +/// within that of one point are within the clearance of each other, so +/// they meet at a vertex, and two segments that meet agree on which side +/// of the road the point is. +const BESIDE_THE_ROAD: f64 = CLEARANCE / 2.0; + +/// The sharpest turn a road makes at a vertex: 150 degrees, so the two +/// segments meeting there diverge by at least 30 and neither runs back +/// along the other. +const SHARPEST_TURN: f64 = TAU * 150.0 / 360.0; + +/// The distance from `(x, y)` to the nearest point of the segment from +/// `a` to `b`, which is one of its ends when the point lies past it. +fn distance_to_segment(a: (f64, f64), b: (f64, f64), x: f64, y: f64) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let along = (((x - a.0) * dx + (y - a.1) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0); + let (px, py) = (a.0 + dx * along, a.1 + dy * along); + + // Return the distance to that nearest point. + ((x - px).powi(2) + (y - py).powi(2)).sqrt() +} + +/// Whether the segment from `a` to `b` crosses the one from `c` to `d`. +fn segments_cross(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool { + let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| { + (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0) + }; + + // Return whether each segment's ends lie on opposite sides of the + // other's line, which is what crossing means. + side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 +} + +/// Whether every pair of segments that do not share a vertex stays the +/// clearance apart. Two segments that do not cross are nearest at one +/// end of one of them, so four point distances cover it. +fn keeps_clearance(points: &[(f64, f64)], is_closed: bool) -> bool { + let segments = if is_closed { + points.len() + } else { + points.len() - 1 + }; + let ends = |i: usize| (points[i], points[(i + 1) % points.len()]); + let shares_a_vertex = + |i: usize, j: usize| j == i + 1 || (is_closed && i == 0 && j == segments - 1); + + // Return whether no pair comes too close. + (0..segments).all(|i| { + (i + 1..segments).all(|j| { + let ((a, b), (c, d)) = (ends(i), ends(j)); + shares_a_vertex(i, j) + || (!segments_cross(a, b, c, d) + && [(a, c, d), (b, c, d), (c, a, b), (d, a, b)] + .into_iter() + .all(|(p, from, to)| distance_to_segment(from, to, p.0, p.1) >= CLEARANCE)) + }) + }) +} + /// The distance from `(x, y)` to each segment of `road`, in road order. /// /// Worked out from the points on their own, so it shares nothing with @@ -29,20 +101,28 @@ const TOLERANCE: f64 = 1e-9; fn distance_to_each_segment(road: &Waypoints, x: f64, y: f64) -> Vec { let points = road.points(); - // Return one distance per segment, each to the nearest point of that - // segment rather than of its line. + // Return one distance per segment. (0..road.num_segments()) - .map(|i| { - let (ax, ay) = points[i]; - let (bx, by) = points[(i + 1) % points.len()]; - let (dx, dy) = (bx - ax, by - ay); - let along = (((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0); - let (px, py) = (ax + dx * along, ay + dy * along); - ((x - px).powi(2) + (y - py).powi(2)).sqrt() - }) + .map(|i| distance_to_segment(points[i], points[(i + 1) % points.len()], x, y)) .collect() } +/// The nearest segment of `road` to `(x, y)`, as its index and its +/// distance, the earliest of any that tie. +fn nearest_segment(road: &Waypoints, x: f64, y: f64) -> (usize, f64) { + // Return the first segment no other beats. + distance_to_each_segment(road, x, y) + .into_iter() + .enumerate() + .fold((0, f64::INFINITY), |best, (i, distance)| { + if distance < best.1 { + (i, distance) + } else { + best + } + }) +} + /// The same distances, nearest first. fn distances_nearest_first(road: &Waypoints, x: f64, y: f64) -> Vec { let mut distances = distance_to_each_segment(road, x, y); @@ -66,7 +146,7 @@ fn distance_to_line(from: (f64, f64), to: (f64, f64), x: f64, y: f64) -> f64 { /// Which stretch of an open road's arc length `s` falls on. The offset is /// continuous within each stretch and can step between two of them, /// where the line a road holds past its end gives way to a nearer bend. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Stretch { BeforeTheStart, OnTheRoad, @@ -84,8 +164,8 @@ fn stretch(road: &Waypoints, s: f64) -> Stretch { } } -/// A turn and a shift, taking a road drawn against the axes anywhere in -/// the plane facing any way. +/// A rotation and a shift, taking a road drawn against the axes anywhere +/// in the plane facing any way. #[derive(Debug, Clone, Copy)] struct Placement { angle: f64, @@ -96,7 +176,7 @@ impl Placement { fn apply(&self, (x, y): (f64, f64)) -> (f64, f64) { let (sin, cos) = (libm::sin(self.angle), libm::cos(self.angle)); - // Return the point turned about the origin and then shifted. + // Return the point rotated about the origin and then shifted. ( cos * x - sin * y + self.shift.0, sin * x + cos * y + self.shift.1, @@ -111,43 +191,56 @@ fn placement() -> impl Strategy { }) } -/// A point anywhere a road can reach and well beyond. +/// A point anywhere in a square three kilometers on a side around the +/// origin. Every road here starts within half a kilometer of the origin +/// and runs for under a kilometer, so the square holds all of them with +/// room to spare, and a point drawn from it lands on the road, beside +/// it, or far from it in whatever proportion the road's size gives. fn anywhere() -> impl Strategy { - (-1000.0..1000.0, -1000.0..1000.0) + (-1500.0..1500.0, -1500.0..1500.0) } -/// An open road of 2 to 8 points that never turns back on itself. Each -/// point lies at least a meter further along one axis than the last and -/// anywhere within a hundred meters of it on the other, so a bend can be -/// as sharp as it likes short of doubling back, and no segment can be -/// short enough for the road to refuse. +/// An open road of 2 to 8 points: a random walk from anywhere, facing +/// any way, in strides of a meter to a hundred, turning at each vertex +/// by up to the sharpest turn either way. It can turn back on itself so +/// long as its segments keep the clearance, which the filter holds it +/// to. Its strides keep every segment far longer than the road's +/// minimum, and the refusal check guards the builder all the same: a +/// road it would refuse is a case to discard rather than a panic to +/// report. /// /// Every range here excludes what a float can otherwise be, since a -/// road with a NaN in it is not a road, and the refusal check guards the -/// builder all the same: a road it would refuse is a case to discard -/// rather than a panic to report. +/// road with a NaN in it is not a road. fn open_road() -> impl Strategy { (2..=8usize) .prop_flat_map(|n| { ( + (-500.0..500.0, -500.0..500.0), + 0.0..TAU, + proptest::collection::vec(-SHARPEST_TURN..SHARPEST_TURN, n - 2), proptest::collection::vec(1.0..100.0f64, n - 1), - proptest::collection::vec(-100.0..100.0f64, n), - placement(), ) }) - .prop_map(|(steps, ys, placement)| { - let mut x = 0.0; - let mut points = Vec::with_capacity(ys.len()); - for (i, y) in ys.into_iter().enumerate() { + .prop_map(|(start, first_heading, turns, strides)| { + let mut heading = first_heading; + let mut points = vec![start]; + for (i, stride) in strides.into_iter().enumerate() { if i > 0 { - x += steps[i - 1]; + heading += turns[i - 1]; } - points.push(placement.apply((x, y))); + let (x, y) = points[i]; + points.push(( + x + stride * libm::cos(heading), + y + stride * libm::sin(heading), + )); } - // Return the walk, placed. + // Return the walk. points }) + .prop_filter("segments closer than the clearance", |points| { + keeps_clearance(points, false) + }) .prop_filter("a segment the road would refuse", |points| { Waypoints::check_for_too_short_segments(points, false).is_none() }) @@ -156,8 +249,9 @@ fn open_road() -> impl Strategy { /// A closed road of 3 to 12 points around a center, each at its own /// angle and radius and walked in angular order, so it cannot cross -/// itself. Half of them are walked the other way round, so a corner -/// turns right as often as it turns left. +/// itself, then rotated and shifted anywhere. Half of them are walked +/// the other way round, so a corner turns right as often as it turns +/// left. /// /// The angles come from proportional gaps. The widest gap is under what /// the others add up to, so no gap reaches half a turn: a segment @@ -165,8 +259,8 @@ fn open_road() -> impl Strategy { /// segments on the far side, and the polygon would no longer be simple. /// No two points are closer than about three degrees apart at five /// meters out, a quarter of a meter, which keeps every segment far longer -/// than the road's minimum. The filter guards the builder all the same, -/// as `open_road` says. +/// than the road's minimum. The filters hold it to the clearance and +/// guard the builder all the same, as `open_road` says. fn closed_road() -> impl Strategy { (3..=12usize) .prop_flat_map(|n| { @@ -196,6 +290,9 @@ fn closed_road() -> impl Strategy { // Return the polygon, placed and walked either way round. points }) + .prop_filter("segments closer than the clearance", |points| { + keeps_clearance(points, true) + }) .prop_filter("a segment the road would refuse", |points| { Waypoints::check_for_too_short_segments(points, true).is_none() }) @@ -211,11 +308,16 @@ fn any_road() -> impl Strategy { proptest! { // Each case is a few segments' worth of arithmetic, so a thousand of // them cost less than one compile, and every run draws a fresh - // thousand. A failure's seed lands in `proptest-regressions` beside - // this file, to be committed so the case is rerun from then on; the - // default location is relative to a crate root, which an integration - // test does not have. + // thousand. About a third of the open roads drawn fail the clearance + // and are redrawn, roughly one redraw for every two cases, which the + // default allowance of 65,536 redraws covers at that count but not + // on a stress run of a few hundred thousand cases. A million redraws + // suits a run of two million. A failure's seed lands in + // `proptest-regressions` beside this file, to be committed so the + // case is rerun from then on; the default location is relative to a + // crate root, which an integration test does not have. #![proptest_config(ProptestConfig { + max_local_rejects: 1_000_000, failure_persistence: Some(Box::new(FileFailurePersistence::WithSource( "proptest-regressions", ))), @@ -249,12 +351,12 @@ proptest! { let on_road = road.point_at(s); // How far a lane can sit off this point of the road before some - // other stretch of it is nearer than the stretch it is on: half - // the distance to the nearest other stretch, since moving a point + // other segment of it is nearer than the segment it is on: half + // the distance to the nearest other segment, since moving a point // by a distance brings it at most that much closer to anything. - // The nearest stretch of all is the one it is on, at no distance, + // The nearest segment of all is the one it is on, at no distance, // so the runner-up is the one that bounds the reach. A point on a - // vertex is on two stretches at once and has no reach at all, and + // vertex is on two segments at once and has no reach at all, and // one within a micron of a vertex has none worth a lane. A road // with nothing else near has more reach than any lane needs. let distances = distances_nearest_first(&road, on_road.x, on_road.y); @@ -307,30 +409,79 @@ proptest! { } #[test] - fn the_offset_changes_no_faster_than_the_point_moves( + fn the_offset_changes_no_faster_than_a_point_moving_beside_the_road( road in any_road(), - (x, y) in anywhere(), + fraction in 0.0..1.0f64, + lateral in -BESIDE_THE_ROAD..BESIDE_THE_ROAD, heading in 0.0..TAU, - step in 0.001..5.0f64, + step in 0.001..0.5f64, ) { - // A straight walk of a few dozen steps from anywhere, so it - // crosses the road, rounds corners and runs off the ends of it. - // The offset may change by at most as far as the point moved, - // except where the point steps from one stretch of an open road - // to another. Within the road, or within the line it holds past - // an end, the offset is a distance and a distance cannot step. + // A straight walk of a few dozen short steps from a point beside + // the road, so it crosses the road, rounds corners, runs off the + // ends, and can wander out of the band beside the road as it + // goes. The offset may change by at most as far as the point + // moved. + // + // Its magnitude is a distance, to the road or to the line the + // road holds past an end, and a distance stays continuous as the + // point moves. Its sign says which side of the nearest segment + // the point is on, and two segments that meet agree on that all + // round their vertex, where two that do not can face opposite + // ways. Beside the road only segments that meet can both be + // nearest, so the signed offset stays continuous there; far from + // a road that doubles back it cannot, and only the magnitude is + // checked. + // + // Past an end of an open road the offset measures to the line + // the end segment holds. Where the walk crosses the perpendicular + // at that end, the line and the segment meet, so both checks hold + // across it. Where a nearer segment takes over from the line + // instead, the offset steps by the difference between the two, + // so a pair that changes stretch and nearest segment at once is + // the one pair left unchecked. const STEPS: usize = 64; + struct Sample { + at: (f64, f64), + stretch: Stretch, + nearest_segment: usize, + offset: f64, + beside: bool, + } + let start = road.point_at_offset(fraction * road.total_length(), lateral); let (dx, dy) = (step * libm::cos(heading), step * libm::sin(heading)); - let mut previous = road.frenet(x, y); + let sample = |k: usize| { + let at = (start.x + dx * k as f64, start.y + dy * k as f64); + let (s, offset) = road.frenet(at.0, at.1); + let (nearest_segment, distance) = nearest_segment(&road, at.0, at.1); + + // Return the sample with everything the checks below ask of it. + Sample { + at, + stretch: stretch(&road, s), + nearest_segment, + offset, + beside: distance < BESIDE_THE_ROAD, + } + }; + let mut previous = sample(0); for k in 1..=STEPS { - let (px, py) = (x + dx * k as f64, y + dy * k as f64); - let current = road.frenet(px, py); - if stretch(&road, previous.0) == stretch(&road, current.0) { - let change = (current.1 - previous.1).abs(); + let current = sample(k); + let at = current.at; + let same_stretch = current.stretch == previous.stretch; + let same_segment = current.nearest_segment == previous.nearest_segment; + if same_stretch || same_segment { + let change = (current.offset.abs() - previous.offset.abs()).abs(); prop_assert!( change <= step + TOLERANCE, - "offset jumped {change} at ({px}, {py}) where the point moved {step}" + "offset magnitude jumped {change} at {at:?} where the point moved {step}" ); + if current.beside && previous.beside { + let change = (current.offset - previous.offset).abs(); + prop_assert!( + change <= step + TOLERANCE, + "offset jumped {change} at {at:?} where the point moved {step}" + ); + } } previous = current; } @@ -370,15 +521,15 @@ proptest! { }; // Only where that end is the nearest road, with room to spare. A - // road bending back toward its own extension puts another stretch - // nearer, and a point there is in that stretch's lane instead, + // road bending back toward its own extension puts another segment + // nearer, and a point there is in that segment's lane instead, // which is the right answer and not this one. The room is half - // the distance from the end to the nearest other stretch, since + // the distance from the end to the nearest other segment, since // going that far from the end brings the point at most that much // closer to anything, and the two shares together stay inside it. - // An open road's end is at least a meter from every other stretch - // by construction, so there is always some, and a road with - // nothing else near has more than any lane needs. + // An end always has some: the segment before it leaves at thirty + // degrees or more, and every other segment keeps the clearance. + // A road with nothing else near has more than any lane needs. let mut distances = distance_to_each_segment(&road, end.0, end.1); distances.remove(if past_the_end { distances.len() - 1 } else { 0 }); let nearest_other = distances.into_iter().fold(f64::INFINITY, f64::min); From 4bc6a955f21583d48d15a891031c57ce0d775286 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 19:42:44 -1000 Subject: [PATCH 04/11] Name the room a lane has beside a segment Two tests each worked out, in their own words and with a bare cap of fifty or a hundred meters, how far a point could sit off a segment before another segment was nearer. One helper now answers both: half the distance to the nearest other segment, and never more than the widest road, ten lanes. The tests that recover a point from its arc length and offset say so in their names rather than claiming an inverse the docs deny, the distance test names its three stretches outright instead of matching the last with a wildcard, and the past-the-ends test calls the offset it places the expected one and says a negative one lies to the right. Co-Authored-By: Claude Fable 5.1 --- .../tests/frenet_properties.rs | 168 ++++++++++-------- 1 file changed, 92 insertions(+), 76 deletions(-) diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index 817ca7e..af1b814 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -123,14 +123,29 @@ fn nearest_segment(road: &Waypoints, x: f64, y: f64) -> (usize, f64) { }) } -/// The same distances, nearest first. -fn distances_nearest_first(road: &Waypoints, x: f64, y: f64) -> Vec { - let mut distances = distance_to_each_segment(road, x, y); - distances.sort_by(f64::total_cmp); - - // Return them sorted, so the nearest segment is first and the runner-up - // second. - distances +/// The widest road a lane is placed on: ten lanes. A segment with +/// nothing else near it has room beyond any lane, and this is where the +/// tests stop reaching out. +const WIDEST_ROAD: f64 = 10.0 * LANE_WIDTH; + +/// How far a lane can sit off the point `(x, y)` of segment +/// `own_segment` before some other segment is nearer than that one: half +/// the distance to the nearest other segment, since moving a point by a +/// distance brings it at most that much closer to anything, and never +/// more than the widest road. A point on a vertex is on two segments at +/// once and has no room at all. +fn room_for_a_lane(road: &Waypoints, x: f64, y: f64, own_segment: usize) -> f64 { + // The smallest of the other segments' distances, or infinity when + // there is no other segment, which the cap below then answers. + let nearest_other = distance_to_each_segment(road, x, y) + .into_iter() + .enumerate() + .filter(|&(i, _)| i != own_segment) + .map(|(_, distance)| distance) + .fold(f64::INFINITY, f64::min); + + // Return half of it, and no more than a lane is ever placed at. + (nearest_other / 2.0).min(WIDEST_ROAD) } /// The distance from `(x, y)` to the line through `from` and `to`, rather @@ -325,7 +340,7 @@ proptest! { })] #[test] - fn a_point_on_the_road_reads_its_own_arc_length( + fn frenet_recovers_the_arc_length_of_a_point_on_the_road( road in any_road(), fraction in 0.0..1.0f64, ) { @@ -341,36 +356,30 @@ proptest! { } #[test] - fn a_lane_point_round_trips_within_the_bends_reach( + fn frenet_recovers_a_lane_point_while_the_lane_has_room( road in any_road(), fraction in 0.0..1.0f64, - share_of_reach in -0.95..0.95f64, + share_of_room in -0.95..0.95f64, ) { let s = fraction * road.total_length(); prop_assume!(s < road.total_length()); let on_road = road.point_at(s); - // How far a lane can sit off this point of the road before some - // other segment of it is nearer than the segment it is on: half - // the distance to the nearest other segment, since moving a point - // by a distance brings it at most that much closer to anything. - // The nearest segment of all is the one it is on, at no distance, - // so the runner-up is the one that bounds the reach. A point on a - // vertex is on two segments at once and has no reach at all, and - // one within a micron of a vertex has none worth a lane. A road - // with nothing else near has more reach than any lane needs. - let distances = distances_nearest_first(&road, on_road.x, on_road.y); - let reach = distances.get(1).copied().unwrap_or(f64::INFINITY) / 2.0; - let reach = reach.min(50.0); - prop_assume!(reach > 1e-6); - - let lateral = share_of_reach * reach; + // The segment the point is on is the nearest to it, at no + // distance, and the room beside it is what bounds the lane. A + // point within a micron of a vertex has room too small for any + // lane and too small for rounding to respect, so it is skipped. + let (own_segment, _) = nearest_segment(&road, on_road.x, on_road.y); + let room = room_for_a_lane(&road, on_road.x, on_road.y, own_segment); + prop_assume!(room > 1e-6); + + let lateral = share_of_room * room; let point = road.point_at_offset(s, lateral); - let (back_s, back_lateral) = road.frenet(point.x, point.y); + let (back_s, back_offset) = road.frenet(point.x, point.y); prop_assert!((back_s - s).abs() < TOLERANCE, "s {s} came back {back_s}"); prop_assert!( - (back_lateral - lateral).abs() < TOLERANCE, - "lateral {lateral} came back {back_lateral}" + (back_offset - lateral).abs() < TOLERANCE, + "lateral {lateral} came back {back_offset}" ); } @@ -380,31 +389,32 @@ proptest! { (x, y) in anywhere(), ) { let (s, offset) = road.frenet(x, y); - let nearest = distances_nearest_first(&road, x, y)[0]; - match stretch(&road, s) { - Stretch::OnTheRoad => { - prop_assert!( - (offset.abs() - nearest).abs() < TOLERANCE, - "({x}, {y}) read {offset} off a road {nearest} away" - ); - } - end => { - // Past an end the road carries on as the line its last - // segment holds, and the offset measures across that line. - // The line passes through the road, so it can only be - // nearer than the road is. - let points = road.points(); - let (from, to) = match end { - Stretch::BeforeTheStart => (points[0], points[1]), - _ => (points[points.len() - 2], points[points.len() - 1]), - }; - let to_line = distance_to_line(from, to, x, y); - prop_assert!( - (offset.abs() - to_line).abs() < TOLERANCE, - "({x}, {y}) read {offset} off a line {to_line} away" - ); - prop_assert!(offset.abs() <= nearest + TOLERANCE); - } + let (_, dist_to_road) = nearest_segment(&road, x, y); + + // Past an end the road carries on as the line its end segment + // holds, and the offset measures across that line rather than to + // the road. + let points = road.points(); + let end_line = match stretch(&road, s) { + Stretch::OnTheRoad => None, + Stretch::BeforeTheStart => Some((points[0], points[1])), + Stretch::PastTheEnd => Some((points[points.len() - 2], points[points.len() - 1])), + }; + if let Some((from, to)) = end_line { + // Before the start or past the end. The line passes through + // the road, so it can only be nearer than the road is. + let dist_to_line = distance_to_line(from, to, x, y); + prop_assert!( + (offset.abs() - dist_to_line).abs() < TOLERANCE, + "({x}, {y}) read {offset} off a line {dist_to_line} away" + ); + prop_assert!(offset.abs() <= dist_to_road + TOLERANCE); + } else { + // On the road, which a closed road is everywhere. + prop_assert!( + (offset.abs() - dist_to_road).abs() < TOLERANCE, + "({x}, {y}) read {offset} off a road {dist_to_road} away" + ); } } @@ -510,9 +520,10 @@ proptest! { ) { // A point set down past one end of the road, in what would be its // lane had the road gone on: so far along the line the end - // segment holds, and so far to the left of it. The arc length has - // to carry on counting along that line and the offset has to - // hold, or every car past the end would pile onto one arc length. + // segment holds, and so far to either side of it, a negative + // offset lying to the right. The arc length has to carry on + // counting along that line and the offset has to hold, or every + // car past the end would pile onto one arc length. let points = road.points(); let (from, end) = if past_the_end { (points[points.len() - 2], points[points.len() - 1]) @@ -520,32 +531,31 @@ proptest! { (points[1], points[0]) }; - // Only where that end is the nearest road, with room to spare. A - // road bending back toward its own extension puts another segment - // nearer, and a point there is in that segment's lane instead, - // which is the right answer and not this one. The room is half - // the distance from the end to the nearest other segment, since - // going that far from the end brings the point at most that much - // closer to anything, and the two shares together stay inside it. - // An end always has some: the segment before it leaves at thirty - // degrees or more, and every other segment keeps the clearance. - // A road with nothing else near has more than any lane needs. - let mut distances = distance_to_each_segment(&road, end.0, end.1); - distances.remove(if past_the_end { distances.len() - 1 } else { 0 }); - let nearest_other = distances.into_iter().fold(f64::INFINITY, f64::min); - let room = (nearest_other / 2.0).min(100.0); + // The point stays within the room beside the end, so the end + // segment is still the nearest road there. Further out, a road + // bending back toward its own extension could put another segment + // nearer, and a point there belongs to that segment's lane. The + // distance out and the distance aside add up to at most the room. + let own_segment = if past_the_end { road.num_segments() - 1 } else { 0 }; + let room = room_for_a_lane(&road, end.0, end.1, own_segment); let beyond = share_beyond * room; - let lateral = share_lateral * (room - beyond); + let expected_offset = share_lateral * (room - beyond); // Out along the end segment's own direction, then to its left. let length = ((end.0 - from.0).powi(2) + (end.1 - from.1).powi(2)).sqrt(); let (ux, uy) = ((end.0 - from.0) / length, (end.1 - from.1) / length); let (x, y) = if past_the_end { - (end.0 + beyond * ux - lateral * uy, end.1 + beyond * uy + lateral * ux) + ( + end.0 + beyond * ux - expected_offset * uy, + end.1 + beyond * uy + expected_offset * ux, + ) } else { // The road runs the other way here, so its left is this // direction's right. - (end.0 + beyond * ux + lateral * uy, end.1 + beyond * uy - lateral * ux) + ( + end.0 + beyond * ux + expected_offset * uy, + end.1 + beyond * uy - expected_offset * ux, + ) }; let expected_s = if past_the_end { road.total_length() + beyond @@ -554,7 +564,13 @@ proptest! { }; let (s, offset) = road.frenet(x, y); - prop_assert!((s - expected_s).abs() < TOLERANCE, "({x}, {y}) read {s}, not {expected_s}"); - prop_assert!((offset - lateral).abs() < TOLERANCE, "({x}, {y}) read {offset}, not {lateral}"); + prop_assert!( + (s - expected_s).abs() < TOLERANCE, + "({x}, {y}) read {s}, not {expected_s}" + ); + prop_assert!( + (offset - expected_offset).abs() < TOLERANCE, + "({x}, {y}) read {offset}, not {expected_offset}" + ); } } From ffb4658119e442f1a6fcdf8d22573e607cd61a62 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 19:46:00 -1000 Subject: [PATCH 05/11] Say where the offset steps past the end of a road The docs on frenet said the offset goes on measuring across the line the road held past its end, and left unsaid what happens where the road bends back toward that line: any segment nearer than the line still wins, so the offset steps from the line's distance to the road's as a car crosses between them. Review settled that this stays as it is, so the docs now say it. The property tests' notion of a stretch is trimmed to what it classifies, an arc length, and the function naming one binds its result before returning it. Co-Authored-By: Claude Fable 5.1 --- crates/continuo-actors/src/path.rs | 6 +++++- crates/continuo-actors/tests/frenet_properties.rs | 14 ++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/continuo-actors/src/path.rs b/crates/continuo-actors/src/path.rs index a0b308f..1e7765a 100644 --- a/crates/continuo-actors/src/path.rs +++ b/crates/continuo-actors/src/path.rs @@ -277,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` diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index af1b814..005c0ad 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -158,9 +158,9 @@ fn distance_to_line(from: (f64, f64), to: (f64, f64), x: f64, y: f64) -> f64 { ((dx * (y - from.1) - dy * (x - from.0)) / (dx * dx + dy * dy).sqrt()).abs() } -/// Which stretch of an open road's arc length `s` falls on. The offset is -/// continuous within each stretch and can step between two of them, -/// where the line a road holds past its end gives way to a nearer bend. +/// Which stretch an open road's arc length falls on: the road itself, +/// or the line its end segment holds before the start or past the end. +/// A closed road has no ends and is all one stretch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Stretch { BeforeTheStart, @@ -169,14 +169,16 @@ enum Stretch { } fn stretch(road: &Waypoints, s: f64) -> Stretch { - // Return the stretch. A loop has no ends, so all of it is one. - if road.is_closed() || (0.0..=road.total_length()).contains(&s) { + let stretch = if road.is_closed() || (0.0..=road.total_length()).contains(&s) { Stretch::OnTheRoad } else if s < 0.0 { Stretch::BeforeTheStart } else { Stretch::PastTheEnd - } + }; + + // Return which stretch the arc length falls on. + stretch } /// A rotation and a shift, taking a road drawn against the axes anywhere From 2709809f30b7e673cd6b48e8e489f888ee8c6945 Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 20:02:32 -1000 Subject: [PATCH 06/11] Say why a closed road can read its own total The property that a closed road's arc length stays within one lap allows the total itself, and its comment explained why in a sentence nobody could read. Outside the corner where the road closes, the last segment and the first are the same distance away in exact arithmetic, rounding decides which answers, and the last answers with the total where the first answers with zero. The test and its neighbor now say closed road rather than loop, as the rest of the file does. Co-Authored-By: Claude Fable 5.1 --- .../continuo-actors/tests/frenet_properties.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index 005c0ad..cec6e4e 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -346,8 +346,8 @@ proptest! { road in any_road(), fraction in 0.0..1.0f64, ) { - // The total itself is where a loop wraps back to zero, so the - // fraction stops short of it. + // The total itself is where a closed road wraps back to zero, so + // the fraction stops short of it. let s = fraction * road.total_length(); prop_assume!(s < road.total_length()); @@ -500,14 +500,18 @@ proptest! { } #[test] - fn a_loops_arc_length_stays_within_one_lap( + fn a_closed_roads_arc_length_stays_within_one_lap( road in closed_road(), (x, y) in anywhere(), ) { - // The total itself is on the path, as `point_at` treats it: a - // point outside the seam's corner is measured to that corner from - // whichever of its two segments rounds nearer, and the last one - // answers with the total where the first answers with zero. + // The arc length can come back as the total itself rather than + // as zero. Outside the corner where the road closes, a point is + // measured to that corner, which ends the last segment and + // starts the first. Both are the same distance away in exact + // arithmetic, so rounding decides which answers, and the last + // segment answers with the total where the first answers with + // zero. `point_at` treats the total as on the road, so the range + // is closed at both ends. let (s, _) = road.frenet(x, y); let total = road.total_length(); prop_assert!((0.0..=total).contains(&s), "({x}, {y}) read {s} on a lap of {total}"); From 735aae1f7a3bead85ffec1270b46e505ffdd433f Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 20:05:10 -1000 Subject: [PATCH 07/11] Name the file a failing seed is written to The config comment said a failure's seed lands in a directory beside the test, where proptest's WithSource setting writes a sibling file with the test's name and a new extension. Saying the real name is what lets the file be found, committed, or cleaned up on purpose. Co-Authored-By: Claude Fable 5.1 --- crates/continuo-actors/tests/frenet_properties.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index cec6e4e..eed6d64 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -329,10 +329,10 @@ proptest! { // and are redrawn, roughly one redraw for every two cases, which the // default allowance of 65,536 redraws covers at that count but not // on a stress run of a few hundred thousand cases. A million redraws - // suits a run of two million. A failure's seed lands in - // `proptest-regressions` beside this file, to be committed so the - // case is rerun from then on; the default location is relative to a - // crate root, which an integration test does not have. + // suits a run of two million. A failure's seed lands beside this + // file as `frenet_properties.proptest-regressions`, to be committed + // so the case is rerun from then on; the default location is + // relative to a crate root, which an integration test does not have. #![proptest_config(ProptestConfig { max_local_rejects: 1_000_000, failure_persistence: Some(Box::new(FileFailurePersistence::WithSource( From e20c41c8993331547700e2e881adf3f09b946a4b Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 20:44:12 -1000 Subject: [PATCH 08/11] Keep regression seeds honest and the lockfile current A persisted proptest seed replays its case only while proptest and the generators that drew it stay as they are, and nothing says when that stops. So the seed file is committed as proptest asks, the fix it led to lands as a unit test with the shrunk case written out, and the file is deleted whenever proptest moves in the lock or a generator changes. The config comment says so. Proptest stays at a caret requirement pinned through Cargo.lock like libm, and CI now fetches the dependency crates with the lock held before its first build. A stale lock used to be updated quietly and built against whatever it resolved, which for libm is a different fingerprint and for proptest a seed that no longer means its case; now it fails the job at the cause. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 10 ++++++++++ Cargo.toml | 4 +++- .../continuo-actors/tests/frenet_properties.rs | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fe6f46..26914f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,16 @@ jobs: "$fmpy_python" -m pip install fmpy echo "FMPY_PYTHON=$fmpy_python" >> "$GITHUB_ENV" + # 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 + # 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. diff --git a/Cargo.toml b/Cargo.toml index 598d86c..a391a36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,9 @@ fmi-export = "0.3" 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. +# 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" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"] } diff --git a/crates/continuo-actors/tests/frenet_properties.rs b/crates/continuo-actors/tests/frenet_properties.rs index eed6d64..9d144f7 100644 --- a/crates/continuo-actors/tests/frenet_properties.rs +++ b/crates/continuo-actors/tests/frenet_properties.rs @@ -329,10 +329,18 @@ proptest! { // and are redrawn, roughly one redraw for every two cases, which the // default allowance of 65,536 redraws covers at that count but not // on a stress run of a few hundred thousand cases. A million redraws - // suits a run of two million. A failure's seed lands beside this - // file as `frenet_properties.proptest-regressions`, to be committed - // so the case is rerun from then on; the default location is - // relative to a crate root, which an integration test does not have. + // suits a run of two million. + // + // A failure's seed lands beside this file as + // `frenet_properties.proptest-regressions`, and every run replays it + // before drawing fresh cases. It is committed, as proptest asks, and + // the fix lands with the shrunk case written out as a unit test in + // `path.rs`, since a seed draws the same case only while proptest + // and the generators here stay as they are and nothing says when + // that stops. The file is deleted whenever proptest moves in the + // lock or a generator changes, and the unit tests keep every case it + // held. The default location is relative to a crate root, which an + // integration test does not have. #![proptest_config(ProptestConfig { max_local_rejects: 1_000_000, failure_persistence: Some(Box::new(FileFailurePersistence::WithSource( From 4d7a07152c62b34226158c8199cdc6fa4efd1c8f Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 21:24:49 -1000 Subject: [PATCH 09/11] Record what the property tests settled about frenet The pull request description was carrying the reasons, which is DECISIONS.md's job. One dated entry now holds them: the offset steps where a nearer segment takes over from the line an open road holds past its end, the signed offset is continuous beside the road rather than everywhere, and a persisted proptest seed is committed with its fix landing as a unit test and the file deleted once proptest or a generator moves. CLAUDE.md's list of barriers names the CI step that fetches the dependency crates with the lock held. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 17 ++++++++++------- DECISIONS.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4e12a3..637ac54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 7c3c533..e84fffe 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. From 40333413898b3bfe7606c20a9d6380a166fee85a Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 21:35:55 -1000 Subject: [PATCH 10/11] Check the lockfile as soon as the cache is restored The locked fetch sat after every install step, so a stale lock was reported only once the packager and the Python environment were set up. It now follows the rust-cache restore directly, the first moment cargo can run, and the crates it fetches land in the restored cache. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26914f4..2f2dd31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -106,16 +116,6 @@ jobs: "$fmpy_python" -m pip install fmpy echo "FMPY_PYTHON=$fmpy_python" >> "$GITHUB_ENV" - # 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 - # 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. From 5c4395c43b2d77823bb885aa56163c38b72b951c Mon Sep 17 00:00:00 2001 From: Effendi Dufford Date: Wed, 2 Sep 2026 21:35:55 -1000 Subject: [PATCH 11/11] Say what proptest is for without naming a test The dev-dependency comment tied proptest to the property tests on the road, which would read as stale the day a second file of property tests appeared. It names the kind of test now and nothing more. Co-Authored-By: Claude Fable 5.1 --- crates/continuo-actors/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/continuo-actors/Cargo.toml b/crates/continuo-actors/Cargo.toml index 43e835c..fb9d73e 100644 --- a/crates/continuo-actors/Cargo.toml +++ b/crates/continuo-actors/Cargo.toml @@ -14,7 +14,7 @@ serde_json = { workspace = true } tracing = { workspace = true } # The conductor and the transport serve the determinism integration test, -# and proptest the property tests on the road. +# and proptest the property tests. [dev-dependencies] continuo-conductor = { workspace = true } continuo-transport = { workspace = true }