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:
@@ -6041,6 +6041,16 @@ fn render_value(v: &rusqlite::types::Value) -> String {
|
||||
match v {
|
||||
RV::Null => "(null)".to_string(),
|
||||
RV::Integer(i) => i.to_string(),
|
||||
// Full-precision, shortest round-trip — NOT the issue-#32
|
||||
// display rounding. `render_value` is reused as a *canonical
|
||||
// identity key* by the uniqueness dry-runs (`dry_run_unique`,
|
||||
// `check_uniqueness_collisions`, ADR-0017 §4.3 / ADR-0029 §5):
|
||||
// they group rows by this string to detect duplicates the
|
||||
// engine would reject. Rounding here would merge two distinct
|
||||
// doubles into one key and report a collision the engine —
|
||||
// which compares exact values — would not. Display rounding
|
||||
// lives in `format_cell` (query / `show data` cells) only,
|
||||
// where no value is ever used as a key.
|
||||
RV::Real(r) => format!("{r}"),
|
||||
RV::Text(s) => s.clone(),
|
||||
RV::Blob(_) => "<blob>".to_string(),
|
||||
@@ -10809,12 +10819,49 @@ fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String
|
||||
} else {
|
||||
i.to_string()
|
||||
}),
|
||||
V::Real(r) => Some(format!("{r}")),
|
||||
V::Real(r) => Some(format_real_display(r)),
|
||||
V::Text(s) => Some(s),
|
||||
V::Blob(b) => Some(format!("<blob {} bytes>", b.len())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a floating-point value for **display**, trimming the
|
||||
/// IEEE-754 noise that surfaces when the engine coerces a
|
||||
/// `decimal` (stored as exact TEXT, ADR-0005) to a double for
|
||||
/// arithmetic or aggregation (issue #32): `sum(price * qty)`
|
||||
/// would otherwise show `298.59999999999997` for `298.60`.
|
||||
///
|
||||
/// A double carries ~15–17 significant decimal digits and the
|
||||
/// noise lives in the last one or two, so we round to 15
|
||||
/// significant figures and then take the *shortest* round-tripping
|
||||
/// form of the rounded value. That collapses
|
||||
/// `298.59999999999997` → `298.6` and `0.30000000000000004` →
|
||||
/// `0.3`. A clean value rounds to itself, so the result is never
|
||||
/// longer than the previous `format!("{r}")` — the magnitude and
|
||||
/// whatever digit expansion `Display` already chose are preserved
|
||||
/// (Rust's `Display` for `f64` never uses scientific notation, so
|
||||
/// a very large value still expands to full digits, exactly as
|
||||
/// before). Non-finite values pass through unchanged.
|
||||
///
|
||||
/// DISPLAY ONLY, and only for genuine display cells. The CSV
|
||||
/// encoder (`persistence::csv_io::format_real`) keeps the exact
|
||||
/// shortest round-trip so a stored `real` survives save/load
|
||||
/// byte-for-byte; the uniqueness dry-runs key on the full-precision
|
||||
/// `render_value`; FK-key matching and EXPLAIN-SQL literals keep
|
||||
/// full precision too. Rounding any of those would change behaviour,
|
||||
/// not just looks. This is wired only into `format_cell`.
|
||||
fn format_real_display(r: f64) -> String {
|
||||
if !r.is_finite() {
|
||||
return format!("{r}");
|
||||
}
|
||||
// `{:.14e}` is a 15-significant-figure scientific form (one
|
||||
// mantissa digit + 14 after the point); parsing it back and
|
||||
// letting `Display` pick the shortest exact form drops the
|
||||
// trailing IEEE-754 noise.
|
||||
let rounded: f64 = format!("{r:.14e}").parse().unwrap_or(r);
|
||||
format!("{rounded}")
|
||||
}
|
||||
|
||||
/// Capture the query plan for an explainable command
|
||||
/// (ADR-0028 §2). Matches the inner command, builds the exact
|
||||
/// SQL it would otherwise run via the shared `build_*_sql`
|
||||
@@ -11442,6 +11489,78 @@ mod tests {
|
||||
Database::open(":memory:").expect("open in-memory")
|
||||
}
|
||||
|
||||
// ---- Issue #32 — display rounding of coerced doubles ----
|
||||
|
||||
#[test]
|
||||
fn format_real_display_trims_ieee754_noise() {
|
||||
// The reported case and the classic float artifacts.
|
||||
assert_eq!(format_real_display(298.599_999_999_999_97), "298.6");
|
||||
assert_eq!(format_real_display(0.1 + 0.2), "0.3");
|
||||
assert_eq!(format_real_display(59.97), "59.97");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_real_display_keeps_whole_numbers_and_sign() {
|
||||
assert_eq!(format_real_display(5.0), "5");
|
||||
assert_eq!(format_real_display(0.0), "0");
|
||||
assert_eq!(format_real_display(-298.599_999_999_999_97), "-298.6");
|
||||
assert_eq!(format_real_display(1.5e-12), "0.0000000000015");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_real_display_preserves_clean_values_no_regression() {
|
||||
// A value with no IEEE-754 noise rounds to itself, so the
|
||||
// helper is never *worse* (or longer) than the previous
|
||||
// `format!("{r}")` — including a very large magnitude, which
|
||||
// `Display` expands to full digits both before and after.
|
||||
for v in [1e300_f64, 1.25_f64, 1.5e-12_f64, 42.0_f64, -0.5_f64] {
|
||||
assert_eq!(
|
||||
format_real_display(v),
|
||||
format!("{v}"),
|
||||
"clean value {v} must format exactly as before",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_value_keeps_full_precision_for_identity_grouping() {
|
||||
// DA guard (issue #32): `render_value` must NOT adopt the
|
||||
// display rounding — the uniqueness dry-runs key on its output
|
||||
// to detect duplicates the engine (which compares exact
|
||||
// values) would reject. Two adjacent doubles that agree to 15
|
||||
// significant figures must still render to *distinct* strings,
|
||||
// or `dry_run_unique` / `check_uniqueness_collisions` would
|
||||
// report a false collision.
|
||||
use rusqlite::types::Value as V;
|
||||
let a = 1.0_f64;
|
||||
let b = 1.000_000_000_000_000_2_f64; // 1.0.next_up()
|
||||
assert_ne!(a, b, "the two doubles are genuinely distinct");
|
||||
assert_eq!(render_value(&V::Real(a)), "1");
|
||||
assert_eq!(render_value(&V::Real(b)), "1.0000000000000002");
|
||||
assert_ne!(
|
||||
render_value(&V::Real(a)),
|
||||
render_value(&V::Real(b)),
|
||||
"render_value must keep distinct doubles distinct (identity key)",
|
||||
);
|
||||
// And the display formatter *does* round both to the same
|
||||
// string — which is exactly why it must not be used as a key.
|
||||
assert_eq!(format_real_display(a), format_real_display(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_real_display_passes_through_non_finite() {
|
||||
assert_eq!(format_real_display(f64::INFINITY), "inf");
|
||||
assert_eq!(format_real_display(f64::NEG_INFINITY), "-inf");
|
||||
assert_eq!(format_real_display(f64::NAN), "NaN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_real_display_rounds_to_fifteen_significant_figures() {
|
||||
// 1/3 collapses to 15 sig figs (the noise past that is the
|
||||
// double's representation limit, not real precision).
|
||||
assert_eq!(format_real_display(1.0 / 3.0), "0.333333333333333");
|
||||
}
|
||||
|
||||
fn col(name: &str, ty: Type) -> ColumnSpec {
|
||||
ColumnSpec::new(name, ty)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user