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.
Summary
impl fmt::Display for Numericdrops 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
querytool, which serializes NUMERIC viato_string()thenparse::<f64>(), so a query likeCAST(-0.5 AS numeric(10,4))returns JSON0.5instead of-0.5. This silently flips the sign of correlations, 0–1 indices, and regression residuals.Reproduction
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):When
|value| < divisor,int_part == 0(prints without a sign) andfrac_partis.abs()'d, so the sign vanishes. For|value| >= 1the non-zeroint_partcarries 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 usesencode(). There is noToSqlParam for Numeric, so INSERTs are not affected — this is a display-layer bug, not data corruption.Also note a related gotcha: a bare
::numericcast 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 latenti128::MIN.abs()panic):Plus regression tests for
-0.5,-0.999,-1.5,0, and scale-0 negatives, and a CHANGELOG entry underhyperdb-api-core.