fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)

`decimal` is stored as exact TEXT, but SQLite has no native decimal type,
so arithmetic/aggregation implicitly coerces it to an IEEE-754 double.
The computed result carries no playground type, so `sum(price * qty)`
rendered the double's full noise — `298.59999999999997` for `298.60` — a
confusing, off-topic float lesson for a teaching tool.

Add `format_real_display`: round REAL values to 15 significant figures
(a double's reliable precision) then take the shortest round-tripping
form, collapsing `298.59999999999997` to `298.6`. Wired into `format_cell`
(result-set / `show data` cells) only — the sole surface where the noise
appears, since it arises from arithmetic.

Every other f64->string path keeps full precision for semantic, not
cosmetic, reasons: CSV persistence stays byte-exact for round-trip;
`render_value` is a canonical identity key for the uniqueness dry-runs
(dry_run_unique, check_uniqueness_collisions), where rounding would
report collisions the exact-valued engine wouldn't; FK-key matching and
EXPLAIN-SQL literals likewise stay exact.

ADR-0005 Amendment 1; +7 tests.
This commit is contained in:
claude@clouddev1
2026-06-12 14:42:22 +00:00
parent 7e4bc122be
commit 3d4a0fd45e
4 changed files with 244 additions and 2 deletions
+62
View File
@@ -70,3 +70,65 @@ True UUIDs are intentionally **not** in the type set.
- Learners who later need a true UUID column will find that the
app does not provide one; this is a deliberate trade-off in
favour of TUI legibility.
## Amendment 1 — display rounding of coerced doubles (2026-06-12)
Issue #32. The Decision keeps `decimal` exact by storing it as
TEXT, noting that "numeric ops require casts" — the engine has no
native decimal/BCD type (SQLite's storage classes are only NULL /
INTEGER / REAL / TEXT / BLOB; `NUMERIC` is an affinity, not a
type). What the original wording did not anticipate is that the
engine performs that cast **implicitly**: `sum(price * qty)` over
TEXT decimals coerces to an IEEE-754 double with no explicit cast,
and the computed result carries no playground type (ADR-0030 §6),
so it rendered with the double's full noise —
`298.59999999999997` for `298.60`. For a teaching tool that is a
confusing, off-topic lesson about float representation.
### Decision
**Round floating-point values to 15 significant figures for
display only.** A double carries ~1517 significant decimal digits
and the noise lives in the last one or two; rounding to 15 then
taking the shortest round-tripping form of the rounded value
collapses `298.59999999999997``298.6` and
`0.30000000000000004``0.3`. A clean value rounds to itself, so
the result is never longer than before; non-finite values pass
through. Implemented as `format_real_display` in `db.rs`.
The rounding is wired into **exactly one place — `format_cell`,
the result-set / `show data` cell formatter** — because that is
the only surface where the IEEE-754 noise actually appears: noise
arises from *arithmetic/aggregation*, whose results flow through
`format_cell`. Every other `f64`-to-string path deliberately keeps
full precision, and the distinction is **semantic, not cosmetic**:
- **Persistence stays exact.** The CSV encoder
(`persistence::csv_io::format_real`) keeps the shortest
round-tripping form so a stored `real` survives save/load
byte-for-byte — rounding there would corrupt data.
- **Uniqueness dry-runs key on exact values.** `render_value`
(the diagnostic/echo formatter) is reused as a *canonical
identity key* by `dry_run_unique` (ADR-0029 §5) and
`check_uniqueness_collisions` (ADR-0017 §4.3): they group rows
by this string to predict the duplicates the engine would
reject. Rounding there would merge two distinct doubles into one
key and report a collision the engine — which compares exact
values — would not. So `render_value` keeps `format!("{r}")`.
(It also never displays a *computed* value, so it has no noise
to trim.)
- **FK-key matching and EXPLAIN-SQL literals keep full
precision** — neither is a data-cell display.
Within `format_cell` the rounding applies to **all** REAL cells
(stored `real` columns and computed results alike), for one
consistent rule; the lost digits are at the double's precision
limit, not real information, and a stored `real` typed by the user
is itself noise-free so its display is unchanged in practice. Raw
`decimal` columns are unaffected — they are TEXT and render
verbatim, trailing zeros and all (`100.10`). Exact decimal
*arithmetic* (a SQLite extension exposing
`decimal_mul`/`decimal_sum`) was considered and rejected: it would
require rewriting the user's standard-SQL operators into function
calls, defeating both the "validated SQL runs verbatim" model and
the goal of teaching ordinary SQL.
+1 -1
View File
@@ -10,7 +10,7 @@ This directory contains the project's ADRs, recorded per
- [ADR-0002 — Database engine](0002-database-engine.md)
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11)
- [ADR-0004 — Project file format](0004-project-file-format.md)
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md)
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md) — the ten-type set (`text`/`int`/`real`/`decimal`/`bool`/`date`/`datetime`/`blob`/`serial`/`shortid`), compound PKs, no true UUIDs; `decimal` stored as exact TEXT. **Amendment 1, 2026-06-12** (issue #32): SQLite has no native decimal/BCD type, so arithmetic/aggregation over a TEXT `decimal` is implicitly coerced to an IEEE-754 double and the computed (typeless) result leaked float noise (`298.59999999999997` for `298.60`); floating-point values are now rounded to **15 significant figures for display only** (`format_real_display` in `db.rs`, wired into `format_cell` — the result-set/`show data` cell formatter, the only surface where arithmetic noise surfaces) while every other f64→string path keeps full precision because the distinction is *semantic*: persistence (`csv_io::format_real`) stays byte-exact for round-trip; `render_value` is a *canonical identity key* for the uniqueness dry-runs (`dry_run_unique` ADR-0029 §5, `check_uniqueness_collisions` ADR-0017 §4.3) so rounding it would report collisions the exact-valued engine wouldn't; FK-key matching and EXPLAIN-SQL literals likewise stay exact — so stored `real`/`decimal` round-trips stay byte-exact and raw `decimal` columns render verbatim
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite`
- [ADR-0007 — Sharing and export](0007-sharing-and-export.md)
- [ADR-0008 — Testing approach](0008-testing-approach.md)