Skip to content

fix(query): stop corrupting integer-valued doubles read through the overlay#1355

Merged
bplatz merged 3 commits into
mainfrom
fix/integer-float-index-corruption
Jun 20, 2026
Merged

fix(query): stop corrupting integer-valued doubles read through the overlay#1355
bplatz merged 3 commits into
mainfrom
fix/integer-float-index-corruption

Conversation

@bplatz

@bplatz bplatz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

An integer-valued double (e.g. 55000.0) inserted into a xsd:double/xsd:float predicate that already has indexed data was silently read back as a tiny subnormal (2.71736e-319). No error, deterministic, and it surfaces in queries and aggregates (SUM over the column reads ≈ 0).

Root cause

The novelty→overlay encoder value_to_otype_okey (used when reading novelty over an indexed predicate) optimized integer-valued doubles to encode_i64, but paired the key with the datatype-derived OType (XSD_DOUBLE/XSD_FLOAT), whose decode kind is F64. The reader then ran decode_f64 over the encode_i64 bits:

decode_f64(encode_i64(55000)) == f64::from_bits(55000) == 2.71736e-319   (ratio 2^-1074)

This is the same anti-pattern already guarded at three encode-side sites (resolver.rs, import_sink.rs); the read-side overlay was the one path that still had it.

Scope / impact

  • Triggers only when the predicate has a persisted index; novelty-only ledgers encode correctly.
  • The persisted commits were always correct — corruption was purely on the read path. Affected data is recoverable with the fixed reader; no re-ingestion required.
  • Non-integer doubles (55000.5) and integer types were never affected.

Changes

  • binary_scan.rsvalue_to_otype_okey always encodes finite doubles with encode_f64.
  • dict_overlay.rs — same guard applied to the unused twin value_to_obj_pair (defensive).
  • it_decimal_exactness.rs — regression test: index a double, insert an integer-valued double, assert exact round-trip. Verified it fails without the fix with the exact 2.71736e-319 signature.

Testing

  • New regression test passes; fails when the fix is reverted.
  • fluree-db-query unit (1078) + decimal exactness (14), batched-overlay (4), aggregates (16), SPARQL (136) all green. fmt + clippy clean.

…verlay

When a double/float predicate already has indexed data, a newly inserted
integer-valued double (e.g. 55000.0) was read back as a tiny subnormal
(2.71736e-319). The novelty overlay encoder optimized integral doubles to
encode_i64 but paired the key with the datatype-derived OType (XSD_DOUBLE/
XSD_FLOAT), whose decode kind is F64 — so the reader ran decode_f64 over the
i64-encoded bits, i.e. f64::from_bits(N).

The persisted commits were always correct; the corruption was purely on the
read path, so affected data is recoverable without re-ingestion.

value_to_otype_okey now always encodes finite doubles with encode_f64,
matching the encode-side guards already in resolver.rs and import_sink.rs.
The unused twin value_to_obj_pair gets the same guard defensively. Adds a
regression test that indexes a double, inserts an integer-valued double, and
asserts an exact round-trip.
@bplatz bplatz requested review from aaj3f and zonotope June 18, 2026 17:44

@aaj3f aaj3f left a comment

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.

Good catch on this 👍

It seems after this PR's work, DictOverlay::value_to_obj_pair is unused anywhere but is not tracking as dead code because it's pub. If we want to keep it just in case, perhaps we at least downgrade to pub(crate) with #[cfg(test)] coverage or with some comment like // retained for parity with value_to_otype_okey

Also, I was also just reviewing #1335 but that PR sustains the bugs that you've fixed in this PR. It's probably worth making sure that this PR lands first and (if you make the test additions I suggested in the review comments below), then in #1335 you can (a) have test-guards that will keep the issue/bug from persisting in that PR and also (b) you can have some test-driven requirements for the rework you'll want to do in that branch's it_decimal_exactness.rs code.

Comment on lines +893 to +965
#[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 a NEW integer-valued double into the now-indexed predicate;
// it lands in novelty and is read back through the overlay merge.
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 . }
"#,
)
.await;
let ledger = result.ledger;

let query = r"
PREFIX ex: <http://example.org/>
SELECT ?amount WHERE { ex:a 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, "expected exactly one row, got {values:?}");
let got: f64 = values[0].parse().expect("double result");
assert_eq!(
got, 55000.0,
"integer-valued double over an indexed predicate must round-trip \
exactly (was corrupted to a subnormal), got {got:e}"
);
})
.await;
}

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.

Comment thread fluree-db-query/src/dict_overlay.rs Outdated
Comment on lines 491 to 495

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.

Given that d.is_finite() will guard against conditions like NaN/Inf, the Err(_) arm is effectively unreachable. It may just be worth a comment if nothing else to make it clear that Ok((ObjKind::NULL, ObjKey::from_u64(0))) is never reachable/returnable for a truly finite d

bplatz added 2 commits June 19, 2026 21:20
Address review feedback on the integer-valued-double overlay fix:

- it_decimal_exactness: assert the returned binding stays xsd:double
  (no silent downgrade to integer/long) and add boundary companions
  -55000.0 (sign-flip branch) and 2^53 (largest exact integral double).
- dict_overlay: value_to_obj_pair is now pub(crate) + #[expect(dead_code)]
  with a note explaining it is kept for parity with value_to_otype_okey.
- binary_scan / dict_overlay: drop the redundant is_finite() pre-check in
  the Double arm; encode_f64 already rejects NaN/Inf, so its Err arm is the
  single reachable "unrepresentable double -> NULL sentinel" path.
@bplatz bplatz force-pushed the fix/integer-float-index-corruption branch from 2fbbc20 to b473e09 Compare June 20, 2026 01:22
@bplatz

bplatz commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

@aaj3f addressed in 15ea8d9

@bplatz bplatz merged commit 6ba2b3e into main Jun 20, 2026
14 checks passed
@bplatz bplatz deleted the fix/integer-float-index-corruption branch June 20, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants