KMPCharts/.paul/phases/v0.5.0-architecture-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

159 lines
8.7 KiB
Markdown

# v0.5.0 — Architecture + Interaction Plan
## Source
mobile-architect verdict 2026-05-21: "Library is a closed renderer with single skin. Anything beyond first-party use case requires forking." Senza interaction layer "no senior reviewer will take a real-time chart claim seriously."
## Goal
Aprire estensibilità + aggiungere interaction layer (S0 per portfolio). MinMaxLTTB SOTA algorithm. Config split. Bisect tier snapshot per perf.
## Prereq
v0.4.0 shipped (hygiene + correctness).
## Scope IN
- Interaction layer: pinch zoom, drag pan, crosshair, tap-to-inspect
- Renderer/LodStrategy/AxisFormatter interfaces
- MinMaxLTTB algorithm (replaces both MIN_MAX and LTTB attuali)
- Bisect-based windowed snapshot (30x perf win)
- ChartConfig split DataConfig/AxisConfig/RenderConfig
- Compose stability annotations (@Stable, @Immutable)
## Scope OUT
- Multi-Y axis / log scale → v0.6.0+
- Annotations / threshold lines → v0.6.0+
- Headless data export → backlog
---
## Tasks
### T1 — Bisect tier snapshot (perf S0)
- Files: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/buffer/CircularBuffer.kt`, `TieredBuffer.kt`
- Goal: `snapshotWindow(startMs, endMs, outTs, outV): Int` bisect su timestamps monotonici. Cuts 30x copies su 10s window in 5min buffer.
- Agent: data-impl + mobile-performance-reviewer review
- Verify: macrobench frame time -50% addizionale post v0.4.0 T11
### T2 — LodStrategy interface + MinMaxLTTB impl
- New files:
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/lod/LodStrategy.kt`
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/lod/MinMaxLttb.kt`
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/lod/MinMaxLod.kt`
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/lod/LttbLod.kt`
- Move + rename: `buffer/LodDecimator.kt``lod/LodDecimator.kt` (delegate to strategy)
- Goal: interface `LodStrategy { fun decimate(...): Int }`. Default = `MinMaxLttb` (10x faster than LTTB puro per arXiv 2305.00332).
- API change: `LodMode` enum → `LodStrategy` interface. `ChartConfig.lodMode: LodMode``lodStrategy: LodStrategy`. Backward compat: `LodMode.MIN_MAX_LTTB` shorthand.
- Agent: kmp-architect + data-impl
- Verify: nuovi test `MinMaxLttbVisualFidelityTest`, perf ≥ MIN_MAX puro su large datasets
- Pattern: [MinMaxLTTB arXiv 2305.00332](https://arxiv.org/pdf/2305.00332), [Rust ref impl](https://github.com/andrei-ng/minmaxlttb-rs)
### T3 — SignalRenderer interface
- New file: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt` (interface)
- Move impl → `LineSignalRenderer.kt`
- Goal: pluggable rendering. Future-proof per `ScatterRenderer`, `AreaRenderer`, `BarRenderer`. Default `LineSignalRenderer`.
- `SignalConfig.renderer: SignalRenderer = LineSignalRenderer` (per-signal opt-in)
- Agent: kmp-architect
- Verify: existing rendering unchanged, plus `customRendererPlugsIn` test
### T4 — AxisFormatter interface
- New file: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisFormatter.kt`
- Default impls: `TimeAxisFormatter` (current behavior), `DecimalAxisFormatter`, `DateTimeAxisFormatter`, `UnitAxisFormatter("mV", "°C", "Hz")`.
- `AxisConfig.xFormatter: AxisFormatter = TimeAxisFormatter`, `yFormatter: AxisFormatter = DecimalAxisFormatter`
- Agent: kmp-architect + ui-impl
- Verify: user can pass `UnitAxisFormatter("ms")` and labels show "5 ms"
### T5 — Split ChartConfig → DataConfig + AxisConfig + RenderConfig
- File: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/model/ChartConfig.kt`
- New: `DataConfig`, `AxisConfig`, `RenderConfig`. `ChartConfig` = wrapper o builder DSL.
- `targetFps: Int?``sealed class FrameRate { object Display; data class Fixed(val fps: Int) }`
- Mark old `ChartConfig` ctor `@Deprecated(level = WARNING)` con `ReplaceWith`.
- Agent: kmp-architect
- Verify: README example updated, compile green con deprecation warning
### T6 — Compose stability annotations
- Files (tutti i data class in model/):
- `ChartConfig.kt`, `SignalConfig.kt`, `ChartTheme.kt`, `T0.kt`, `YRange.kt`, `LodMode.kt`, `AxisLabelMode.kt`
- `RealtimeChartState.kt`
- Action: `@Immutable` su data class + sealed root. `@Stable` su `RealtimeChartState`.
- Agent: compose-guide
- Verify: Compose compiler report `chart-realtime` mostra 0 unstable classes nel public API. `./gradlew :chart-realtime:compileReleaseKotlinAndroid -Pkotlinx.compose.report` etc.
### T7 — Interaction layer S0 (touch/zoom/pan/crosshair)
- New files:
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/ChartInteractionState.kt`
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/interaction/InteractionConfig.kt`
- Modify: `RealtimeChart.kt` aggiunge `Modifier.pointerInput` con `detectTransformGestures` (pinch) + `detectDragGestures` (pan) + `detectTapGestures` (crosshair toggle).
- State:
- `viewportOffsetMs: Long` — pan offset history
- `viewportScale: Float` — zoom factor on xWindowSeconds
- `mode: ViewportMode { Following, Frozen, History(offsetMs) }`
- `crosshair: CrosshairState?` — pixel position + projected (timestampMs, value) per ogni signal
- Inverse projection helper (pixel → data) per tooltip
- Agent: ui-impl + compose-guide
- Verify:
- pinch zoom restringe/allarga xWindowSeconds
- drag pan sposta viewport (mode Frozen automatically)
- tap mostra crosshair con valori istantanei
- tap su crosshair attivo → dismiss
- swipe a destra estrema → mode Following (auto-scroll resumes)
- New tests: `ChartInteractionStateTest`, integration test composable
### T8 — Cache Stroke + TextStyle + TextLayoutResult per zero-alloc reale
- Files:
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/SignalRenderer.kt:50``Stroke` cached per signal
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt:54-131``TextStyle` hoisted in `remember`, label `TextLayoutResult` cached via `remember(label, style)`
- Action: introduce `internal class FrameCaches` holding stroke per signal + label LRU.
- Agent: compose-guide + mobile-performance-reviewer
- Verify: allocation tracker show < 1 KB/s steady-state at 30fps, 4 signals
- Pattern: [TextMeasurer LRU - composables.com](https://composables.com/jetpack-compose/androidx.compose.ui/ui-text/classes/TextMeasurer/api), [Modifier.drawWithCache - Android Devs](https://developer.android.com/develop/ui/compose/graphics/draw/overview)
### T9 — Replace Pair<Float,Float> return + Map iteration alloc
- Files:
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/render/AxisRenderer.kt:144-152` `resolveYRange` riempie `FloatArray(2)` out param
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChart.kt:70,79,93` Map iter via cached `signalsArray`
- `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt` invalidate signalsArray su addSignal/removeSignal
- Agent: mobile-performance-reviewer + data-impl
- Verify: Map iter allocation count = 0 in macrobench
### T10 — clear() / reset() su RealtimeChartState
- File: `chart-realtime/src/commonMain/kotlin/dev/dtrentin/chart/RealtimeChartState.kt`
- Goal: `fun clear()` purge tutti i buffer + reset dataVersion + reset resolvedT0Ms. Public API.
- Agent: data-impl
- Verify: test `clearResetsState`
---
## Acceptance v0.5.0
- A1: `LodStrategy` interface + `MinMaxLttb` default, perf MIN_MAX puro su 100k+ samples
- A2: `SignalRenderer` interface, default `LineSignalRenderer`, user custom renderer impl-able in 50 LoC
- A3: `AxisFormatter` interface con 4 default impls (Time, Decimal, DateTime, Unit)
- A4: ChartConfig split, FrameRate sealed, deprecated old API con ReplaceWith
- A5: Compose compiler report: 0 unstable public types
- A6: Interaction layer: pinch zoom + drag pan + crosshair tap funzionanti
- A7: Bisect snapshot taglia copy 30x su 10s window in 5min buffer (macrobench)
- A8: Render zero-alloc reale (< 1 KB/s in macrobench steady)
- A9: `RealtimeChartState.clear()` API + test
## Parallelism
| Group | Tasks |
|-------|-------|
| P0 | T1, T6, T10 (parallel) |
| P1 | T2, T3, T4 (interfaces parallel) |
| P2 | T5 (config split, depends on T2-T4 interfaces) |
| P3 | T7 (interaction layer, biggest task) |
| P4 | T8, T9 (perf finishing) |
## Estimated agent calls
~12 agent calls. ~5-7 giorni full-time.
## Risks
- T2 (MinMaxLTTB): break LodMode enum API migration required by consumers
- T7 (interaction): biggest scope, multi-modal touch testing manual
- T5 (config split): breaking API change, mitigare con typealias + deprecated factory
## Verification commands
```bash
./gradlew :chart-realtime:apiCheck
./gradlew :chart-realtime:iosSimulatorArm64Test
./gradlew :app:installDebug # manual interaction test
./gradlew :chart-realtime:assembleRelease
```