Port Telemetry, Format, RideStatistics; add cross-language parity harness

T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models
(Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can
map to them in T08 rather than the domain depending on the database.

T04 — ride_statistics.dart including ElevationAccumulator, ported structurally
faithfully: moving average, reversal hysteresis, gainIncludingPending, and the
finish() reconciliation against lastRaw.

T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures
through the real Kotlin files and the Dart port, then diffs. Result: every value
byte-identical, including noisy_gain=38.959594555022136 to the last digit. The
sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double
with artefacts Dart's binary64 does not reproduce. Documented, not papered over.

That harness settled a real question. The ported elevation test failed at 50.9m
against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's
and Dart's Random(42) are different streams. On a shared LCG fixture both produce
39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed
luck rather than on a property of the algorithm. The Dart test now uses the shared
LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour
(25 seeds spanned 24.7-46.7m).

52 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:59:35 -05:00
parent f0f9ed8c34
commit 4af4e3411a
14 changed files with 1368 additions and 1 deletions

59
tool/parity/main.kt Normal file
View File

@@ -0,0 +1,59 @@
import com.rippr.data.TrackPoint
import com.rippr.data.Segment
import com.rippr.geo.Geo
import com.rippr.geo.LatLon
import com.rippr.stats.RideStatistics
// A deterministic LCG implemented identically in Kotlin and Dart, so both languages
// see the SAME noise sequence. Neither language's built-in Random can do this.
class Lcg(private var s: Long) {
fun nextDouble(): Double {
s = s * 6364136223846793005L + 1442695040888963407L
val bits = (s ushr 11) and ((1L shl 53) - 1L)
return bits.toDouble() / (1L shl 53).toDouble()
}
}
fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) =
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
fun main() {
fun out(k: String, v: Any) = println("$k=$v")
out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938))
out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719))
out("perp_beyond_end", Geo.perpendicularDistanceMeters(
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0)))
val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) }
val simplified = Geo.simplify(ride, 5.0)
out("simplify_count", simplified.size)
out("simplify_last_lat", simplified.last().lat)
out("path_length_ride", Geo.pathLengthMeters(ride))
// The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise.
val rng = Lcg(42L)
val noisy = (0 until 600).map {
p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong())
}
out("noisy_gain", RideStatistics.compute(noisy).elevationGainM)
out("noisy_loss", RideStatistics.compute(noisy).elevationLossM)
val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) }
out("climb_gain", RideStatistics.compute(climb).elevationGainM)
val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) }
val s = RideStatistics.compute(run)
out("run_distance", s.distanceM)
out("run_moving_millis", s.movingMillis)
out("run_avg_speed", s.avgMovingSpeedKmh)
val prof = RideStatistics.elevationProfile(run, 200)
out("profile_size", prof.size)
out("profile_last_distance", prof.last().distanceM)
val hist = RideStatistics.speedHistogram(run, 10)
out("hist_buckets", hist.size)
out("hist_first_millis", hist.first().millis)
}