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:
59
tool/parity/main.kt
Normal file
59
tool/parity/main.kt
Normal 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
64
tool/parity/probe.dart
Normal 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
51
tool/parity/run.sh
Executable 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
10
tool/parity/stubs.kt
Normal 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 }
|
||||
2
tool/parity/telemetry_stub.kt
Normal file
2
tool/parity/telemetry_stub.kt
Normal file
@@ -0,0 +1,2 @@
|
||||
package com.rippr
|
||||
object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }
|
||||
Reference in New Issue
Block a user