Skip to content

NUMERIC values in (-1, 0) lose their sign in Display (e.g. -0.5 renders as 0.5) #84

@StefanSteiner

Description

@StefanSteiner

Summary

impl fmt::Display for Numeric drops the minus sign for any NUMERIC/DECIMAL value with magnitude < 1 (the open interval (-1, 0)). The internal value and binary decode are correct; only the stringification is wrong.

This surfaces through the HyperDB MCP query tool, which serializes NUMERIC via to_string() then parse::<f64>(), so a query like CAST(-0.5 AS numeric(10,4)) returns JSON 0.5 instead of -0.5. This silently flips the sign of correlations, 0–1 indices, and regression residuals.

Reproduction

CAST(-0.5   AS numeric(10,4))  -- returns  0.5   (BUG, expected -0.5)
CAST(-0.999 AS numeric(10,4))  -- returns  0.999 (BUG, expected -0.999)
CAST(-1.5   AS numeric(10,4))  -- returns -1.5   (OK, |x| >= 1)
CAST(-0.5   AS double precision) -- returns -0.5 (OK, float path)

Equivalent unit-level repro: Numeric::new(-5000, 4).to_string() yields "0.5000".

Root cause

hyperdb-api-core/src/types/special.rs, impl fmt::Display for Numeric (lines ~1223-1240):

let divisor = 10i128.pow(u32::from(self.scale));
let int_part = self.value / divisor;          // -5000 / 10000 == 0  (no sign on 0)
let frac_part = (self.value % divisor).abs();  // sign stripped by .abs()
write!(f, "{}.{:0width$}", int_part, frac_part, width = self.scale as usize)

When |value| < divisor, int_part == 0 (prints without a sign) and frac_part is .abs()'d, so the sign vanishes. For |value| >= 1 the non-zero int_part carries the -, which is why those cases work.

This is a bug in the Hyper Rust API, not in hyperd — the server transmits the correct unscaled value (-5000, scale 4) and the decode is correct.

Blast radius

Read/stringification only. to_f64() is correct, and the binary write path uses encode(). There is no ToSqlParam for Numeric, so INSERTs are not affected — this is a display-layer bug, not data corruption.

Also note a related gotcha: a bare ::numeric cast truncates to scale 0 (rounds to integer), e.g. 0.859::numeric -> 1; specify precision/scale to avoid it.

Proposed fix

Compute the sign explicitly and format the magnitude with unsigned_abs() (also removes a latent i128::MIN .abs() panic):

} else {
    let divisor = 10u128.pow(u32::from(self.scale));
    let sign = if self.value < 0 { "-" } else { "" };
    let abs = self.value.unsigned_abs();
    let int_part = abs / divisor;
    let frac_part = abs % divisor;
    write!(f, "{sign}{int_part}.{frac_part:0width$}", width = self.scale as usize)
}

Plus regression tests for -0.5, -0.999, -1.5, 0, and scale-0 negatives, and a CHANGELOG entry under hyperdb-api-core.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions