8000 fix(types): -0.0 and 0.0 hash alike; a rule call's 0.0 returns -0.0's row in every run (#750) by adityamukho · Pull Request #752 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions src/graph/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"
);
}
}
6 changes: 2 additions & 4 deletions tests/float_constant_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Loading
0