diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ebcb12dc..d5d7fcac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A rule call with the float `0.0` returns the row of `-0.0` in every run** (#750), backported from v3.0.0 (#545). `0.0 == -0.0`, as patterns and predicates compare floats, but `Value`'s hash gave the two different hashes, so a join through a hash table paired them only when the process's random hash seed happened to: `[:find ?e :where (xval ?e 0.0)]` for a rule `(xval ?e ?v)` returned the entity whose value is `-0.0` in about one run in twelve and left it out otherwise, with no error. `-0.0` and `0.0` now hash alike, so the rule call returns both rows, as `[?e :m/x 0.0]` does. Every other float hashes as before. Nothing stored was wrong. - **An expression's `=` and `!=` equate a keyword with a ref to the entity it names** (#744), backported from v3.0.0. Patterns and joins treat the keyword `:bob` as equal to a ref to the entity `:bob` names, but `[(= ?g :bob)]` compared the two values as they are, so with `?g` bound to that ref it kept no row and `[(!= ?g :bob)]` kept the row: the same question answered differently by how the query was written, with no error. `=` and `!=` now compare as joins do, in queries, rule bodies, `not` clauses and binding expressions, against a keyword constant, a keyword value or a bound `Val(Ref)`. A keyword still equals only the same keyword, and `<`, `>`, `<=` and `>=` on a keyword or a ref are still a type mismatch (no row). - **`checkpoint` inside a `WriteTransaction` returns `INT-001` instead of deadlocking** (#734), backported from v3.0.0. Called on a thread that holds a `WriteTransaction`, `checkpoint` waited on the write lock that the transaction already held, so the thread hung. It now returns `INT-001`, as `execute` and `begin_write` already did, and the transaction stays usable. `prepare` and `PreparedQuery::execute` make the same check: they could not deadlock, but they ran inside the transaction while `execute` refused. Present since `WriteTransaction` was added. diff --git a/CLAUDE.md b/CLAUDE.md index 1cfebd61a..fc071ff9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ Auto-migrates v1/v2/v3/v4/v5/v6 → v7 on open/checkpoint. ## Test Coverage -**1334 tests** (1325 passing, 9 ignored; unit + integration + doc). +**1335 tests** (1326 passing, 9 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index d3e7ca287..fc4aeaca3 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,10 +4,10 @@ | | | |---|---| -| **Verified** | 2026-10-24 with `cargo test` | -| **Passing** | 1,325 | +| **Verified** | 2026-10-25 with `cargo test` | +| **Passing** | 1,326 | | **Ignored** | 9 (see [Ignored Tests](#ignored-tests)) | -| **Total** | 1,334, including the golden-file corpus (#391) | +| **Total** | 1,335, including the golden-file corpus (#391) | Not counted above: the `BrowserDb` tests, which run only on wasm32 with `wasm-bindgen-test` in a browser (see [Browser](#browser)). @@ -99,7 +99,8 @@ These tests compare the database against an independent answer rather than a sin - **Float constants** (`tests/float_constant_test.rs`, 2 tests; #706) - A float constant matches equal floats only: `1e-17` does not match `0.0`; `0.0` matches `-0.0`. - - Checked in a pattern, bound to a `$slot`, in a rule body, in a `not` body and in an `=` predicate; in memory, on file before and after a checkpoint, and after reopen. + - Checked in a pattern, bound to a `$slot`, in a rule body, as a rule call's argument, in a `not` body and in an `=` predicate; in memory, on file before and after a checkpoint, and after reopen. + - `Value`'s hash gives `-0.0` and `0.0` the same hash (`src/graph/types.rs` unit test, #750), so a rule call's `0.0` returns `-0.0`'s row in every run. - **Keyword or ref in `=` / `!=`** (`tests/keyword_ref_expression_test.rs`, 4 tests; #744) - An expression's `=` and `!=` equate a keyword with a ref to the entity it names, as the pattern constant and the join do: either operand order, a keyword constant or value, a bound `Val(Ref)`, a binding expression, a rule body, a `not` clause, an `or` branch and a keyword whose name is a UUID; a keyword still equals the same keyword only. - In memory, on file after a checkpoint, and after reopen. diff --git a/src/graph/types.rs b/src/graph/types.rs index 23c3b948e..2eadab8fc 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -92,6 +92,10 @@ impl std::hash::Hash for Value { Value::Float(f) => { if f.is_nan() { 0_u8.hash(state); + } else if *f == 0.0 { + // `-0.0 == 0.0`, so both must hash alike (#750, #545 on v3). + 2_u8.hash(state); + 0_u64.hash(state); } else if f.is_sign_negative() { 1_u8.hash(state); (-f).to_bits().hash(state); @@ -724,4 +728,21 @@ mod tests { } } } + + /// Equal values hash alike (#750): `-0.0 == 0.0`. + #[test] + fn equal_floats_hash_alike() { + use std::hash::{BuildHasher, RandomState}; + let h = RandomState::new(); + assert_eq!( + h.hash_one(Value::Float(-0.0)), + h.hash_one(Value::Float(0.0)), + "-0.0 and 0.0" + ); + assert_ne!( + h.hash_one(Value::Float(-1.0)), + h.hash_one(Value::Float(1.0)), + "-1.0 and 1.0" + ); + } } diff --git a/tests/float_constant_test.rs b/tests/float_constant_test.rs index 8cb931c80..8373ff1e4 100644 --- a/tests/float_constant_test.rs +++ b/tests/float_constant_test.rs @@ -50,13 +50,11 @@ fn assert_float_constants_exact(db: &Minigraf) { "[:find ?e :where [?e :m/x -0.0]]", "[:find ?e :where [?e :m/x ?v] [(= ?v 0.0)]]", "[:find ?e :where (zeroish ?e)]", + // Joined by `Value`'s hash, which once told `-0.0` from `0.0` (#750). + "[:find ?e :where (xval ?e 0.0)]", ] { assert_eq!(names_of(&query(db, q)), [":neg", ":zero"], "{q}"); } - // A rule call's literal joins by `Value`'s hash on v2.x, which tells - // `-0.0` from `0.0` (v3.0.0 pairs them, #545); only `1e-17` is checked. - let r = query(db, "[:find ?e :where (xval ?e 0.0)]"); - assert_eq!(names_of(&r), [":zero"], "a rule call's float argument"); let r = query(db, "[:find ?e :where (xval ?e 0.00000000000000001)]"); assert_eq!(names_of(&r), [":tiny"], "a rule call's float argument"); for q in [