KMPCharts/.paul/phases/v0.4.0-portfolio-hardening-plan.md
Trentin Davide 0e3b79e2da feat(chart): v0.4.0 portfolio hardening
Ship 15-task plan via kmp-manager group-by-group flow. Library moves
from "prototype" to "senior-engineer-built KMP library" per 3-reviewer
audit (android-reviewer + mobile-performance-reviewer + mobile-architect).

Critical fixes:
- LICENSE MIT (T1) — legal blocker resolved
- explicitApi() Strict + BCV 0.16.3 + klib enabled (T2) — ABI locked
  baseline: chart-realtime.api 161 LOC + chart-realtime.klib.api 211 LOC
- Internal lockdown: CircularBuffer, TieredBuffer, LodDecimator,
  dataVersion, rememberFrameTick (T3)
- kotlinx-datetime 0.6.2 replaces expect/actual currentTimeMs (T4)
  Platform.kt + .android.kt + .ios.kt deleted
- CircularBuffer.writeIndex Int -> Long (T5) — fix 124d overflow @ 200Hz
- Remove !! and @Suppress("UNUSED_EXPRESSION") from commonMain (T6)
- Remove dead config: maxBufferSeconds, xLabelDecimals,
  SignalConfig.label (T7)

Correctness:
- POSITIVE_INFINITY/Float.MAX_VALUE sentinels -> hasData flag (T8)
- Snapshot.withMutableSnapshot + SnapshotApplyConflictException retry
  in push/addSignal/removeSignal (T9)
- dataVersion mutableLongStateOf — Compose-observable, idle = 0
  recompositions (T10) — RenderLoop.kt deleted, targetFps @Deprecated
- Single buffer.snapshot() per signal per frame via SignalEntry
  per-signal scratch arrays (T11) — render-time snapshot cost halved
- M4 binning Tier1/Tier2: firstTs/V + minTs/V + maxTs/V + lastTs/V
  with chronological dedup (T12) — no C7 vertical spike artifacts.
  TIER1/TIER2 CAPACITY x2 -> x4 (TOTAL 77.4k -> 94.8k)
- NaN/Inf/backward-timestamp guards on push (T13) — silent drop,
  per-signal lastPushedTs tracking, KDoc contract

Test gap fill:
- NumberFormat extracted from AxisRenderer to internal object (T14)
  +38 tests pinning rounding/scientific/time-format behavior
- TieredBufferTest +15 tests: tier roll, window-crossing, clear,
  edge cases (T15)
- LodDecimatorTest +10 LTTB-specific branch coverage tests (T15)

Test count: 33 -> 107 (+74).
ABI: 211/272 -> 161/211 LOC (locked, baseline committed).

Deferred to v0.5.0:
- Bump kotlinx-datetime 0.6.2 -> 0.7.x (needs Kotlin 2.1.20+)
- Remove ChartConfig.targetFps (currently @Deprecated, ignored)
- NumberFormat Long overflow @ 1e19 (safe via current routing)
- LTTB upper-bound inclusive vs snapshot half-open semantic mismatch
- Macrobench setup for -30% frame-time verification

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

11 KiB
Raw Blame History

v0.4.0 — Portfolio Hardening Plan

Source

Verdict consolidato 3 reviewer (android-reviewer + mobile-performance-reviewer + mobile-architect) 2026-05-21. Library oggi = prototipo solido NON portfolio-grade. Stima totale: ~10-12 giorni full-time.

Goal

Portare chart-realtime da "promising prototype" a "senior-engineer-built KMP library". Fix issue critiche, lockdown ABI, correttezza thread-safety, zero-alloc render reale.

Scope IN (Fase 1 + 2 del plan completo)

  • Hygiene base: LICENSE, explicitApi, binary-compatibility-validator, dead code removal
  • Correttezza: Compose snapshot thread safety, M4 binning, single-snapshot per frame, NaN/Inf guards
  • Test coverage: AxisRenderer format helpers + TieredBuffer + LTTB

Scope OUT

  • Interaction layer (touch/zoom/pan) → v0.5.0
  • MinMaxLTTB algorithm swap → v0.5.0
  • Marketing (Maven Central, screenshots, CI badges, demo iOS) → v1.0.0

Tasks

T1 — LICENSE (S0 blocker)

  • File: /Users/henesis/dev/dav/KMPCharts/LICENSE
  • Action: scegli MIT o Apache 2.0. Aggiungi file root + reference in README + chart-realtime/build.gradle.kts pom config (per Maven publish futuro).
  • Agent: manager direct (10 LoC)
  • Verify: ls LICENSE + git tracked

T2 — explicitApi + binary-compatibility-validator

  • Files: chart-realtime/build.gradle.kts, root build.gradle.kts, gradle/libs.versions.toml
  • Action:
    • Aggiungi kotlin { explicitApi() } a chart-realtime/build.gradle.kts
    • Plugin org.jetbrains.kotlinx.binary-compatibility-validator versione corrente
    • Config apiValidation { @OptIn(kotlinx.validation.ExperimentalBCVApi::class) klib { enabled = true } }
    • Commit chart-realtime/api/chart-realtime.api dump
  • Agent: gradle-expert
  • Verify: ./gradlew :chart-realtime:apiDump + check committed file
  • Pattern: RevenueCat KMP example

T3 — Internal lockdown (6 leaked symbols)

  • Files:
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:33dataVersion internal
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt → class internal
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt → class + companion internal
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/LodDecimator.kt → class internal
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/RenderLoop.kt:16rememberFrameTick internal
  • Action: cambia visibility a internal. Fixare call site RealtimeChart.kt:51-54 che usa TieredBuffer.TOTAL_CAPACITY (resta accessibile da stesso modulo).
  • Agent: kmp-architect
  • Verify: ./gradlew :chart-realtime:apiCheck + compile green

T4 — Drop expect/actual currentTimeMs → kotlinx-datetime

  • Files:
    • Delete: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/Platform.kt
    • Delete: chart-realtime/src/androidMain/kotlin/dev/dtrentin/chart/Platform.android.kt
    • Delete: chart-realtime/src/iosMain/kotlin/dev/dtrentin/chart/Platform.ios.kt
    • Modify: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:64 (callsite)
    • Modify: chart-realtime/build.gradle.kts add kotlinx-datetime dep
    • Modify: gradle/libs.versions.toml add kotlinx-datetime ≥ 0.7.0
  • Action: import kotlinx.datetime.Clock. Replace callsite con Clock.System.now().toEpochMilliseconds().
  • Agent: kmp-architect
  • Verify: compileKotlinIosSimulatorArm64 + compileDebugKotlinAndroid green
  • Pattern: kotlinx-datetime GitHub

T5 — CircularBuffer.writeIndex: Int → Long (overflow critico dopo 124 giorni @ 200Hz)

  • File: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt:15-46
  • Action:
    • writeIndex: Int → Long
    • Aggiorna call site (writeIndex % capacity).toInt() per index access
    • Update snapshot boundary math currentWrite - capacity
  • Agent: data-impl
  • Verify: nuovo test writeIndexSurvivesIntMaxValue in CircularBufferTest

T6 — Rimuovi @Suppress("UNUSED_EXPRESSION") frameTick + !!

  • File: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt:50,59,63,90,103
  • Action:
    • Linea 59: delete (recomposition già triggata da line 41 read)
    • Linea 63: aggiungi val t0 = state.resolvedT0Ms ?: return@Canvas
    • Linea 90: replace state.resolvedT0Ms!! con t0 capturato
    • Linea 50/103: replace mutableLongStateOf con remember { longArrayOf(-1L) } → versionHolder[0] (no snapshot write in draw)
  • Agent: compose-guide
  • Verify: compose preview funziona + 33 test pass

T7 — Remove dead config

  • Files:
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt:18maxBufferSeconds (ignored by TieredBuffer)
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt:24xLabelDecimals (ignored by formatTimeSec)
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/SignalConfig.kt:17label (never used)
  • Action: rimuovi i 3 field. Update README dove menzionano.
  • Agent: kmp-architect
  • Verify: compile green + test green

T8 — Replace POSITIVE_INFINITY sentinel

  • Files:
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt:77-87
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt:35,54
  • Action: introdurre var hasData = false flag invece di confronto float-equality sentinel.
  • Agent: data-impl
  • Verify: test esistenti pass + nuovo test tier1BinAcceptsFloatMaxValueSample

T9 — Compose snapshot thread safety

  • File: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:42-66
  • Action: wrap push, addSignal, removeSignal body in Snapshot.withMutableSnapshot { }. Catch SnapshotApplyConflictException con retry loop.
  • Alt: replace _signals: MutableState<Map> con mutableLongStateOf versione bumped on mutation + plain volatile map → eliminate snapshot writes from background thread.
  • Agent: kmp-architect + compose-guide (paired review)
  • Verify: stress test multi-producer (2+ thread call push 200Hz). Aggiungere pushFromBackgroundThreadIsSafe test.
  • Pattern: Zach Klipp Snapshot guide

T10 — dataVersion → mutableLongStateOf (drop frameTick dependency)

  • Files:
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:33
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt:41,60
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/RenderLoop.kt → ridurre frameTick a heartbeat opzionale o eliminarlo
  • Action: dataVersion come mutableLongStateOf(0L). Canvas reads diventano Compose-observable. Recomposition data-driven, no più dipendenza da rememberFrameTick polling.
  • Agent: compose-guide
  • Verify: idle behavior — no new data = no recomposition. Use Layout Inspector recomposition count.

T11 — Single snapshot per signal per frame

  • Files:
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt:79-86
    • chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt:30
  • Action: snapshot una sola volta per signal in RealtimeChart, calcola dataMin/dataMax nello stesso loop, passa count a drawSignal (rimuovi snapshot interno).
  • Agent: kmp-architect
  • Verify: macrobench frame time -30% con 4 signals

T12 — M4 binning per Tier1/Tier2 (fix artefatti visivi C7)

  • File: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/TieredBuffer.kt:30-66
  • Action: per bin store (firstTs, firstV, lastTs, lastV, minV, maxV) invece di doppio push (midTs, min), (midTs, max). Rispetta ordine cronologico downstream.
  • Agent: data-impl
  • Verify: nuovi test tier1BinPreservesM4, tier2BinPreservesM4, noVerticalSpikesAtBinBoundaries
  • Pattern: M4 paper / MinMaxLTTB arXiv 2305.00332

T13 — NaN/Inf/backward-timestamp guards

  • File: chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:52
  • Action:
    • push rifiuta !value.isFinite() (early return)
    • push rifiuta timestampMs < lastPushedTs per signal (drop or expose PushResult sealed)
    • KDoc contratto: "Samples must have finite value and monotonic non-decreasing timestamps"
  • Agent: data-impl
  • Verify: test pushRejectsNaN, pushRejectsBackwardTimestamp

T14 — Test gap: AxisRenderer format helpers

  • File new: chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/render/NumberFormatTest.kt
  • Refactor: extract formatFixed, formatScientific, formatTimeSec, niceInterval, pow10 da AxisRenderer privato → internal object NumberFormat.
  • Action: 20+ edge case test (0.999 rounding, 1.005 banker's, 99.999 carry, 1e19 Long overflow, negative, NaN, very large exp, time -65s, time 3700s)
  • Agent: unit-test-writer
  • Verify: iosSimulatorArm64Test count passa da 33 a 50+

T15 — Test gap: TieredBuffer + LodDecimator LTTB branch

  • Files new:
    • chart-realtime/src/commonTest/kotlin/dev/dtrentin/chart/buffer/TieredBufferTest.kt
    • Extend LodDecimatorTest.kt con LTTB branch coverage
  • Action: 10+ test su tier roll, bin flush, snapshot window crossing 3 tier boundaries, clear()
  • Agent: unit-test-writer
  • Verify: coverage TieredBuffer ≥80%

Acceptance v0.4.0

  • A1: ./gradlew :chart-realtime:apiCheck green con .api dump committato
  • A2: kotlin { explicitApi() } attivo, nessun warning visibility
  • A3: LICENSE presente, riferito in README + Gradle publish config
  • A4: Tutti i symbol "leaked" sono internal (CircularBuffer, TieredBuffer, LodDecimator, dataVersion, rememberFrameTick, capacity constants)
  • A5: kotlinx-datetime sostituisce expect/actual currentTimeMs (3 file cancellati)
  • A6: 0 !! non null assertion in commonMain
  • A7: 0 @Suppress magic-fix annotations
  • A8: Multi-producer push test green (50 thread × 100 push each, no lost samples, no race exceptions)
  • A9: Macrobench frame time -30% post T11 (single snapshot)
  • A10: iosSimulatorArm64Test ≥ 60 test (da 33)
  • A11: Compose snapshot conformance: writes da background thread sempre via withMutableSnapshot
  • A12: NaN/Inf samples rifiutati, backward timestamps droppati
  • A13: TieredBuffer M4 binning, no vertical spike artifacts
  • A14: README aggiornato — rimuovi dead config refs, claim "zero GC at render" qualificato

Parallelism

Group Tasks
P0 T1, T7 (parallel, file-isolated)
P1 T2 (Gradle config)
P2 T3, T4 (parallel: visibility + kotlinx-datetime in disjoint files)
P3 T5, T8 (buffer fixes parallel)
P4 T6, T9, T10 (compose layer)
P5 T11, T12 (render perf + M4)
P6 T13 (input validation)
P7 T14, T15 (test gap fill, parallel)

Estimated agent calls

~15 agent calls. ~3-4 giorni full-time.

Risks

  • T9 (snapshot thread safety) may surface API changes — minor breaking
  • T12 (M4 binning) requires LodDecimator MIN_MAX consumer update — coupled
  • T10 may remove rememberFrameTick public API — breaking, mitigate via @Deprecated

Verification commands (consolidated)

./gradlew :chart-realtime:apiCheck
./gradlew :chart-realtime:compileKotlinIosX64 :chart-realtime:compileKotlinIosArm64 :chart-realtime:compileKotlinIosSimulatorArm64
./gradlew :chart-realtime:iosSimulatorArm64Test
./gradlew :chart-realtime:assembleRelease
./gradlew :chart-realtime:assembleChartRealtimeXCFramework