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>
171 lines
7.2 KiB
Kotlin
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) }
|
|
}
|
|
}
|