From 2a4b6302245a6918940b0d4b9fc0639fa1173f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:01:29 +0000 Subject: [PATCH 1/2] simd_int_ops: mask_andnot / mask_andnot_assign (mask-op family; D-LGJ-W8 PR-N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dst = a & !b word-wise, following the mask_and/mask_or family shape exactly (U64x8-chunked polyfill loop + scalar tail, length-mismatch panics, #[inline]). Re-exported through ndarray::simd (the sanctioned consumer path) alongside the existing mask ops. Tail semantics documented on both fns: !b sets b's tail bits, but a & !b is word-wise a subset of a, so dst's tail is zero whenever a's tail is zero — the same pre-conforming-inputs contract mask_or carries. A caller holding a possibly-non-conforming a clears the tail itself (the lance-graph-java kernel does, against its known row count). Tests (5): parity vs inline scalar reference across 13 lengths straddling the 8-word group boundary (incl. the 2-word/70-row shape); algebra identities ((a&!b)|(a&b)==a, (a&!b)&b==0, with a non-vacuity check); the two-arm conforming-tail falsifier; should_panic length-mismatch arms for both fns. Disable-run performed centrally: the vectorized op flipped to & — parity + algebra went red, restored, 51/51 green; clippy -D warnings + fmt clean. Blackboard entry records the consumer wave (lance-graph-java D-LGJ-W8, lgj_mask_andnot behind Mask.minus) and the explicit W1a deviation (free-fn family shape over the struct-method litmus; council-surfaced, rationale in the entry). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Pud4qpxFHwqyqDjSabQbs --- .claude/blackboard.md | 37 ++++++++++ src/simd.rs | 4 +- src/simd_int_ops.rs | 167 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/.claude/blackboard.md b/.claude/blackboard.md index 60c9de36..7c516775 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -918,3 +918,40 @@ running multi-hundred-MiB derivations. break cost bumps in the other direction: a reader shipped before the writer would reject the new profile. A bounded budget keeps the forward compatibility the header format exists for. + +## 2026-08-18 — mask_andnot / mask_andnot_assign added for lance-graph-java D-LGJ-W8 + +Added `mask_andnot(a, b, dst)` (`dst = a & !b`) and `mask_andnot_assign(a, b)` +(`a &= !b`) to `src/simd_int_ops.rs`, re-exported through +`ndarray::simd::{mask_andnot, mask_andnot_assign}`. Consumer: lance-graph-java +wave D-LGJ-W8 — the mask-native navigation correction — `lgj_mask_andnot` +behind `Mask.minus`. + +Both follow the existing mask-op family shape exactly: `U64x8`-chunked +polyfill dispatch with a scalar tail, panic-on-length-mismatch, and the +tail-bit semantics documented precisely (`dst`'s tail is zero whenever `a`'s +tail is zero, because `a & !b` is a bitwise subset of `a` — the same +pre-conforming-inputs contract `mask_or` already carries). Parity vs an +independent scalar reference, algebra identities +(`(a & !b) | (a & b) == a`, `(a & !b) & b == 0`), and a dedicated +tail-conformance falsifier (a conforming `a` against a maximally dirty `b`, +including a `b`-tail-only-dirty arm) are all in place. + +**EXPLICIT W1a DEVIATION RECORD.** The pair follows the existing mask-op +*family* shape (free functions re-exported through `ndarray::simd`) rather +than the W1a struct-method litmus — +`.claude/knowledge/vertical-simd-consumer-contract.md:325-326` would reject +a free fn for a *new* primitive. Rationale: `mask_andnot` / +`mask_andnot_assign` are not a new primitive shape, they are the fifth and +sixth members of the existing `mask_and` / `mask_and_assign` / `mask_or` / +`mask_or_assign` free-function family; a lone struct-method member sitting +beside four free-fn siblings would fragment exactly the polyfill surface +the `simd.rs` re-export comment (:686-693) protects, not honor it. All +other W1a criteria hold in full: +parity vs scalar reference, tail-bit semantics documented, all backends +reached via the existing polyfill dispatch (`crate::simd::U64x8`, which +resolves to AVX-512 / AVX2 / NEON-scalar / wasm-scalar / portable-scalar +per target — every arm confirmed to carry `Not`). Deviation was +council-surfaced (5+3, S2-7) and operator-visible, not smuggled. + +Loose ends: none. diff --git a/src/simd.rs b/src/simd.rs index 22150be6..ca8d27ba 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -693,8 +693,8 @@ pub use crate::simd_amx::{amx_report, cpu_model, CpuModel}; // bits zero. See `src/simd_int_ops.rs` for the full statement. #[cfg(feature = "std")] pub use crate::simd_int_ops::{ - eq_u32_strided_to_mask, eq_u32_to_mask, gt_i32_to_mask, mask_and, mask_and_assign, mask_or, mask_or_assign, - masked_sum_i32, + eq_u32_strided_to_mask, eq_u32_to_mask, gt_i32_to_mask, mask_and, mask_and_assign, mask_andnot, mask_andnot_assign, + mask_or, mask_or_assign, masked_sum_i32, }; // The popcount that closes the loop on the masks above: `mask_count` in ABI // terms. Already public at `ndarray::bitwise::popcount_batch_u64`; re-exported diff --git a/src/simd_int_ops.rs b/src/simd_int_ops.rs index d028c33c..b3f2b7a2 100644 --- a/src/simd_int_ops.rs +++ b/src/simd_int_ops.rs @@ -855,6 +855,77 @@ pub fn mask_or_assign(dst: &mut [u64], src: &[u64]) { } } +/// `dst = a & !b`, elementwise over `u64` mask words — "a minus b" as a +/// bitmask set difference (every bit set in `a` but not in `b`). +/// +/// # Tail-bit semantics +/// +/// `!b` sets every bit of `b`'s tail — the padding bits past whatever +/// logical row count `b` represents — because bitwise NOT has no notion of +/// "past the end" and will happily flip a conforming (zero) tail to all +/// ones. That looks like the same hazard [`mask_or`] warns about, but the +/// AND with `a` recovers it: `a & !b` is a bitwise subset of `a` (every bit +/// set in the result is also set in `a`), so **`dst`'s tail is zero +/// whenever `a`'s tail is zero, regardless of what `!b`'s tail does.** This +/// is the same pre-conforming-inputs contract `mask_or` documents — a +/// caller holding a possibly-non-conforming `a` must clear `a`'s tail +/// itself (the lgj-abi kernel does, against its own known `n_rows`); a +/// conforming `a` composes safely against any `b`, tail included. +/// +/// `dst` must not overlap `a` or `b`; use [`mask_andnot_assign`] for the +/// in-place case (Rust's borrow rules already prevent the overlap in safe +/// code, so this is a note about which function to reach for, not a +/// hazard). +/// +/// # Panics +/// +/// Panics unless `a.len() == b.len() == dst.len()`. +#[inline] +pub fn mask_andnot(a: &[u64], b: &[u64], dst: &mut [u64]) { + assert_eq!(a.len(), b.len(), "mask_andnot: a/b length mismatch"); + assert_eq!(a.len(), dst.len(), "mask_andnot: a/dst length mismatch"); + let n = a.len(); + + const L: usize = crate::simd::U64x8::LANES; + let groups = n / L; + for g in 0..groups { + let off = g * L; + let va = crate::simd::U64x8::from_slice(&a[off..]); + let vb = crate::simd::U64x8::from_slice(&b[off..]); + (va & !vb).copy_to_slice(&mut dst[off..]); + } + for i in (groups * L)..n { + dst[i] = a[i] & !b[i]; + } +} + +/// `a &= !b`, elementwise over `u64` mask words. +/// +/// The in-place form of [`mask_andnot`] — same tail-bit contract: the +/// result is a bitwise subset of the (pre-update) `a`, so `a`'s tail stays +/// zero whenever it started zero, regardless of what `b`'s tail holds. +/// +/// # Panics +/// +/// Panics if `a.len() != b.len()`. +#[inline] +pub fn mask_andnot_assign(a: &mut [u64], b: &[u64]) { + assert_eq!(a.len(), b.len(), "mask_andnot_assign: length mismatch"); + let n = a.len(); + + const L: usize = crate::simd::U64x8::LANES; + let groups = n / L; + for g in 0..groups { + let off = g * L; + let va = crate::simd::U64x8::from_slice(&a[off..]); + let vb = crate::simd::U64x8::from_slice(&b[off..]); + (va & !vb).copy_to_slice(&mut a[off..]); + } + for i in (groups * L)..n { + a[i] &= !b[i]; + } +} + /// Sum of `values[i]` where mask bit `i` is set, widened to `i64`. /// /// Bit order is the module convention: element `i` is bit `i % 64` of @@ -1792,6 +1863,102 @@ mod tests { mask_and(&[0u64; 4], &[0u64; 3], &mut dst); } + // ── mask_andnot (a & !b) ───────────────────────────────────────────────── + + #[test] + fn mask_andnot_matches_scalar_reference() { + // Same length set as `mask_and_or_match_scalar_reference`, straddling + // the 8-word U64x8 group boundary; len=2 is the `mask_words_for(70)` + // shape (70 rows -> 2 words, a 6-bit tail in the second word). + for &len in &[0usize, 1, 2, 7, 8, 9, 15, 16, 17, 31, 63, 64, 100] { + let mut seed = 0xA11C_E5EE_D000_0001; + let a: Vec = (0..len).map(|_| splitmix64(&mut seed)).collect(); + let b: Vec = (0..len).map(|_| splitmix64(&mut seed)).collect(); + + let ref_andnot: Vec = a.iter().zip(&b).map(|(x, y)| x & !y).collect(); + + let mut dst = vec![0xDEAD_BEEFu64; len]; + mask_andnot(&a, &b, &mut dst); + assert_eq!(dst, ref_andnot, "mask_andnot len={len}"); + + let mut dst = a.clone(); + mask_andnot_assign(&mut dst, &b); + assert_eq!(dst, ref_andnot, "mask_andnot_assign len={len}"); + } + } + + #[test] + fn mask_andnot_algebra_identities() { + let mut seed = 0x1357_9BDF_2468_ACE0; + let a: Vec = (0..20).map(|_| splitmix64(&mut seed)).collect(); + let b: Vec = (0..20).map(|_| splitmix64(&mut seed)).collect(); + + // (a & !b) | (a & b) == a — partitioning a's bits by whether b also + // has them set recovers a exactly. + let mut a_andnot_b = vec![0u64; 20]; + mask_andnot(&a, &b, &mut a_andnot_b); + let mut a_and_b = vec![0u64; 20]; + mask_and(&a, &b, &mut a_and_b); + let mut recombined = vec![0u64; 20]; + mask_or(&a_andnot_b, &a_and_b, &mut recombined); + assert_eq!(recombined, a, "(a & !b) | (a & b) == a"); + + // (a & !b) & b == 0 — the "not b" half can never overlap b. + let mut overlap = vec![0u64; 20]; + mask_and(&a_andnot_b, &b, &mut overlap); + assert_eq!(overlap, vec![0u64; 20], "(a & !b) & b == 0"); + + // ...and non-trivially so: on this corpus a_andnot_b must actually + // differ from a (b removes real bits), or both identities above hold + // vacuously of a no-op. + assert_ne!(a_andnot_b, a, "andnot must actually remove bits on this corpus"); + } + + #[test] + fn mask_andnot_preserves_conforming_tail() { + // 2 words = the `mask_words_for(70)` shape: word 0 fully valid (rows + // 0..63), word 1 valid only in its low 7 bits (rows 64..70); the + // tail is word 1 bits 7..63, which a conforming mask always holds + // zero. + const TAIL_MASK: u64 = !0x7Fu64; // bits 7..63 + + // Arm 1: a conforms (tail zero), b is maximally non-conforming (all + // bits set, including its own tail) — dst must still be zero + // everywhere, tail included, because `a & !b` can never exceed `a`. + let a = [0x1234_5678_9ABC_DEF0u64, 0x0000_0000_0000_005Bu64]; + assert_eq!(a[1] & TAIL_MASK, 0, "fixture precondition: a's tail is zero"); + let b = [u64::MAX; 2]; + let mut dst = [0xDEAD_BEEFu64; 2]; + mask_andnot(&a, &b, &mut dst); + assert_eq!(dst, [0u64, 0u64], "a & !(all-ones) == 0, tail included"); + + // Arm 2: a still conforms; b's body is zero (so it removes nothing + // from a) but b's tail is dirty (all ones) — exactly the shape where + // `!b` flips a normally-zero tail to all ones. dst must equal a + // exactly, and in particular dst's tail must stay zero: a's tail was + // already zero, and `a & !b` can only ever narrow a, never widen it. + let b_dirty_tail = [0u64, TAIL_MASK]; + assert_ne!(b_dirty_tail[1] & TAIL_MASK, 0, "fixture precondition: b's tail is dirty"); + let mut dst = [0xDEAD_BEEFu64; 2]; + mask_andnot(&a, &b_dirty_tail, &mut dst); + assert_eq!(dst, a, "a & !b == a when b's body is 0, even with a dirty b tail"); + assert_eq!(dst[1] & TAIL_MASK, 0, "dst's tail stays zero despite b's dirty tail"); + } + + #[test] + #[should_panic(expected = "length mismatch")] + fn mask_andnot_rejects_length_mismatch() { + let mut dst = [0u64; 4]; + mask_andnot(&[0u64; 4], &[0u64; 3], &mut dst); + } + + #[test] + #[should_panic(expected = "length mismatch")] + fn mask_andnot_assign_rejects_length_mismatch() { + let mut a = [0u64; 4]; + mask_andnot_assign(&mut a, &[0u64; 3]); + } + #[test] #[should_panic(expected = "out_words.len()")] fn eq_u32_to_mask_rejects_short_destination() { From 69d9013d52776f782d4ae70617d504fd4953fddb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 03:43:14 +0000 Subject: [PATCH 2/2] plan: chacha20 0.9.1 -> 0.10.1 matryoshka port, spec v1 Der Fork in vendor/chacha20 haengt auf 0.9.1 und cipher 0.4; upstream 0.10.1 sitzt auf cipher 0.5. Die Spec haelt fest, was der AdaWorldAPI- Delta tatsaechlich ist -- gemessen gegen das pristine 0.9.1-Crate aus dem Registry-Cache, nicht geraten: 4 Quell-Hunks + 1 neue Datei + ein Manifest-Rewrite. Der Grund fuer das Ledger oben im Dokument: mehrere Punkte liessen sich in diesem Container NICHT verifizieren, und die sind als UNVERIFIED markiert statt geraten -- (a) die genauen encrypt/decrypt-Signaturen von aead 0.6, (b) ob cargo die Form "dasselbe Crate zweimal unter einem Rename patchen" akzeptiert, (c) Durchsatz ndarray_simd vs. der neue backends/avx512.rs von upstream. Punkt (c) entscheidet, ob der x86-Arm ueberhaupt portiert werden sollte, und ist auf BEIDEN Seiten ungemessen. Nur die Spec, kein Code. Nichts gebaut, nichts gemessen. --- .../plans/chacha20-010-matryoshka-port-v1.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 .claude/plans/chacha20-010-matryoshka-port-v1.md diff --git a/.claude/plans/chacha20-010-matryoshka-port-v1.md b/.claude/plans/chacha20-010-matryoshka-port-v1.md new file mode 100644 index 00000000..14daf17a --- /dev/null +++ b/.claude/plans/chacha20-010-matryoshka-port-v1.md @@ -0,0 +1,224 @@ +# chacha20 0.9.1 → 0.10.1 matryoshka port — spec v1 + +> **Status: sources read, not built.** Structural claims cite a file:line that +> exists in this container. Points that could not be verified from source here +> are marked **UNVERIFIED** and are not guessed. + +## 0. Verification ledger + +| artefact | where | status | +|---|---|---| +| the fork | `vendor/chacha20/` | read, 10 files | +| pristine chacha20 0.9.1 | `~/.cargo/registry/cache/*/chacha20-0.9.1.crate` | read via `tar -xzO`, diffed | +| upstream chacha20 0.10.1 | `~/.cargo/registry/src/*/chacha20-0.10.1/` | read, full source | +| `cipher` 0.5.2 | fetched `.crate` | read `src/lib.rs`, `src/stream/core_api.rs`, manifest | +| `cipher` 0.4.4 | registry | read `src/stream_core.rs` | +| `chacha20poly1305` 0.10.1 / 0.11.0 | registry + index + `.crate` | manifests + `src/lib.rs` | +| `poly1305` 0.9.0 | fetched `.crate` | `src/backend.rs`, manifest | +| `aead` 0.6.1 | fetched `.crate` | grep of exports only — **partial** | +| MedCare-rs / OGAR / a2ui-rs lockfiles | `/home/user/*/Cargo.lock` | read | + +**UNVERIFIED, left open:** (a) `aead` 0.6's exact `encrypt`/`decrypt` signatures; +(b) whether cargo accepts the "patch same crate twice under a rename" form +(Option B, §5.4) — settle with `cargo metadata`, not by reading; (c) throughput +of `ndarray_simd` vs upstream's new `backends/avx512.rs` — unmeasured on both +sides, and it decides whether the x86 arm is worth porting at all. + +## 1. The delta to re-apply + +Diffed against pristine 0.9.1: the AdaWorldAPI delta is **4 source hunks + 1 new +file + a manifest rewrite**. `legacy.rs`, `xchacha.rs`, `backends/{soft,sse2,avx2,neon}.rs`, +`tests/mod.rs` are **byte-identical** to upstream. + +- **Hunk 1** `src/lib.rs:110-113` — `#![allow(unexpected_cfgs)]`. **Dies in the + port**; 0.10 declares cfgs via `check-cfg` in the manifest instead. +- **Hunk 2** `src/backends.rs:6-23` — declares `ndarray_simd` under + `any(all(x86_64, avx512f, …), all(wasm32, simd128))`. **Structural problem:** + this arm *replaces* the x86 subtree, so `soft`/`avx2`/`sse2` are not compiled + at all. Survivable at 0.9.1; **not** at 0.10 (see Hunk 6). +- **Hunk 3** `src/lib.rs:161-172` — `type Tokens = ();` (the "no runtime probe" property). +- **Hunk 4** `src/lib.rs:233-242` — the mirror: `let tokens = ();`. +- **Hunk 5** `src/lib.rs:273-290` — dispatch `f.call(&mut backends::ndarray_simd::Backend(self))`. +- **New file** `src/backends/ndarray_simd.rs` (110 lines) — vertical layout, 16 + blocks in parallel, word `w` of every block across the 16 lanes of one + `U32x16`, counter word 12 carrying lane index (`:76-81`). No cross-lane + shuffle, no `unsafe`, no intrinsics. `ParBlocksSize = U16` (`:30-32`). +- **Manifest** — `rust-version` 1.95, empty `[workspace]`, and the one + non-upstream dependency: `ndarray` as a target-dep with `features = ["std"]` + (required — `ndarray::simd` is `#[cfg(feature = "std")]`, `src/lib.rs:239-241`). +- **Hunk 6 (NEW at 0.10)** `src/rng.rs:49-90` carries a **second independent copy + of the backend dispatch tree**, including `let (avx2_token, sse2_token) = self.tokens;`. + With `Tokens = ()` and `feature = "rng"` active this **fails to compile**. The + delta grows from four hunks to five, in a file that did not exist before. + +## 2. `cipher 0.4.4 → 0.5.2`, as it touches the fork + +**The backend trait's shape is unchanged** (`cipher-0.5.2/src/stream/core_api.rs:10-31` +vs `cipher-0.4.4/src/stream_core.rs:10-31`): same three methods, same defaults, +`ParBlocksSizeUser`/`BlockSizeUser` verbatim. The `ks16` algorithm ports with +**zero changes**. + +Renames: `StreamBackend` → `StreamCipherBackend`; `StreamClosure` → +`StreamCipherClosure`; `generic_array::GenericArray` → `cipher::array::Array` +(hybrid-array); `crate::Block` → `crate::chacha::Block`. + +Semantic changes: + +- **(a)** `R: Unsigned` → `R: Rounds` with `const COUNT` (`lib.rs:53-80`): + `ks16` → ``, `R::USIZE` → `R::COUNT`. +- **(b)** `ChaChaCore` gains `V: Variant` (`lib.rs:119-127`, `variants.rs:9-84`): + every impl becomes `impl … for Backend<'_, R, V>`. +- **(c) THE CORRECTNESS TRAP — the counter is 64-bit for `Variant = Legacy`** + (`variants.rs:66`). It occupies `state[12..14]`. `ndarray_simd.rs:41`, `:50`, + `:76-81` only ever touch `state[12]`. Consequences: `orig[13]` can no longer be + a `splat` (lanes straddle a carry within 15 of a 2^32 boundary); both advance + sites need the 64-bit carry under a `size_of::() == 8` guard. + **Invisible to RFC 8439** (IETF vectors are `Counter = u32`) — caught only by + legacy KATs. Upstream's shape: `backends/avx512.rs:47-56`, `:66-79`. +- **(d) Opportunity:** with `ParBlocksSize = U16` the default `gen_tail_blocks` + wastes up to 240 block computations per tail. Override it (upstream does: + `avx512.rs:315-338`). + +`cipher 0.5` has **no `std` feature** — the fork's `std = ["cipher/std"]` cannot +survive (0.10 removed `std` outright). + +## 3. The wasm question — the premise needs correcting + +**`cpufeatures` is NOT unconditional at 0.10, and never was at 0.9.** It is +`[target.'cfg(any(target_arch = "x86_64", target_arch = "x86"))']`-scoped at +**both** versions. A `wasm32-unknown-unknown` build of stock upstream 0.10.1 +pulls no `cpufeatures` and compiles no per-arch intrinsics — the cfg chain +(`backends.rs:5-31`) falls through to `soft`. + +So the framing "stock upstream pulls cpufeatures, which wasm cannot use" is +**not correct as a compilability claim**. What the fork actually buys on wasm is +**throughput**: a 16-wide `U32x16` backend that `ndarray::simd` lowers to the +native `[U32x4; 4]` simd128 lane (`src/simd.rs:404-406`) instead of the +1-block-at-a-time `soft` backend. + +Consequence for the port: keeping the wasm path free of `cpufeatures` costs +**nothing** — there is nothing to keep it free of. The wasm work is exactly one +thing: preserve the `all(wasm32, simd128)` arm in both cfg chains and keep the CI +gate green. + +Also: 0.10 raises edition to 2024 / MSRV 1.85. Repo pins 1.97.1, so satisfied — +but the fork manifest's `edition = "2021"` / `rust-version = "1.95"` must move. + +## 4. The new dependencies — all three optional + +| dep | req | optional | gate | +|---|---|---|---| +| `cipher` | `^0.5` (`stream-wrapper`) | yes | default feature `cipher` | +| `rand_core` | `^0.10` | yes | feature `rng` | +| `zeroize` | `^1.8.1` | yes | implicit `dep:` feature | +| `cpufeatures` | `^0.3` | no | **x86 target-dep only** | + +**`rand 0.10`'s `std_rng` is a default feature and enables `chacha20/rng`.** So +the moment the fork carries 0.10 in a graph with default-featured `rand`, +`src/rng.rs` compiles — which promotes Hunk 6 from theoretical to mandatory. +Already true in MedCare-rs (`Cargo.lock`: `rand 0.10.2 → chacha20 0.10.1`). + +## 5. The resolution problem, and the trap in the obvious fix + +### 5.1 The half-applied patch is in MedCare-rs, not ndarray + +MedCare's lock carries **two** chacha20 nodes: `0.9.1` from the ndarray fork +(via `chacha20poly1305 0.10.1`) and `0.10.1` from the **registry** (via +`rand 0.10.2`). No warning. + +**ndarray's own workspace cannot reproduce it** — its only `^0.10` requirer +(`rand` via `quickcheck`) resolves without `std_rng`, and `crates/burn`, which +does declare it, is excluded. That shapes falsifier F3. + +### 5.2 THE TRAP — the obvious fix inverts the bug + +`chacha20poly1305 0.10.1` requires **`chacha20 ^0.9`**. So naively bumping the +fork to 0.10.1 produces: + +- `chacha20poly1305` no longer matches the patch → resolves **registry 0.9.1** → + the AEAD, the actual production consumer, **loses the fork entirely**; +- `rand` now matches → the fork accelerates a CSPRNG nobody asked to accelerate. + +Strictly worse than today. **The version carry and the AEAD bump are one atomic +change.** Target: `chacha20poly1305 0.11.0` (requires `chacha20 ^0.10` with +`xchacha`, `aead ^0.6`, `cipher ^0.5`, `poly1305 ^0.9`). + +### 5.3 Three second-order consequences of the AEAD bump + +- **(a) `zeroize` stops being automatic.** 0.10.1 forced `chacha20/zeroize`; + 0.11.0 makes it opt-in. `crates/encryption` passes `default-features = false`, + so after the bump the ChaCha state is **no longer zeroized on drop** unless + `"zeroize"` is added. Silent security regression. +- **(b) `poly1305_force_soft` becomes a no-op.** poly1305 0.9 renamed the cfg to + `poly1305_backend="soft"`. The old flag is silently ignored and the + 424-intrinsic AVX2 surface returns — the exact second unaudited SIMD surface + the matryoshka pattern exists to prevent. `.cargo/config.toml` must change in + the same commit. +- **(c)** `crates/encryption/src/aead.rs` import migration; `from_slice` maps to + `hybrid_array::Array::from_slice`. **UNVERIFIED:** `aead 0.6` method signatures. + +### 5.4 Two options + +**Option A (recommended)** — one fork carrying 0.10.1, AEAD bumped with it, +poly1305 cfg renamed. One chacha20 in every consumer graph. + +**Option B** — two forks, one per major, both patched. Doubles the maintenance +surface of a crypto fork against two `cipher` majors, forever. **UNVERIFIED** +that cargo accepts the rename form here. + +## 6. Falsifiers, each with the disable that proves it real + +- **F1a — RFC 8439 through the matryoshka backend.** `RUSTFLAGS="-Ctarget-cpu=x86-64-v4" + cargo test --all-features` in `vendor/chacha20`. + *Disable:* change a rotate constant in `ndarray_simd.rs:64-70`. If still green, + the vectors are not reaching `ndarray_simd` and F2 is what is broken. + *Port note:* `tests/mod.rs:174-223` is a currently-dead `legacy` module that + will start compiling at 0.10 and fail — replace with upstream's `tests/kats.rs`. +- **F1b — the 64-bit counter, which F1a cannot see.** Seek `ChaCha20Legacy` to + `block_pos = 2^32 - 8`, compare 16 blocks against a `soft` build. + *Disable:* drop the `state[13]` write. F1a stays green; F1b must go red. +- **F2 — two-sided proof `ndarray_simd` is selected.** Positive: at v4, `ndarray` + is a compiled dep of the fork. **Negative: at the default v3 it is NOT** — the + arm that proves the gate is a gate. + *Disable:* delete `target_feature = "avx512f"`; the negative side must go red. +- **F3 — exactly ONE chacha20 resolves.** `cargo tree -d | grep -c '^chacha20'` == 0. + Needs a **canary dev-dep** (`rand` with `std_rng`) in `crates/encryption`, since + ndarray's own graph cannot currently fail this. + *Disable:* revert the fork to 0.9.1 with the canary in place → two nodes. +- **F4 — wasm gate.** Extend `ci.yaml:141-142` with `--all-features` (so `rng`, + i.e. Hunk 6, compiles for wasm) and a `cargo tree --target wasm32 | grep -c + cpufeatures` == 0 assertion. + *Disable:* removing the wasm arm still builds (falls to `soft`), so the build + alone is **not** a falsifier — needs a numeric parity assertion. +- **F5 — the poly1305 cfg rename did not disarm.** `cargo build -p encryption -v + | grep poly1305_backend` must hit. + *Disable:* leave the old flag; grep comes back empty. +- **F6 — `encryption`'s 38 tests on both tiers** (default v3 and v4 matryoshka). + +## 7. Risk register and sequencing + +Highest-severity rows: `zeroize` silently dropped (§5.3a); poly1305 AVX2 surface +silently re-armed (§5.3b); the 64-bit counter carry (§2c); and **cross-repo** — +MedCare's `[patch]` is a *branch pointer*, so it picks up the new version on the +next `cargo update` and its `chacha20poly1305` must move to 0.11 in the same +window or its AEAD silently drops to the registry crate. + +**The unmeasured question this port does not answer:** upstream 0.10 now ships +`backends/avx512.rs` — 16-parallel-block, `ParBlocksSize = U16`, the exact niche +`ndarray_simd.rs` was written to fill, opt-in via `--cfg chacha20_avx512`. +Neither side is benchmarked. The *architectural* justification (no raw intrinsics +in the crypto crate) still holds; the *performance* one is unmeasured. **Measure +before porting** — a "lose" verdict shrinks the port to the wasm arm alone and +removes Hunks 3, 4 and 6 entirely. + +**Order:** 1. measure (all else is conditional) → 2. vendor pristine 0.10.1 and +gate it green with *zero* delta applied (isolates "faithful copy" from "correct +delta") → 3. port `ndarray_simd.rs` (F1a+F1b) → 4. re-apply cfg hunks, keeping +`soft`/`avx2`/`sse2` compiled so `rng.rs` resolves (F2) → 5. bump `encryption` to +chacha20poly1305 0.11 with `zeroize` (F6) → 6. rename the poly1305 cfg (F5) → +7. canary dev-dep + `cargo tree -d` gate (F3) → 8. extend the wasm CI gate (F4) → +9. update the prose that would otherwise be wrong → 10. coordinate MedCare-rs. + +Steps 2-8 are independently revertible. The one irreversible-in-practice coupling +is 5 → 10 (the AEAD bump crossing repos): prepare MedCare's side unmerged so the +window is minutes.