KMPCharts/chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/lod/MinMaxLttbLodStrategyTest.kt
Trentin Davide af6814e2b8 feat(chart): v0.5.0 architecture + interaction
Ships v0.5.0 via kmp-manager 6-phase flow. 13 plan tasks (D1, T1-T10, D3, D4),
14 agent calls, 6 P-groups. 107 → 207 tests on iosSimulatorArm64.

Toolchain (D1):
- Kotlin 2.1.0 → 2.3.21
- Compose-Multiplatform 1.8.0 → 1.11.0
- Android Gradle Plugin 8.7.3 → 9.2.0
- Gradle 8.11.1 → 9.5.1
- coroutines 1.9.0 → 1.11.0, kotlin-test → 2.3.21
- Drop kotlinx-datetime; use stdlib kotlin.time.Clock
- Drop iosX64 target (Compose-MP 1.11.0 has no ios_x64 variant)

Architecture (T1-T6, T10):
- TieredBuffer.snapshotWindow: bisect-based windowed snapshot
- New lod/ package: LodStrategy interface + MinMax/Lttb/MinMaxLttb impls
  (MinMaxLttb SOTA per arXiv 2305.00332, 1.80× faster than pure LTTB)
- New render/SignalRenderer: public interface + LineSignalRenderer object
- New render/AxisFormatter: 4 default impls (Time, Decimal, DateTime, Unit)
- HARD BREAK: deleted LodMode, LodDecimator, ChartConfig.targetFps
- ChartConfig split: DataConfig + AxisConfig + RenderConfig + FrameRate sealed
- @Immutable/@Stable on all public types (0 unstable)
- RealtimeChartState.clear() API

Interaction layer (T7):
- New interaction/ package
- ChartInteractionState + rememberChartInteractionState()
- ViewportMode sealed: Following / Frozen / History(anchorMs)
- Pinch zoom + drag pan + tap crosshair gestures
- Swipe-to-edge resumes Following
- InverseProjection: pixel → ms + bisect nearest-sample

Perf finishing (T8, T9, D3):
- LineSignalRenderer Stroke cache, AxisRenderer TextStyle cache
- resolveYRange Pair<Float,Float> → FloatArray out-param
- RealtimeChartState.signalsArray cached (invalidated on add/remove only)
- LTTB upper-bound aligned to half-open [start, start+windowMs) semantic

Correctness (D4):
- NumberFormat.formatFixed Long overflow guard @ |v|≥1e19

ABI baseline regenerated:
- chart-realtime.api: 161 → 428 LOC
- chart-realtime.klib.api: 211 → 501 LOC

Modules touched: chart-realtime (lib), app (consumer), gradle (toolchain),
.paul (state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:05:43 +02:00

171 lines
7.2 KiB
Kotlin

package dev.dtrentin.chart.lod
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.random.Random
import kotlin.test.*
import kotlin.time.Duration
import kotlin.time.measureTime
class MinMaxLttbLodStrategyTest {
// Tests run up to 100k samples → enlarge scratch beyond default TieredBuffer.TOTAL_CAPACITY (94_800).
private val strategy = MinMaxLttbLodStrategy(maxCount = 100_000)
private val outX = FloatArray(8192)
private val outY = FloatArray(8192)
@Test fun emptyInput_returnsZero() {
assertEquals(0, strategy.decimate(LongArray(0), FloatArray(0), 0, 0L, 1000L, 100, outX, outY))
}
@Test fun zeroPixelWidth_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 1000L, 0, outX, outY))
}
@Test fun zeroWindowMs_returnsZero() {
assertEquals(0, strategy.decimate(longArrayOf(0L), floatArrayOf(1f), 1, 0L, 0L, 100, outX, outY))
}
@Test fun fewerSamplesThanPixels_passThrough() {
val ts = longArrayOf(0L, 500L, 1000L)
val v = floatArrayOf(1f, 2f, 3f)
// windowMs=1001 keeps the 1000L sample inside half-open [0, 1001).
val n = strategy.decimate(ts, v, 3, 0L, 1001L, 100, outX, outY)
assertEquals(3, n)
assertEquals(1f, outY[0]); assertEquals(2f, outY[1]); assertEquals(3f, outY[2])
}
@Test fun upperBoundIsHalfOpen() {
// D3: snapshot semantic is half-open [windowStartMs, windowStartMs+windowMs).
// Sample at tRel == windowMs must be excluded.
val ts = longArrayOf(0L, 50L, 100L, 200L)
val v = floatArrayOf(1f, 2f, 3f, 999f)
val count = strategy.decimate(ts, v, 4, 0L, 200L, 100, outX, outY)
// Boundary sample excluded → n=3 ≤ pixelWidth=100 → pass-through path with 3 points.
assertEquals(3, count)
for (i in 0 until count) {
assertTrue(outY[i] != 999f, "boundary sample at tRel==windowMs must be excluded")
}
}
@Test fun output_neverExceedsPixelWidth() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) }
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
assertTrue(count <= pixels, "count=$count > pixelWidth=$pixels")
assertTrue(count > 0)
}
@Test fun outputX_monotonicallyIncreasing() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) }
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
for (i in 1 until count) {
assertTrue(outX[i] >= outX[i - 1], "outX must be non-decreasing at $i: ${outX[i - 1]} -> ${outX[i]}")
}
}
@Test fun spikePreserved_singleTallOutlier() {
// 100k samples baseline 1f + single 1000f spike. pixelWidth=512.
// Preselection guarantees this — bucket containing spike must emit it as max.
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { 1f }
v[50_000] = 1000f
val pixels = 512
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
val maxOut = outY.copyOf(count).max()
assertEquals(1000f, maxOut, "spike must survive preselection, got max=$maxOut")
}
@Test fun visualFidelity_envelopePreservedVsMinMax() {
// Synthetic sin + noise, 100k samples, n_out=512.
// MinMaxLttb must preserve the global min/max envelope close to pure MIN_MAX.
val n = 100_000
val rng = Random(seed = 42L)
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) {
(sin(2.0 * PI * it / 1000.0) * 10.0 + rng.nextDouble(-0.5, 0.5)).toFloat()
}
val pixels = 512
// Pure MIN_MAX baseline.
val minMax = MinMaxLodStrategy(maxCount = 100_000)
val mmX = FloatArray(8192); val mmY = FloatArray(8192)
val mmCount = minMax.decimate(ts, v, n, 0L, n.toLong(), pixels, mmX, mmY)
val mmMin = mmY.copyOf(mmCount).min()
val mmMax = mmY.copyOf(mmCount).max()
// MinMaxLttb under test.
val count = strategy.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
val outMin = outY.copyOf(count).min()
val outMax = outY.copyOf(count).max()
// Tolerance: 5% of (mmMax - mmMin). Preselection bound says we should see at least
// one of the global min/max per bucket — global envelope should be near-exact.
val tol = (mmMax - mmMin) * 0.05f
assertTrue(abs(outMin - mmMin) <= tol, "min envelope drift: outMin=$outMin vs mmMin=$mmMin tol=$tol")
assertTrue(abs(outMax - mmMax) <= tol, "max envelope drift: outMax=$outMax vs mmMax=$mmMax tol=$tol")
}
@Test fun performance_minMaxLttbNotSlowerThanPureLttb() {
// 100k samples → 512 output. MinMaxLttb should be ≤ pure LTTB time.
// Paper claim is ~10x; acceptance gate is ≥ 1x (≤ pure LTTB time).
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) * 10f }
val pixels = 512
val pureLttb = LttbLodStrategy(maxCount = 100_000)
val minMaxLttb = MinMaxLttbLodStrategy(maxCount = 100_000)
// Warm up both.
repeat(3) {
pureLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
minMaxLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
}
val pureRuns = LongArray(5)
val mmltRuns = LongArray(5)
for (i in 0 until 5) {
pureRuns[i] = measureTime { pureLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY) }.inWholeMicroseconds
mmltRuns[i] = measureTime { minMaxLttb.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY) }.inWholeMicroseconds
}
pureRuns.sort(); mmltRuns.sort()
val purMedian = pureRuns[2]
val mmlMedian = mmltRuns[2]
// Acceptance gate per spec: MinMaxLttb ≤ pure LTTB (paper claim 10x; we require ≥1x).
// Give 20% slack for JIT noise on shared infra.
val slackUs = (purMedian * 0.20).toLong()
assertTrue(
mmlMedian <= purMedian + slackUs,
"MinMaxLttb median=$mmlMedian us > pure LTTB median=$purMedian us (slack=$slackUs us)"
)
println("[perf] pureLttb median=${purMedian}us minMaxLttb median=${mmlMedian}us ratio=${purMedian.toDouble() / mmlMedian}x")
}
@Test fun customRatio_ratio2_stillValid() {
val n = 100_000
val ts = LongArray(n) { it.toLong() }
val v = FloatArray(n) { sin(it.toFloat() / 100f) * 10f }
val pixels = 512
val s2 = MinMaxLttbLodStrategy(ratio = 2, maxCount = 100_000)
val count = s2.decimate(ts, v, n, 0L, n.toLong(), pixels, outX, outY)
assertTrue(count <= pixels, "ratio=2 output should still be ≤ pixelWidth, got $count")
assertTrue(count > 0)
// Monotonic X.
for (i in 1 until count) {
assertTrue(outX[i] >= outX[i - 1], "outX must be non-decreasing at $i")
}
}
@Test fun ratio_lessThan1_throws() {
assertFailsWith<IllegalArgumentException> { MinMaxLttbLodStrategy(ratio = 0) }
}
}