Files
rippr/tool/parity/probe.dart
Dylan 4af4e3411a 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>
2026-08-14 20:59:35 -05:00

65 lines
2.5 KiB
Dart

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