Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions fluree-db-api/tests/it_decimal_exactness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ fn binding_values(sparql_json: &JsonValue, var: &str) -> Vec<String> {
.collect()
}

fn binding_datatypes(sparql_json: &JsonValue, var: &str) -> Vec<String> {
sparql_json["results"]["bindings"]
.as_array()
.expect("bindings array")
.iter()
.map(|b| {
b[var]["datatype"]
.as_str()
.expect("binding datatype string")
.to_string()
})
.collect()
}

fn memory_fluree() -> MemoryFluree {
assert_index_defaults();
FlureeBuilder::memory().build_memory()
Expand Down Expand Up @@ -889,3 +903,106 @@ async fn sparql_delete_data_decimal_retracts_exactly() {
"deleted decimal fact must not survive"
);
}

#[tokio::test]
async fn integer_valued_double_over_indexed_predicate_is_not_corrupted() {
// Regression (fluree/db-r#142): an integer-valued double inserted into a
// predicate that already has INDEXED double/float data was silently
// corrupted to a tiny subnormal. The novelty overlay encoder paired the
// datatype-derived OType (F64 decode) with an i64-encoded key, so the
// reader ran decode_f64 over integer bits (55000.0 -> 2.71736e-319).
// The trigger needs a persisted index for the predicate; novelty-only
// ledgers encode the value correctly.
let fluree = FlureeBuilder::memory()
.with_ledger_cache_config(fluree_db_api::LedgerManagerConfig::default())
.build_memory();
let ledger_id = "double/indexed-overlay:main";

let (local, handle) = start_background_indexer_local(
fluree.backend().clone(),
Arc::new(fluree.nameservice_mode().clone()),
fluree_db_indexer::IndexerConfig::small(),
);

local
.run_until(async move {
let ledger = genesis_ledger(&fluree, ledger_id);

// Seed + index an integer-valued double for ex:amount.
let result = run_sparql_update(
&fluree,
ledger,
r#"
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
INSERT DATA { ex:seed ex:amount "28575.0"^^xsd:double . }
"#,
)
.await;
trigger_index_and_wait(&handle, ledger_id, result.receipt.t).await;
let ledger = fluree.ledger(ledger_id).await.expect("load ledger");

// Insert NEW integer-valued doubles into the now-indexed predicate;
// they land in novelty and are read back through the overlay merge.
// Boundary companions exercise the encode_f64/decode_f64 sign-flip
// branch (-55000.0) and the i64-range edge (2^53, the largest
// exactly-representable integral double).
let result = run_sparql_update(
&fluree,
ledger,
r#"
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
INSERT DATA {
ex:a ex:amount "55000.0"^^xsd:double .
ex:b ex:amount "-55000.0"^^xsd:double .
ex:c ex:amount "9.007199254740992e15"^^xsd:double .
}
"#,
)
.await;
let ledger = result.ledger;

// (subject, expected exact f64) for each inserted integral double.
let cases = [
("ex:a", 55000.0),
("ex:b", -55000.0),
("ex:c", 9.007_199_254_740_992e15),
];
for (subject, expected) in cases {
let query = format!(
"PREFIX ex: <http://example.org/>
SELECT ?amount WHERE {{ {subject} ex:amount ?amount . }}"
);
let result = support::query_sparql(&fluree, &ledger, &query)
.await
.expect("query");
let sparql_json = result
.to_sparql_json(&ledger.snapshot)
.expect("to_sparql_json");

let values = binding_values(&sparql_json, "amount");
assert_eq!(
values.len(),
1,
"{subject}: expected exactly one row, got {values:?}"
);
let got: f64 = values[0].parse().expect("double result");
assert_eq!(
got, expected,
"{subject}: integer-valued double over an indexed predicate must \
round-trip exactly (was corrupted to a subnormal), got {got:e}"
);

// Lock in that the uniform-f64 encoding does not silently downgrade
// the reported datatype to xsd:integer/xsd:long.
let datatypes = binding_datatypes(&sparql_json, "amount");
assert_eq!(
datatypes,
vec!["http://www.w3.org/2001/XMLSchema#double".to_string()],
"{subject}: datatype must stay xsd:double"
);
}
})
.await;
}
Comment on lines +907 to +1008

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is good for the bug that prompted this PR. Just small additional suggestions:

  1. Also assert the datatype of the returned binding is xsd:double (not silently downgraded to xsd:integer or xsd:long), to lock in that uniform-f64 encoding doesn't change the reported type.
  2. Add a negative/boundary companion value (e.g. -55000.0 and a large integral double like 9.007199254740992e15) in the same indexed predicate to cover the sign-flip branch of encode_f64/decode_f64 and the i64-range edge. Not required for the fix, but cheap insurance.

23 changes: 10 additions & 13 deletions fluree-db-query/src/binary_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2586,19 +2586,16 @@ fn value_to_otype_okey(
"datatype not resolvable to OType for Double value",
)
})?;
if d.is_finite() && d.fract() == 0.0 {
let as_i64 = *d as i64;
if (as_i64 as f64) == *d {
return Ok((ot, ObjKey::encode_i64(as_i64).as_u64()));
}
}
if d.is_finite() {
match ObjKey::encode_f64(*d) {
Ok(key) => Ok((ot, key.as_u64())),
Err(_) => Ok((OType::NULL, 0)),
}
} else {
Ok((OType::NULL, 0))
// Do NOT optimize integral doubles to encode_i64: `ot` is the
// datatype-derived OType (e.g. XSD_DOUBLE), whose decode kind is F64.
// Pairing it with an i64-encoded key makes the reader run decode_f64
// over integer bits, corrupting the value to a tiny subnormal
// (55000.0 -> 2.71736e-319). Mirrors the encode-side guards in
// resolver.rs / import_sink.rs. (fluree/db-r#142)
match ObjKey::encode_f64(*d) {
Ok(key) => Ok((ot, key.as_u64())),
// NaN/Inf can't be order-encoded → NULL sentinel.
Err(_) => Ok((OType::NULL, 0)),
}
}
FlakeValue::Ref(sid) => {
Expand Down
33 changes: 17 additions & 16 deletions fluree-db-query/src/dict_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,28 +476,29 @@ impl DictOverlay {
///
/// Unlike `BinaryIndexStore::value_to_obj_pair()`, this never returns `None`
/// for representable values.
pub fn value_to_obj_pair(&mut self, val: &FlakeValue) -> io::Result<(ObjKind, ObjKey)> {
///
/// Kept for: parity with the live `value_to_otype_okey` encoder in
/// `binary_scan.rs` — both must apply the same integral-double guard so a
/// future overlay write path can reuse this twin without reintroducing the
/// subnormal corruption (fluree/db-r#142).
/// Use when: a `DictOverlay`-based write path needs (ObjKind, ObjKey) pairs.
#[expect(dead_code)]
pub(crate) fn value_to_obj_pair(&mut self, val: &FlakeValue) -> io::Result<(ObjKind, ObjKey)> {
match val {
FlakeValue::Null => Ok((ObjKind::NULL, ObjKey::from_u64(0))),
FlakeValue::Boolean(b) => Ok((ObjKind::BOOL, ObjKey::encode_bool(*b))),
FlakeValue::Long(n) => Ok((ObjKind::NUM_INT, ObjKey::encode_i64(*n))),

FlakeValue::Double(d) => {
// Integer-valued doubles that fit i64 → NUM_INT
if d.is_finite() && d.fract() == 0.0 {
let as_i64 = *d as i64;
if (as_i64 as f64) == *d {
return Ok((ObjKind::NUM_INT, ObjKey::encode_i64(as_i64)));
}
}
if d.is_finite() {
match ObjKey::encode_f64(*d) {
Ok(key) => Ok((ObjKind::NUM_F64, key)),
Err(_) => Ok((ObjKind::NULL, ObjKey::from_u64(0))),
}
} else {
// NaN/Inf → NULL sentinel (can't represent in index)
Ok((ObjKind::NULL, ObjKey::from_u64(0)))
// Do NOT optimize integral doubles to NUM_INT: when paired with a
// float/double datatype the decode resolves an F64 OType and runs
// decode_f64 over the i64-encoded bits, corrupting the value to a
// tiny subnormal. Mirrors value_to_otype_okey and the encode-side
// guards in resolver.rs / import_sink.rs. (fluree/db-r#142)
match ObjKey::encode_f64(*d) {
Ok(key) => Ok((ObjKind::NUM_F64, key)),
// NaN/Inf can't be order-encoded → NULL sentinel.
Err(_) => Ok((ObjKind::NULL, ObjKey::from_u64(0))),
}
}

Expand Down
Loading