# 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](https://www.revenuecat.com/blog/engineering/binary-compatability/) ### T3 — Internal lockdown (6 leaked symbols) - Files: - `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt:33` → `dataVersion` 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:16` → `rememberFrameTick` 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](https://github.com/Kotlin/kotlinx-datetime) ### 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:18` → `maxBufferSeconds` (ignored by TieredBuffer) - `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt:24` → `xLabelDecimals` (ignored by formatTimeSec) - `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/SignalConfig.kt:17` → `label` (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` 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](https://blog.zachklipp.com/introduction-to-the-compose-snapshot-system/) ### 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](https://arxiv.org/pdf/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) ```bash ./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 ```