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)
}

64
tool/parity/probe.dart Normal file
View File

@@ -0,0 +1,64 @@
// Dart side of the cross-language parity harness (T07).
// Must stay fixture-for-fixture identical to tool/parity/main.kt.
import 'dart:math' as math;
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/geo/geo.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Same LCG as the Kotlin oracle. Dart ints are 64-bit two's complement on the VM and
/// multiplication wraps, matching Kotlin's Long.
class Lcg {
Lcg(this._s);
int _s;
double nextDouble() {
_s = _s * 6364136223846793005 + 1442695040888963407;
final bits = (_s >>> 11) & ((1 << 53) - 1);
return bits / (1 << 53);
}
}
TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) =>
TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts,
latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt);
void main() {
void out(String k, Object v) => print('$k=$v');
out('haversine_calgary_edmonton', haversineMeters(51.0447, -114.0719, 53.5461, -113.4938));
out('haversine_short_hop', haversineMeters(51.0447, -114.0719, 51.04480, -114.0719));
out('perp_beyond_end', perpendicularDistanceMeters(
const LatLon(51.003, -114.0), const LatLon(51.000, -114.0), const LatLon(51.002, -114.0)));
final ride = List.generate(21600,
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001));
final simplified = simplify(ride, 5.0);
out('simplify_count', simplified.length);
out('simplify_last_lat', simplified.last.lat);
out('path_length_ride', pathLengthMeters(ride));
final rng = Lcg(42);
final noisy = List.generate(600,
(i) => p(1, i * 1000, 51.0, -114.0, 0.0, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), i + 1));
out('noisy_gain', computeSummary(noisy).elevationGainM);
out('noisy_loss', computeSummary(noisy).elevationLossM);
final climb = List.generate(101,
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 50.0, 1000.0 + i, i + 1));
out('climb_gain', computeSummary(climb).elevationGainM);
final run = List.generate(100,
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 40.0, 1000.0, i + 1));
final s = computeSummary(run);
out('run_distance', s.distanceM);
out('run_moving_millis', s.movingMillis);
out('run_avg_speed', s.avgMovingSpeedKmh);
final prof = elevationProfile(run, maxSamples: 200);
out('profile_size', prof.length);
out('profile_last_distance', prof.last.distanceM);
final hist = speedHistogram(run, bucketKmh: 10);
out('hist_buckets', hist.length);
out('hist_first_millis', hist.first.millis);
}

51
tool/parity/run.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Cross-language parity harness (T07).
#
# Drives the SAME fixtures through the native Kotlin implementation and the Dart port,
# and diffs the output. This is the port's strongest correctness guarantee: it proved the
# elevation accumulator is bit-identical across both languages.
#
# Requires: kotlinc (brew install kotlin) and a JDK 17-21.
#
# NOTE: never redirect the build to /dev/null. A v2 sweep once returned four identical
# results because a quoting bug corrupted the source while the compile error hid behind
# a redirect. If every value looks suspiciously equal, suspect the harness first.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
NATIVE="${RIPPR_NATIVE:-$HOME/dojo/rippr}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [ ! -d "$NATIVE" ]; then
echo "Native repo not found at $NATIVE. Set RIPPR_NATIVE." >&2
exit 1
fi
# The files actually under test are copied verbatim from the native repo — never
# reimplemented. Only the Room-annotated holders and one Telemetry constant are stubbed,
# so the algorithm files compile without androidx or org.json on the classpath.
cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/"
cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/"
cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/"
echo "== compiling Kotlin oracle =="
( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt stubs.kt telemetry_stub.kt main.kt \
-include-runtime -d parity.jar 2>&1 | grep -v '^warning:' || true )
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"
echo "== running Dart side =="
( cd "$HERE/../.." && dart run tool/parity/probe.dart ) | grep '=' | sort > "$WORK/dart.txt"
echo "== diff =="
if diff -u "$WORK/kotlin.txt" "$WORK/dart.txt"; then
echo "PARITY OK - byte-identical on every key"
else
echo
echo "Differences above. Expected and accepted: run_avg_speed and any other"
echo "speed-derived value, where Kotlin's 32-bit Float widens to double with"
echo "artefacts Dart's uniform binary64 does not reproduce. Anything else is a bug."
exit 1
fi

10
tool/parity/stubs.kt Normal file
View File

@@ -0,0 +1,10 @@
package com.rippr.data
data class TrackPoint(
val id: Long = 0, val tripId: Long, val segmentId: Long, val timestamp: Long,
val latitude: Double, val longitude: Double, val speedKmh: Float,
val altitudeM: Double, val accuracyM: Float = 0f, val bearingDeg: Float = 0f,
val synced: Boolean = false,
)
data class Segment(
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
) { val isOpen: Boolean get() = endedAt == null }

View File

@@ -0,0 +1,2 @@
package com.rippr
object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }