Skip to content

Commit c78b7a4

Browse files
committed
docs/test(v3): float review follow-ups — dyadic fixture invariant, NaN caveat, order tripwire
- eql-scalars: correct the FLOAT4_FIXTURES comment — the values are dyadic rationals (n/2^k), not 'powers of two and halves'; add a keep-it-dyadic warning so a non-f32-exact fixture (e.g. 0.1) can't silently desync the oracle. - eql-types: document NaN/-0.0/+-Inf special-value behaviour on the float8 module (and point float4 at it) — NaN is never rejected server-side, so the caller-facing 'reject NaN client-side' guidance lives next to the types. - float_special: add nan_order_position_is_deterministic_and_total, a tripwire locking the total+deterministic order the Block-ORE index relies on (without pinning NaN's unspecified direction). - Drop the dangling reference to the removed U-001 upgrade note.
1 parent f299ecd commit c78b7a4

4 files changed

Lines changed: 77 additions & 2 deletions

File tree

crates/eql-scalars/src/lib.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,12 @@ pub const TEXT: ScalarSpec = ScalarSpec {
507507

508508
/// `float4` fixture plaintexts — IEEE-754 strings parsed into `f32` in the SQLx
509509
/// harness (the catalog stays zero-dep). EVERY value is exactly representable in
510-
/// f32 (powers of two and halves), so the `real` round-trip is lossless and the
511-
/// f32→f64 widening before encryption is exact. The three pivots MUST be present
510+
/// f32 — each is a dyadic rational `n/2^k` (e.g. `2.25 = 9/4`, `0.25 = 1/4`,
511+
/// `1024 = 2^10`), the value class `real` stores losslessly — so the `real`
512+
/// round-trip is lossless and the f32→f64 widening before encryption is exact.
513+
/// Keep new fixtures dyadic: a value like `0.1` is NOT f32-exact, and the
514+
/// oracle's expected order (parsed `f32`) would then disagree with the value the
515+
/// `real` column actually rounds to. The three pivots MUST be present
512516
/// verbatim: `"-inf"` (min_pivot), `"0"` (origin/mid), `"inf"` (max_pivot).
513517
/// NaN and `-0.0` are deliberately excluded (see the `float_special` suite).
514518
/// Distinctness is enforced by `Fixture::Float` (above) and its guard test.

crates/eql-types/src/v3/float4.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
//! wire shape here is identical to [`crate::v3::float8`] — an 8-block `ob` term
99
//! (`f64::ENCODED_LEN == 8`, same as `int8`). `float4` vs `float8` is purely a
1010
//! Postgres-surface distinction (column type, domain name).
11+
//!
12+
//! Special-value behaviour (`-0.0`, `±Inf`, and the **NaN is not rejected
13+
//! server-side — reject it client-side** caveat) is identical to `float8`; see
14+
//! [`crate::v3::float8`] for the full note.
1115
1216
use schemars::{schema::RootSchema, schema_for};
1317

crates/eql-types/src/v3/float8.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@
77
//! (`Plaintext::Float`), so the wire shape is identical to
88
//! [`crate::v3::float4`] — an 8-block `ob` term (`f64::ENCODED_LEN == 8`, same
99
//! as `int8`).
10+
//!
11+
//! ## Special values (caller-facing)
12+
//!
13+
//! `-0.0` canonicalizes to `+0.0` (equal under `=`, IEEE-consistent) and
14+
//! `±Inf` order correctly (`-Inf < finite < +Inf`). **NaN is unordered and
15+
//! unspecified in the encoder**: it can be encrypted, stored, and pass the
16+
//! domain CHECK, but it carries **no comparison guarantee** and does NOT follow
17+
//! IEEE semantics (where NaN compares false against everything). The domain
18+
//! CHECK validates only the envelope — it cannot inspect the ciphertext — so a
19+
//! NaN payload is never rejected server-side. **Reject NaN client-side before
20+
//! encryption** if your column must not contain it; otherwise a NaN row sorts
21+
//! at an arbitrary (but deterministic) position in an encrypted range scan
22+
//! rather than being excluded the way native Postgres `double precision` would.
23+
//! See the `float_special` regression suite for the locked behaviour.
1024
1125
use schemars::{schema::RootSchema, schema_for};
1226

tests/sqlx/tests/encrypted_domain/float_special.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,56 @@ async fn infinities_order_correctly() -> Result<()> {
130130
assert!(ord_cmp(&pool, &p[0], "<", &p[2]).await?, "-Inf < +Inf");
131131
Ok(())
132132
}
133+
134+
#[tokio::test]
135+
async fn nan_order_position_is_deterministic_and_total() -> Result<()> {
136+
// TRIPWIRE for encoder drift — NOT a direction guarantee.
137+
//
138+
// NaN is "unordered and unspecified" by design, so we deliberately do NOT
139+
// pin WHERE NaN sorts relative to finite / ±Inf values (that position is an
140+
// encoder artifact and may change). But the Block-ORE index the `_ord`
141+
// domain rides on requires a *total, deterministic* order: the same
142+
// plaintext must always land at the same position, and every pair must
143+
// resolve to exactly one of `<` / `=` / `>`. If a future encoder change
144+
// makes NaN's position non-deterministic (same bits, different sort slot ->
145+
// btree corruption) or non-total (a comparison that follows IEEE and returns
146+
// false both ways), this fails loudly. The NaN==NaN equality artifact is
147+
// locked separately in `two_encryptions_of_same_nan_bits_compare_equal`;
148+
// this guards the ORDER side of the same deterministic-terms property.
149+
let pool = setup().await?;
150+
// Two independent encryptions of canonical NaN, plus a spread of references.
151+
let p = encrypt_specials(&[
152+
F8(f64::NAN), // 0: NaN (encryption A)
153+
F8(f64::NAN), // 1: NaN (encryption B)
154+
F8(f64::NEG_INFINITY), // 2
155+
F8(0.0), // 3
156+
F8(f64::INFINITY), // 4
157+
])
158+
.await?;
159+
let (nan_a, nan_b) = (&p[0], &p[1]);
160+
161+
for (label, r) in [("-Inf", &p[2]), ("0", &p[3]), ("+Inf", &p[4])] {
162+
let lt = ord_cmp(&pool, nan_a, "<", r).await?;
163+
let eq = ord_cmp(&pool, nan_a, "=", r).await?;
164+
let gt = ord_cmp(&pool, nan_a, ">", r).await?;
165+
// Totality: exactly one of < = > holds (NaN is NOT IEEE-incomparable here).
166+
assert_eq!(
167+
[lt, eq, gt].iter().filter(|b| **b).count(),
168+
1,
169+
"NaN vs {label} is not a total order: (<, =, >) = ({lt}, {eq}, {gt})"
170+
);
171+
// Determinism: a second independent NaN encryption lands identically.
172+
let (lt_b, eq_b, gt_b) = (
173+
ord_cmp(&pool, nan_b, "<", r).await?,
174+
ord_cmp(&pool, nan_b, "=", r).await?,
175+
ord_cmp(&pool, nan_b, ">", r).await?,
176+
);
177+
assert_eq!(
178+
(lt, eq, gt),
179+
(lt_b, eq_b, gt_b),
180+
"NaN's order position vs {label} is not stable across re-encryption \
181+
(deterministic index terms broken)"
182+
);
183+
}
184+
Ok(())
185+
}

0 commit comments

Comments
 (0)