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

204
lib/src/domain/models.dart Normal file
View File

@@ -0,0 +1,204 @@
/// Core domain models, free of any persistence or platform dependency.
///
/// Ported from the Room entities in `com.rippr.data`. The Room annotations are
/// deliberately **not** carried over: Drift owns the table definitions in T08 and maps
/// to these types, so the domain layer never depends on the database package. That is
/// the same separation the Kotlin app achieved by keeping logic free of Android imports.
///
/// ## One deliberate divergence: Float becomes double
///
/// Kotlin stores `speedKmh`, `accuracyM` and `bearingDeg` as 32-bit `Float`. Dart has no
/// float32 — every `double` is IEEE-754 binary64. These are therefore widened.
///
/// This is the right call (a float32 shim would be pure friction for sub-millimetre
/// precision on a GPS-derived value), but it means **speed-derived values cannot be
/// compared bit-for-bit across the two implementations**. Kotlin's `Float.toDouble()`
/// produces artefacts like `12.300000190734863`; Dart produces `12.3`. The T07 parity
/// harness must use a tolerance for these, and only these.
library;
/// Where a ride is in its lifecycle.
///
/// [Trip.endedAt] alone distinguishes active from finished, but cannot tell recording
/// from paused — and the recorder needs that distinction to decide what to do when the
/// OS restarts it mid-ride. Hence both.
enum TripState { recording, paused, completed }
/// One ride, from pressing Start to pressing Stop.
///
/// The aggregate fields are denormalised on purpose. They are accumulated as points
/// arrive and recomputed authoritatively when the trip completes, so the trips list can
/// render hundreds of rides without touching the point table.
class Trip {
const Trip({
this.id = 0,
required this.startedAt,
this.endedAt,
this.name,
this.state = TripState.recording,
this.distanceM = 0.0,
this.movingMillis = 0,
this.maxSpeedKmh = 0.0,
this.elevationGainM = 0.0,
this.pointCount = 0,
});
final int id;
final int startedAt;
/// Null while the ride is still active.
final int? endedAt;
/// Null means the UI derives a label from [startedAt]. Never store an empty string.
final String? name;
final TripState state;
final double distanceM;
final int movingMillis;
final double maxSpeedKmh;
final double elevationGainM;
final int pointCount;
bool get isActive => endedAt == null;
int get elapsedMillis => endedAt == null ? 0 : endedAt! - startedAt;
Trip copyWith({
int? id,
int? startedAt,
int? endedAt,
String? name,
TripState? state,
double? distanceM,
int? movingMillis,
double? maxSpeedKmh,
double? elevationGainM,
int? pointCount,
}) =>
Trip(
id: id ?? this.id,
startedAt: startedAt ?? this.startedAt,
endedAt: endedAt ?? this.endedAt,
name: name ?? this.name,
state: state ?? this.state,
distanceM: distanceM ?? this.distanceM,
movingMillis: movingMillis ?? this.movingMillis,
maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh,
elevationGainM: elevationGainM ?? this.elevationGainM,
pointCount: pointCount ?? this.pointCount,
);
}
/// One pause-free stretch of recording within a [Trip].
///
/// This layer is what makes pause correct rather than cosmetic. Without it, a rider who
/// pauses at a gas station and resumes across town gets a polyline drawn straight
/// through terrain they never travelled, and a distance total that includes it. Points
/// are grouped by segment for rendering, distance accumulation, and GPX `<trkseg>`
/// output, so every consumer naturally leaves a gap where the rider stopped.
class Segment {
const Segment({
this.id = 0,
required this.tripId,
required this.startedAt,
this.endedAt,
});
final int id;
final int tripId;
final int startedAt;
/// Null while this segment is still being recorded into.
final int? endedAt;
bool get isOpen => endedAt == null;
}
/// A single GPS fix.
///
/// Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes
/// from the platform location fix, which is GPS-derived and can jump, whereas the
/// autoincrement id is genuinely monotonic in write order.
class TrackPoint {
const TrackPoint({
this.id = 0,
required this.tripId,
required this.segmentId,
required this.timestamp,
required this.latitude,
required this.longitude,
required this.speedKmh,
required this.altitudeM,
this.accuracyM = 0.0,
this.bearingDeg = 0.0,
this.synced = false,
});
final int id;
final int tripId;
final int segmentId;
final int timestamp;
final double latitude;
final double longitude;
final double speedKmh;
final double altitudeM;
final double accuracyM;
final double bearingDeg;
/// Set once the point has been accepted by the remote endpoint.
final bool synced;
TrackPoint copyWith({int? id, int? tripId, int? segmentId, bool? synced}) =>
TrackPoint(
id: id ?? this.id,
tripId: tripId ?? this.tripId,
segmentId: segmentId ?? this.segmentId,
timestamp: timestamp,
latitude: latitude,
longitude: longitude,
speedKmh: speedKmh,
altitudeM: altitudeM,
accuracyM: accuracyM,
bearingDeg: bearingDeg,
synced: synced ?? this.synced,
);
}
/// Cheap SQL-computed stats for the live recording screen.
///
/// Deliberately limited to what plain aggregate functions can express. Distance and
/// elevation gain are absent because they need consecutive-row differences — they are
/// accumulated in Dart and stored on the [Trip] row instead.
///
/// The SQLite-3.18 window-function limitation that forced this in the Kotlin app no
/// longer strictly applies (Drift bundles a modern SQLite), but the split is kept: the
/// accumulate-as-you-go design is what lets a mid-ride crash leave usable totals.
class RideStats {
const RideStats({
required this.pointCount,
required this.maxSpeedKmh,
required this.avgSpeedKmh,
required this.firstTimestamp,
required this.lastTimestamp,
required this.pendingUpload,
});
static const empty = RideStats(
pointCount: 0,
maxSpeedKmh: 0.0,
avgSpeedKmh: 0.0,
firstTimestamp: 0,
lastTimestamp: 0,
pendingUpload: 0,
);
final int pointCount;
final double maxSpeedKmh;
final double avgSpeedKmh;
final int firstTimestamp;
final int lastTimestamp;
final int pendingUpload;
int get durationMillis =>
pointCount == 0 ? 0 : lastTimestamp - firstTimestamp;
}