CI / test (pull_request) Canceled after 0s
The SIMD migration weakened the near-zero-norm guard in all four clawhdf5-accel cosine_similarity backends (scalar/avx2/avx512/neon) from `denom < f32::EPSILON` to `denom == 0.0`. Vectors with a tiny but nonzero norm (denom in (0, 1.19e-7)) fell through to dot/denom and scored as identical instead of maximally dissimilar, diverging from the pre-SIMD scalar loop's documented fallback behavior. Restores the epsilon threshold in all four backends so `1.0 - cosine_similarity(...)` in hnsw.rs::compute_distance reproduces the old fallback exactly. Adds regression tests in clawhdf5-accel and clawhdf5-ann locking in the near-zero-norm case. Co-Authored-By: Claude Sonnet 5 <[email protected]>
202 lines
10 KiB
Markdown
202 lines
10 KiB
Markdown
# Verification Brief — branch `verify/v3-plus-v6`
|
|
|
|
Independent audit of three already-implemented fixes:
|
|
|
|
- **P1** — `clawhdf5-ann::hnsw::compute_distance` now delegates to `clawhdf5-accel`'s
|
|
runtime-dispatched SIMD kernels (`l2_distance`, `cosine_similarity`) instead of
|
|
scalar loops.
|
|
- **P2** — `clawhdf5-io::async_read::AsyncFileReader` now opens the file handle
|
|
once and caches it + its length behind a `tokio::sync::Mutex`.
|
|
- **PR1** — `clawhdf5-migrate` writes SHINES provenance (`hdf5_writer.rs`) and
|
|
verifies it on read-back (`validate.rs`).
|
|
|
|
Branch state audited: `verify/v3-plus-v6` @ `07b7301` (merge of the v3 ann/io/migrate
|
|
work and v6 agent/format work). All three areas' existing test suites
|
|
(`cargo test -p clawhdf5-accel -p clawhdf5-ann -p clawhdf5-io --features async
|
|
-p clawhdf5-migrate --release`) pass — 41 + 23 + 89 + 26 tests green. That is
|
|
expected: the defect below is a numerical edge case none of the existing tests
|
|
exercise.
|
|
|
|
---
|
|
|
|
## P1 — SIMD distance in `clawhdf5-ann` — DEFECT FOUND
|
|
|
|
**File:** `crates/clawhdf5-accel/src/scalar.rs`, `avx2.rs`, `avx512.rs`, `neon.rs`
|
|
(all four backends share the bug identically; it surfaces in callers through
|
|
`crates/clawhdf5-ann/src/hnsw.rs:54`, `compute_distance`'s
|
|
`1.0 - clawhdf5_accel::cosine_similarity(a, b)`).
|
|
|
|
**Problem:** The near-zero-norm guard in `cosine_similarity` changed threshold
|
|
during the SIMD migration, and the new threshold is wrong.
|
|
|
|
Old scalar loop (pre-SIMD, `hnsw.rs` @ `55959b4`):
|
|
|
|
```rust
|
|
let denom = norm_a.sqrt() * norm_b.sqrt();
|
|
if denom < f32::EPSILON {
|
|
1.0
|
|
} else {
|
|
1.0 - (dot / denom)
|
|
}
|
|
```
|
|
|
|
New code, identical in all four `clawhdf5-accel` backends (e.g.
|
|
`scalar.rs:23-24`):
|
|
|
|
```rust
|
|
let denom = (norm_a * norm_b).sqrt();
|
|
if denom == 0.0 { 0.0 } else { dot / denom }
|
|
```
|
|
|
|
The old code clamped *any* near-zero denominator (anything under
|
|
`f32::EPSILON ≈ 1.19e-7`, not just exact zero) to a safe "maximally
|
|
dissimilar" result. The new code only special-cases an **exact** `0.0`
|
|
denominator; anything smaller but nonzero falls through to `dot / denom`.
|
|
|
|
For genuinely-zero vectors the two are equivalent (`denom == 0.0` in both, and
|
|
`1.0 - 0.0 == 1.0` matches the old `1.0`), and the existing test
|
|
(`hnsw.rs::cosine_zero_vector`, `clawhdf5-accel::test_cosine_zero_vector`)
|
|
only covers that case — which is why it didn't catch this.
|
|
|
|
But for vectors with a small (not exactly zero) norm, the two diverge sharply.
|
|
Concrete repro (values confirmed via a standalone build of both functions):
|
|
|
|
```
|
|
a = b = [1e-4] // tiny but nonzero, identical vectors
|
|
old cosine distance = 1.0 // "unreliable direction" fallback, correctly
|
|
// caps degenerate near-zero vectors at max distance
|
|
new cosine distance = 0.0 // computed as fully identical
|
|
```
|
|
|
|
`denom` here is `1e-8`, comfortably below `f32::EPSILON` (`1.19e-7`) but not
|
|
`== 0.0`, so the old guard fired and the new one doesn't. This is not a
|
|
narrow floating-point-rounding footgun — the divergence spans roughly three
|
|
orders of magnitude of vector norm (anything with `denom` in
|
|
`(0, 1.19e-7)`), and it flips the result from "maximally dissimilar" to
|
|
"identical," the two opposite ends of the distance range. Any HNSW cosine
|
|
index that indexes or queries a near-zero-magnitude embedding (e.g. an
|
|
embedder's output for empty/masked/degenerate input, or a soft-deleted/
|
|
zeroed-out placeholder vector) will silently rank it as a near-duplicate of
|
|
other near-zero vectors instead of correctly pushing it to the bottom of
|
|
results.
|
|
|
|
Mismatched-length and truly-empty inputs were also checked: empty vectors
|
|
(`a.len() == b.len() == 0`) behave identically old vs. new (both hit the
|
|
zero-denominator path → distance `1.0`). Mismatched lengths now panic via
|
|
`assert_eq!` in every backend, versus the old code's `for i in 0..a.len()`
|
|
(which panicked on OOB if `b` was shorter, or silently truncated to `a`'s
|
|
length if `b` was longer). No caller reaches this: `HnswIndex::build_with_metric`
|
|
and `insert` both assert equal dimensions before any `compute_distance` call,
|
|
so mismatched lengths are unreachable in practice — not flagging as a
|
|
separate defect.
|
|
|
|
**Proposed fix:** Restore the epsilon-threshold guard in all four
|
|
`clawhdf5-accel` cosine_similarity backends (`scalar.rs`, `avx2.rs`,
|
|
`avx512.rs`, `neon.rs`), replacing `if denom == 0.0 { 0.0 }` with
|
|
`if denom < f32::EPSILON { 0.0 }`, so `1.0 - cosine_similarity(...)` in
|
|
`hnsw.rs` reproduces the old `denom < f32::EPSILON → 1.0` fallback exactly.
|
|
Add a regression test in `clawhdf5-accel` (e.g.
|
|
`test_cosine_near_zero_norm_clamped`) asserting `cosine_similarity(&[1e-4],
|
|
&[1e-4])` returns `0.0` (so `1.0 - sim == 1.0`, matching the old HNSW
|
|
fallback) rather than `1.0`, and a matching test in `hnsw.rs`
|
|
(`cosine_near_zero_vector`, alongside the existing `cosine_zero_vector`) using
|
|
a tiny-but-nonzero vector pair to lock in `compute_distance == 1.0`.
|
|
|
|
TASK: INT-01 — Restore f32::EPSILON near-zero-denom guard in clawhdf5-accel cosine_similarity (all 4 backends) + regression tests
|
|
|
|
---
|
|
|
|
## P2 — Cached async file handle in `clawhdf5-io` — SOUND, no defect
|
|
|
|
**File:** `crates/clawhdf5-io/src/async_read.rs`, `AsyncFileReader::read_at` /
|
|
`::len` (lines 96-126).
|
|
|
|
Checked against the pre-fix version (diff in `b08df7b`, which per-call opened
|
|
a fresh `tokio::fs::File` and re-stat'd the length):
|
|
|
|
- **No seek/read interleaving across tasks.** `read_at` takes
|
|
`let mut guard = self.handle.lock().await` once at the top and then borrows
|
|
`file` from that guard (`guard.as_mut()`) for the rest of the function,
|
|
including both the `seek(...).await` and `read_exact(...).await` calls.
|
|
Because `file` is a live borrow of `guard`, the Rust borrow checker forces
|
|
`guard` (and therefore the lock) to stay held across both await points —
|
|
it cannot be dropped until the whole function returns. `tokio::sync::Mutex`
|
|
is specifically designed to be held across `.await` (unlike `std::sync::Mutex`),
|
|
so a second task's `read_at` call blocks at `.lock().await` until the first
|
|
task's seek+read pair has fully completed. A seek from one task can never be
|
|
followed by a read from another task on the same descriptor.
|
|
- **Lazy-init race is also covered by the same lock.** The `if guard.is_none()`
|
|
open-and-populate branch runs under the same guard acquired at the top, so
|
|
two concurrent first-callers can't both open+overwrite the cached handle;
|
|
the second one to acquire the lock sees `guard.is_some()` and reuses it.
|
|
- **Cached length staleness.** The length is cached forever once populated —
|
|
intentional and documented in the struct's doc comment ("cached for the
|
|
lifetime of this reader"). Grepped the whole workspace
|
|
(`AsyncFileReader` outside `async_read.rs` itself): zero other callers exist
|
|
yet, so there's no current code path where a caller observes a stale length
|
|
against a file that changed size mid-lifetime. If the backing file were
|
|
truncated externally during the reader's life, the stale (larger) cached
|
|
length would make `read_at` attempt to read more than remains on disk —
|
|
but that fails loudly via `read_exact`'s `UnexpectedEof` rather than
|
|
silently returning corrupted/truncated data, which is a safe failure mode,
|
|
not a correctness bug.
|
|
- **Short-read/truncation semantics.** The `offset >= file_len → empty`,
|
|
`to_read = len.min(available)` logic is byte-for-byte unchanged from the
|
|
pre-fix version; only the source of `file_len` changed (cached vs.
|
|
freshly stat'd). For the current, only-consumer-is-itself usage pattern
|
|
(open once, read many times, file not mutated externsally during the
|
|
reader's life) the observable behavior is identical to before.
|
|
|
|
No item raised for P2.
|
|
|
|
---
|
|
|
|
## PR1 — SHINES provenance in `clawhdf5-migrate` — SOUND, no defect
|
|
|
|
**Files:** `crates/clawhdf5-migrate/src/hdf5_writer.rs`,
|
|
`crates/clawhdf5-migrate/src/main.rs`, `crates/clawhdf5-migrate/src/validate.rs`,
|
|
`crates/clawhdf5-migrate/src/hdf5_reader.rs`.
|
|
|
|
- **Current-run source path / timestamp on `--incremental` merges.**
|
|
`write_hdf5` (`hdf5_writer.rs:23`) computes `timestamp = iso8601_now()`
|
|
fresh on every call — it is never read from the merged `data` struct, so
|
|
the top-level `migrated_at` attribute and the per-dataset
|
|
`.with_provenance("clawhdf5-migrate", timestamp, source_opt)` calls
|
|
(`hdf5_writer.rs:147,177,189`) always carry the current run's wall-clock
|
|
time, incremental or not. For `source_path`: `hdf5_reader::read_hdf5`
|
|
(used to load the incremental base) explicitly returns
|
|
`source_path: String::new()` with a comment noting the caller must carry
|
|
the real path forward (`hdf5_reader.rs:52-56`); `main.rs:160`
|
|
(`base.source_path = source.source_path`) does exactly that — it
|
|
overwrites the re-read base's placeholder with the *freshly re-read SQLite
|
|
source's* path before calling `write_hdf5`, not a previous run's path.
|
|
Traced through: on an `--incremental` run, both the top-level attributes
|
|
and every per-dataset provenance attribute reflect the current run, not a
|
|
stale one. `test_incremental_migration` (`main.rs`) exercises the merge
|
|
path and passes, though it doesn't assert on `source_path`/`migrated_at`
|
|
specifically — the coding phase could add that assertion as cheap
|
|
extra insurance, but it's not fixing a defect, just tightening coverage.
|
|
- **Hash-mismatch vs. absent-attribute handling.**
|
|
`verify_chunk_provenance` (`validate.rs:161-184`) returns `Err(...)`
|
|
(fails loudly, wired through `validate_hdf5`'s `?`) only on
|
|
`VerifyResult::Mismatch`, i.e. an actual recomputed-vs-stored SHA-256
|
|
disagreement. `VerifyResult::NoHash` (attribute absent, e.g. an
|
|
older output file) is handled separately — it sets `all_present = false`
|
|
and continues, returning `Ok(false)` from `verify_chunk_provenance`
|
|
(surfaced as `ValidationSummary::provenance_verified == false`, not an
|
|
error). This is correctly asymmetric: real corruption is a hard error,
|
|
merely-missing provenance metadata is a soft "unverified" signal, matching
|
|
the documented contract in the function's doc comment.
|
|
|
|
No item raised for PR1.
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Item | Verdict | Follow-up |
|
|
|------|---------|-----------|
|
|
| P1 SIMD distance | **Defect** — cosine near-zero-norm guard weakened from `< f32::EPSILON` to `== 0.0` across all 4 backends | INT-01 |
|
|
| P2 async file handle | Sound | none |
|
|
| PR1 migrate provenance | Sound | none |
|