KMPCharts/chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.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

318 lines
13 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dev.dtrentin.chart.buffer
internal class TieredBuffer {
private val tier0 = CircularBuffer(TIER0_CAPACITY)
private val tier1 = CircularBuffer(TIER1_CAPACITY)
private val tier2 = CircularBuffer(TIER2_CAPACITY)
// M4 per-bin accumulator: first/min/max/last (ts, value) pairs.
// Preserves visual shape across bin boundaries vs (midTs, min)/(midTs, max) pairs
// that collapse to a vertical spike at flush time (C7 artifact).
private var tier1BinStartMs = -1L
private var tier1BinFirstTs = 0L; private var tier1BinFirstV = 0f
private var tier1BinLastTs = 0L; private var tier1BinLastV = 0f
private var tier1BinMinTs = 0L; private var tier1BinMinV = 0f
private var tier1BinMaxTs = 0L; private var tier1BinMaxV = 0f
private var tier1BinHasData = false
private var tier2BinStartMs = -1L
private var tier2BinFirstTs = 0L; private var tier2BinFirstV = 0f
private var tier2BinLastTs = 0L; private var tier2BinLastV = 0f
private var tier2BinMinTs = 0L; private var tier2BinMinV = 0f
private var tier2BinMaxTs = 0L; private var tier2BinMaxV = 0f
private var tier2BinHasData = false
private val t0Ts = LongArray(TIER0_CAPACITY)
private val t0Vs = FloatArray(TIER0_CAPACITY)
private val t1Ts = LongArray(TIER1_CAPACITY)
private val t1Vs = FloatArray(TIER1_CAPACITY)
private val t2Ts = LongArray(TIER2_CAPACITY)
private val t2Vs = FloatArray(TIER2_CAPACITY)
// Reusable scratch for M4 flush sort/dedup (4 records max per bin). Avoids per-call alloc.
private val flushTs = LongArray(4)
private val flushVs = FloatArray(4)
fun push(timestampMs: Long, value: Float) {
tier0.push(timestampMs, value)
feedTier1(timestampMs, value)
feedTier2(timestampMs, value)
}
private fun feedTier1(ts: Long, value: Float) {
if (tier1BinStartMs < 0L) {
tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS
}
if (ts >= tier1BinStartMs + TIER1_BIN_MS) {
if (tier1BinHasData) {
flushM4ToTier1()
}
tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS
tier1BinFirstTs = ts; tier1BinFirstV = value
tier1BinLastTs = ts; tier1BinLastV = value
tier1BinMinTs = ts; tier1BinMinV = value
tier1BinMaxTs = ts; tier1BinMaxV = value
tier1BinHasData = true
} else {
if (!tier1BinHasData) {
tier1BinFirstTs = ts; tier1BinFirstV = value
tier1BinLastTs = ts; tier1BinLastV = value
tier1BinMinTs = ts; tier1BinMinV = value
tier1BinMaxTs = ts; tier1BinMaxV = value
tier1BinHasData = true
} else {
// Samples arrive in chronological order → always update last.
tier1BinLastTs = ts; tier1BinLastV = value
if (value < tier1BinMinV) { tier1BinMinV = value; tier1BinMinTs = ts }
if (value > tier1BinMaxV) { tier1BinMaxV = value; tier1BinMaxTs = ts }
}
}
}
private fun feedTier2(ts: Long, value: Float) {
if (tier2BinStartMs < 0L) {
tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS
}
if (ts >= tier2BinStartMs + TIER2_BIN_MS) {
if (tier2BinHasData) {
flushM4ToTier2()
}
tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS
tier2BinFirstTs = ts; tier2BinFirstV = value
tier2BinLastTs = ts; tier2BinLastV = value
tier2BinMinTs = ts; tier2BinMinV = value
tier2BinMaxTs = ts; tier2BinMaxV = value
tier2BinHasData = true
} else {
if (!tier2BinHasData) {
tier2BinFirstTs = ts; tier2BinFirstV = value
tier2BinLastTs = ts; tier2BinLastV = value
tier2BinMinTs = ts; tier2BinMinV = value
tier2BinMaxTs = ts; tier2BinMaxV = value
tier2BinHasData = true
} else {
tier2BinLastTs = ts; tier2BinLastV = value
if (value < tier2BinMinV) { tier2BinMinV = value; tier2BinMinTs = ts }
if (value > tier2BinMaxV) { tier2BinMaxV = value; tier2BinMaxTs = ts }
}
}
}
/**
* Push M4 records (first/min/max/last) for current tier1 bin in chronological order,
* deduplicated by (ts, v). 4-element insertion sort. Worst case 4 distinct pushes,
* best case 1 (single-sample bin).
*/
private fun flushM4ToTier1() {
flushTs[0] = tier1BinFirstTs; flushVs[0] = tier1BinFirstV
flushTs[1] = tier1BinMinTs; flushVs[1] = tier1BinMinV
flushTs[2] = tier1BinMaxTs; flushVs[2] = tier1BinMaxV
flushTs[3] = tier1BinLastTs; flushVs[3] = tier1BinLastV
sort4ByTs(flushTs, flushVs)
pushDistinctRecords(tier1, flushTs, flushVs)
}
private fun flushM4ToTier2() {
flushTs[0] = tier2BinFirstTs; flushVs[0] = tier2BinFirstV
flushTs[1] = tier2BinMinTs; flushVs[1] = tier2BinMinV
flushTs[2] = tier2BinMaxTs; flushVs[2] = tier2BinMaxV
flushTs[3] = tier2BinLastTs; flushVs[3] = tier2BinLastV
sort4ByTs(flushTs, flushVs)
pushDistinctRecords(tier2, flushTs, flushVs)
}
private fun sort4ByTs(ts: LongArray, vs: FloatArray) {
// Insertion sort on 4 elements. Co-sorts vs alongside ts.
for (i in 1 until 4) {
val tk = ts[i]; val vk = vs[i]
var j = i - 1
while (j >= 0 && ts[j] > tk) {
ts[j + 1] = ts[j]; vs[j + 1] = vs[j]
j--
}
ts[j + 1] = tk; vs[j + 1] = vk
}
}
private fun pushDistinctRecords(target: CircularBuffer, ts: LongArray, vs: FloatArray) {
// Push first; subsequent only if (ts, v) differs from previous pushed pair.
target.push(ts[0], vs[0])
var prevTs = ts[0]; var prevV = vs[0]
for (i in 1 until 4) {
if (ts[i] != prevTs || vs[i] != prevV) {
target.push(ts[i], vs[i])
prevTs = ts[i]; prevV = vs[i]
}
}
}
fun latestTimestampMs(): Long = tier0.latestTimestampMs()
fun snapshot(
windowStartMs: Long,
windowMs: Long,
outTimestamps: LongArray,
outValues: FloatArray,
): Int {
val nowMs = tier0.latestTimestampMs()
if (nowMs < 0L) return 0
val windowEndMs = windowStartMs + windowMs
val tier0BoundaryMs = nowMs - TIER0_DURATION_MS
val tier1BoundaryMs = nowMs - TIER0_DURATION_MS - TIER1_DURATION_MS
var outIdx = 0
if (windowStartMs < tier1BoundaryMs) {
val n2 = tier2.snapshot(t2Ts, t2Vs)
for (i in 0 until n2) {
val ts = t2Ts[i]
if (ts >= windowStartMs && ts < windowEndMs && ts < tier1BoundaryMs) {
if (outIdx >= outTimestamps.size) break
outTimestamps[outIdx] = ts; outValues[outIdx] = t2Vs[i]; outIdx++
}
}
}
if (windowStartMs < tier0BoundaryMs && windowEndMs > tier1BoundaryMs) {
val n1 = tier1.snapshot(t1Ts, t1Vs)
for (i in 0 until n1) {
val ts = t1Ts[i]
if (ts >= windowStartMs && ts < windowEndMs && ts >= tier1BoundaryMs && ts < tier0BoundaryMs) {
if (outIdx >= outTimestamps.size) break
outTimestamps[outIdx] = ts; outValues[outIdx] = t1Vs[i]; outIdx++
}
}
}
val tier0Start = maxOf(windowStartMs, tier0BoundaryMs)
val n0 = tier0.snapshot(t0Ts, t0Vs)
for (i in 0 until n0) {
val ts = t0Ts[i]
if (ts >= tier0Start && ts < windowEndMs) {
if (outIdx >= outTimestamps.size) break
outTimestamps[outIdx] = ts; outValues[outIdx] = t0Vs[i]; outIdx++
}
}
return outIdx
}
/**
* Bisect-based variant of [snapshot]. Returns identical content + ordering for the
* same (windowStartMs, windowMs) args, but locates the window-start index in each
* tier's chronologically-sorted snapshot via O(log n) bisect instead of an
* O(n) linear pre-scan. Linear walk runs only over the in-window subrange.
*
* Output ordering: tier2 oldest first, then tier1, then tier0 newest — same as [snapshot].
* Tier boundary clamps (tier0BoundaryMs / tier1BoundaryMs) preserved verbatim.
*
* Returns 0 on empty buffer or window entirely outside data.
*/
fun snapshotWindow(
windowStartMs: Long,
windowMs: Long,
outTimestamps: LongArray,
outValues: FloatArray,
): Int {
val nowMs = tier0.latestTimestampMs()
if (nowMs < 0L) return 0
val windowEndMs = windowStartMs + windowMs
val tier0BoundaryMs = nowMs - TIER0_DURATION_MS
val tier1BoundaryMs = nowMs - TIER0_DURATION_MS - TIER1_DURATION_MS
var outIdx = 0
if (windowStartMs < tier1BoundaryMs) {
val n2 = tier2.snapshot(t2Ts, t2Vs)
// Tier2 records satisfy ts < tier1BoundaryMs (older than tier1 horizon),
// so upper clamp is min(windowEndMs, tier1BoundaryMs).
val tier2UpperExclusive = if (windowEndMs < tier1BoundaryMs) windowEndMs else tier1BoundaryMs
val start = bisectStart(t2Ts, n2, windowStartMs)
var i = start
while (i < n2) {
val ts = t2Ts[i]
if (ts >= tier2UpperExclusive) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t2Vs[i]; outIdx++
i++
}
}
if (windowStartMs < tier0BoundaryMs && windowEndMs > tier1BoundaryMs) {
val n1 = tier1.snapshot(t1Ts, t1Vs)
// Tier1 records satisfy tier1BoundaryMs <= ts < tier0BoundaryMs.
val tier1Lower = if (windowStartMs > tier1BoundaryMs) windowStartMs else tier1BoundaryMs
val tier1UpperExclusive = if (windowEndMs < tier0BoundaryMs) windowEndMs else tier0BoundaryMs
val start = bisectStart(t1Ts, n1, tier1Lower)
var i = start
while (i < n1) {
val ts = t1Ts[i]
if (ts >= tier1UpperExclusive) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t1Vs[i]; outIdx++
i++
}
}
val tier0Start = if (windowStartMs > tier0BoundaryMs) windowStartMs else tier0BoundaryMs
val n0 = tier0.snapshot(t0Ts, t0Vs)
val start0 = bisectStart(t0Ts, n0, tier0Start)
var i = start0
while (i < n0) {
val ts = t0Ts[i]
if (ts >= windowEndMs) break
if (outIdx >= outTimestamps.size) return outIdx
outTimestamps[outIdx] = ts; outValues[outIdx] = t0Vs[i]; outIdx++
i++
}
return outIdx
}
fun clear() {
tier0.clear(); tier1.clear(); tier2.clear()
tier1BinStartMs = -1L
tier1BinFirstTs = 0L; tier1BinFirstV = 0f
tier1BinLastTs = 0L; tier1BinLastV = 0f
tier1BinMinTs = 0L; tier1BinMinV = 0f
tier1BinMaxTs = 0L; tier1BinMaxV = 0f
tier1BinHasData = false
tier2BinStartMs = -1L
tier2BinFirstTs = 0L; tier2BinFirstV = 0f
tier2BinLastTs = 0L; tier2BinLastV = 0f
tier2BinMinTs = 0L; tier2BinMinV = 0f
tier2BinMaxTs = 0L; tier2BinMaxV = 0f
tier2BinHasData = false
}
/**
* Tier capacities. TIER1/TIER2 multiplied by 4 because M4 binning (T12) emits up to
* 4 records per bin (first, min, max, last) instead of the previous 2 (min, max).
* Steady-state average likely 23 records/bin after (ts, v) dedup; ×4 is safe upper bound.
*
* TIER0_CAPACITY: 5 min × 200 Hz = 60_000 raw samples.
* TIER1_CAPACITY: 10 min × 10 Hz × 4 M4 records = 24_000 records.
* TIER2_CAPACITY: 45 min × 1 Hz × 4 M4 records = 10_800 records.
* TOTAL_CAPACITY: 94_800 samples per signal.
*/
internal companion object {
const val TIER0_MAX_HZ = 200
const val TIER1_HZ = 10
const val TIER2_HZ = 1
const val TIER0_DURATION_MS = 5L * 60_000L
const val TIER1_DURATION_MS = 10L * 60_000L
const val TIER2_DURATION_MS = 45L * 60_000L
const val TIER1_BIN_MS = 1000L / TIER1_HZ
const val TIER2_BIN_MS = 1000L / TIER2_HZ
val TIER0_CAPACITY = ((TIER0_DURATION_MS / 1000L) * TIER0_MAX_HZ).toInt()
val TIER1_CAPACITY = ((TIER1_DURATION_MS / 1000L) * TIER1_HZ).toInt() * 4
val TIER2_CAPACITY = ((TIER2_DURATION_MS / 1000L) * TIER2_HZ).toInt() * 4
val TOTAL_CAPACITY = TIER0_CAPACITY + TIER1_CAPACITY + TIER2_CAPACITY
}
}