Add the Rippr Flutter port: source, history bundle, and installable APK
The port runs on Android and iOS and is feature-complete; the native Android app is superseded but kept, since it is still the only version that has recorded real rides. rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different from the native com.rippr so both install side by side. Recording the same ride on both at once is the strongest available check that the port is faithful. Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS has no APK equivalent and must be built and signed through Xcode with a free Apple ID, which gives a 7-day profile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
84
rippr-flutter-src/tool/parity/main.kt
Normal file
84
rippr-flutter-src/tool/parity/main.kt
Normal file
@@ -0,0 +1,84 @@
|
||||
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
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.data.TripState
|
||||
import com.rippr.export.RideExport
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used
|
||||
// to compare text across languages -- this can.
|
||||
fun fnv1a(s: String): Long {
|
||||
var h = 2166136261L
|
||||
for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL }
|
||||
return h
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// --- export byte output -------------------------------------------------
|
||||
val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L,
|
||||
state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f)
|
||||
val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1),
|
||||
Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3))
|
||||
val epts = esegs.flatMap { sg -> (0 until 5).map {
|
||||
p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } }
|
||||
val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's <ride>")
|
||||
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
|
||||
out("gpx_len", g.length)
|
||||
out("gpx_fnv", fnv1a(g))
|
||||
out("geojson_len", j.length)
|
||||
out("geojson_fnv", fnv1a(j))
|
||||
out("hist_first_millis", hist.first().millis)
|
||||
}
|
||||
102
rippr-flutter-src/tool/parity/probe.dart
Normal file
102
rippr-flutter-src/tool/parity/probe.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
// ignore_for_file: avoid_print -- this is a command-line harness; stdout is the output.
|
||||
// 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/export/ride_export.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 32-bit. Dart's String.hashCode differs from Java's, so it cannot be used to
|
||||
/// compare text across languages -- this can.
|
||||
int fnv1a(String s) {
|
||||
var h = 2166136261;
|
||||
for (final c in s.codeUnits) {
|
||||
h ^= c;
|
||||
h = (h * 16777619) & 0xFFFFFFFF;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// --- export byte output -------------------------------------------------
|
||||
const etrip = Trip(
|
||||
id: 1,
|
||||
startedAt: 1700000000000,
|
||||
endedAt: 1700000600000,
|
||||
state: TripState.completed,
|
||||
distanceM: 12345.6,
|
||||
maxSpeedKmh: 118.4);
|
||||
const esegs = [
|
||||
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
|
||||
];
|
||||
final epts = <TrackPoint>[
|
||||
for (final sg in esegs)
|
||||
for (var i = 0; i < 5; i++)
|
||||
p(sg.id, 1700000000000 + i * 1000, 51.0447 + i * 0.0001, -114.0719, 72.0,
|
||||
1045.0, i + 1),
|
||||
];
|
||||
final g = gpx(etrip, esegs, epts, "Sam & Dave's <ride>");
|
||||
final j = geoJson(etrip, esegs, epts, "Sam & Dave's <ride>");
|
||||
out('gpx_len', g.length);
|
||||
out('gpx_fnv', fnv1a(g));
|
||||
out('geojson_len', j.length);
|
||||
out('geojson_fnv', fnv1a(j));
|
||||
}
|
||||
60
rippr-flutter-src/tool/parity/run.sh
Executable file
60
rippr-flutter-src/tool/parity/run.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/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 "$NATIVE/app/src/main/java/com/rippr/export/RideExport.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 RideExport.kt stubs.kt \
|
||||
telemetry_stub.kt main.kt -include-runtime -d parity.jar 2>&1 \
|
||||
| grep -v '^warning:' || true )
|
||||
|
||||
# A missing jar means compilation failed. Never let that pass as a green run -- a v2
|
||||
# sweep hid exactly this behind a redirect and reported four identical results.
|
||||
if [ ! -f "$WORK/parity.jar" ]; then
|
||||
echo "Kotlin oracle failed to compile (see errors above)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
19
rippr-flutter-src/tool/parity/stubs.kt
Normal file
19
rippr-flutter-src/tool/parity/stubs.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
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 }
|
||||
|
||||
enum class TripState { RECORDING, PAUSED, COMPLETED }
|
||||
data class Trip(
|
||||
val id: Long = 0, val startedAt: Long, val endedAt: Long? = null,
|
||||
val name: String? = null, val state: TripState = TripState.RECORDING,
|
||||
val distanceM: Double = 0.0, val movingMillis: Long = 0,
|
||||
val maxSpeedKmh: Float = 0f, val elevationGainM: Double = 0.0,
|
||||
val pointCount: Int = 0,
|
||||
)
|
||||
2
rippr-flutter-src/tool/parity/telemetry_stub.kt
Normal file
2
rippr-flutter-src/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