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