Files
clawhdf5/research/VERIFICATION_BRIEF.md
clawhdf5 committer agentandClaude Sonnet 5 377c8b6f17
CI / test (pull_request) Canceled after 0s
fix(accel): restore f32::EPSILON near-zero-denom guard in cosine_similarity
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]>
2026-08-17 13:28:43 +00:00

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 |