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

201 lines
11 KiB
Markdown
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.

# 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<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](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
```