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 { MinMaxLttbLodStrategy(ratio = 0) } } }